office: one word for everything — forward board/reset/delete/merge/review/triage, and a help page that lists all of it

This commit is contained in:
Sam
2026-11-13 17:26:58 +11:00
parent 6dcdcbc818
commit 0717697f03
7 changed files with 150 additions and 96 deletions

View File

@@ -1,9 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Build or repair a project's panes, in a fixed order. """Build or repair a project's panes, in a fixed order.
bin/board # the project in the current folder office board # the project in the current folder
bin/board --folder ~/src/x # or another one office board --folder ~/src/x # or another one
bin/board --replace # close this project's panes and build again office board --replace # close this project's panes and build again
Every page is a workspace with one pane, named after the page. The first Every page is a workspace with one pane, named after the page. The first
workspace holds a shell: it is the pane that never exits, so the session cannot workspace holds a shell: it is the pane that never exits, so the session cannot

View File

@@ -33,7 +33,7 @@ def main():
check_only = "--check" in args check_only = "--check" in args
args = [a for a in args if not a.startswith("--")] args = [a for a in args if not a.startswith("--")]
if len(args) != 1: if len(args) != 1:
h.say("usage: bin/merge <task> [--check]") h.say("usage: office merge <task> [--check]")
return 2 return 2
issue = h.find_issue(args[0]) issue = h.find_issue(args[0])

View File

@@ -371,70 +371,65 @@ def run_on_remote(host, folder, opts):
return 0 return 0
USAGE = """office — open a folder as a project, and say what else can be run USAGE = """office — everything for one project, under one word
office <folder> … open a folder as a project
office help this page
office board … build or repair the panes
office reset <folder> … clear panes and worktrees, keep the history
office delete <folder> … remove sessions, the Paperclip project, .git, dot files
office merge <task> … rebase, check, review, merge one task
office review <task> … run ocr over one task's diff
office triage <task> … ask Mouse-1 one question about a task
Every one of these is also on your PATH under its own name (board, merge,
review, triage, project-reset, project-delete). Both spellings do the same
thing; office is the one to remember.
THE RULE THE RULE
The folder and the session live on the same machine. If you are typing inside The folder and the session live on the same machine. If you are typing inside
the session, on the machine that owns the folder, it is just: office <folder> the session, on the machine that owns the folder, it is just: office <folder>
Use --host only when the folder is on a different machine from your shell. Use --host only when the folder is on a different machine from your shell.
WHAT IT DOES OPENING A FOLDER
1. reads the folder
2. if a machine is named, checks whether that is this machine, and runs locally if so
3. if it really is another machine, checks the login and runs the same command there
4. adopts the Paperclip project, or creates it with --new
5. writes what is missing in <folder>/.harness/ and never overwrites
6. records the project in ~/.config/paperclip-harness/projects.toml
7. creates the session on that machine's daemon, then builds eight panes
COMMANDS
Each of these is its own command on your PATH, not a subcommand of office:
type `board`, not `office board`.
office <folder> --project "<Name>" adopt a project Paperclip already has office <folder> --project "<Name>" adopt a project Paperclip already has
office <folder> --new "<Name>" create the Paperclip project as well office <folder> --new "<Name>" create the Paperclip project as well
office . adopt the folder you are standing in office . adopt the folder you are standing in
office <folder> --session <name> name the session instead of the folder office <folder> --session <name> name the session instead of the folder
office <folder> --project "<Name>" --host <machine> the folder is on that machine office <folder> … --host <machine> the folder is on that machine
office <folder> --new "<Name>" --dry say what would happen, write nothing office <folder> … --dry say what would happen, write nothing
office <folder> --new "<Name>" (with no repository it asks Y or N)
office <folder> --new "<Name>" --commit-all make the repository and commit everything
office <folder> --new "<Name>" --anywhere allow it although agents cannot see it
board create any missing pane in this project's session What it does, in order: reads the folder; runs locally if the named machine is
board --folder <folder> the same, for another project this one; otherwise checks the login and runs the same command there; adopts or
creates the Paperclip project; writes what is missing in <folder>/.harness/ and
never overwrites; records the project in ~/.config/paperclip-harness/projects.toml;
creates the session on that machine's daemon; builds eight panes.
PAGES: office board [--folder <f>] [--replace] [--reset]
board create any pane that is missing, close nothing
board --replace close this project's panes, then build all eight again board --replace close this project's panes, then build all eight again
board --reset close every pane in the session, then build board --reset close every pane in the session, wait, then build
project-reset <folder> clear the panes and the worktrees, keep the history board --folder <folder> do it for another project
project-delete <folder> remove the sessions, the Paperclip project, .git and the dot files
(says what would go; add --yes to do it)
merge <task> rebase, check, review and merge one task (asks first)
review <task> [--post] run ocr over one task's diff, and record the verdict
triage <task> [--ask next|failure] ask Mouse-1 one question about a task
MACHINES (name, or the address ssh reaches) Inside a pane, R rebuilds everything and r re-reads Paperclip. After a reboot
nixos-desktop .13 192.168.20.13 hosts Paperclip, the daemon, the main session TUIOS restores a session with a bare shell in each pane and keeps the pane
sam-4screen-desktop .27 192.168.20.27 your working desktop names, so run board once: it now checks what each pane is really running and
sam-thinkpad .51 192.168.20.51 the laptop replaces any pane that is only a prompt.
EVERY MACHINE, FROM THE TERMINAL STARTING OVER
office reset <folder> clear the panes and the worktrees; keeps the
folder, .harness, the git history and every
Paperclip record
office reset <folder> --dry-run say what would go
office reset <folder> --yes do it
office reset <folder> --yes --delete-folder remove the folder too
On .13, a folder that is on .13: office delete <folder> remove the TUIOS sessions, every Paperclip
office ~/paseo/projects/test-alpha --project "test-alpha" project that claims the folder, the registry
lines, and .git / .gitignore / .harness /
On .27, adopting a folder that lives on .13: .paperclip / .pi. Keeps ordinary files.
office /home/sam/paseo/projects/email_agent --project "Email Agent" --host nixos-desktop office delete <folder> --yes do it
office delete <folder> --yes --delete-folder remove the folder as well
On .27, a folder that is on .27 (stays local, no --host):
office ~/src/thing --new "thing"
On .51, the same folder on .51:
office ~/src/thing --project "thing" --host sam-thinkpad
Then attach. Nothing may move your client from inside a pane, so the last step is yours:
tuios attach <session> on this machine
tuios attach --host nixos-desktop <session> to a session on another machine
WORKTREES, AND WHY GIT MATTERS WORKTREES, AND WHY GIT MATTERS
A new project gets a worktree policy: Paperclip makes a branch and a separate A new project gets a worktree policy: Paperclip makes a branch and a separate
@@ -445,51 +440,74 @@ WORKTREES, AND WHY GIT MATTERS
When the folder cannot do that yet, office asks, and never writes history: When the folder cannot do that yet, office asks, and never writes history:
Y make the repository, write .gitignore containing .paperclip/, stage Y make the repository, write .gitignore containing .paperclip/, and stop.
everything, and stop. Commit in lazygit, then run this again. Nothing is staged. Ignore first, then stage, then commit, in lazygit.
N create the project in place (local_path): a task edits the folder N create the project in place (local_path): a task edits the folder
directly, there is no diff and nothing to merge. directly, there is no diff and nothing to merge.
Y leaves you in lazygit's hands on purpose: what you commit is what the agent Y opens a session with lazygit already in the folder, on the machine that owns
will see, and .gitignore is how you keep a file out of its view. it, and tells you how to attach. Ignore with i, stage all with a, commit with c.
--commit-all does the whole thing in one go, for anyone who does not care. Then run the same office command again and it creates the project.
The base branch is read from the folder, not assumed to be main. .paperclip/ is written into .gitignore every time and is not optional: it is
where Paperclip puts the worktrees, inside the project folder.
Ignoring a file does NOT untrack one that is already committed: office warns
and gives you the git rm --cached line when it finds that.
WHERE THINGS RUN WHERE THINGS RUN
Panes run on the machine that owns the folder. Agents run on the machine that Panes run on the machine that owns the folder. Agents run on the machine that
serves Paperclip, because Paperclip starts them itself. If those differ, the serves Paperclip, because Paperclip starts them itself. If those differ, the
panes work and the agents cannot see the files, so --new refuses unless panes work and the agents cannot see the files, so office --new refuses unless
--anywhere is given. --anywhere is given.
ON ANOTHER MACHINE MACHINES (name, or the address ssh reaches)
--host copies this folder of commands to the other machine, at nixos-desktop .13 192.168.20.13 hosts Paperclip, the daemon, the main session
~/.local/share/paperclip-harness/bin/ sam-4screen-desktop .27 192.168.20.27 your working desktop
and leaves a short ~/.local/bin/office there, so it can be run from that sam-thinkpad .51 192.168.20.51 the laptop
machine too. Nothing has to be installed by hand. On .27 and .51 ~/.local/bin
is not on PATH yet, so there you call it by its full path:
~/.local/share/paperclip-harness/bin/office <folder>
WHEN THE FILES ARE ON ANOTHER MACHINE FROM A TERMINAL, ON ANY MACHINE
The git work has to happen where the files are, because that is where the a folder on .13, typed on .13:
commit, the worktree and the agent are. So when a folder cannot hold a office ~/paseo/projects/test-alpha --project "test-alpha"
worktree yet, office makes a session on THAT machine with lazygit already a folder on .13, typed on .27:
open in the folder, and tells you how to attach to it. No ssh, no cd. office /home/sam/TEST-BETA --new "TEST-BETA" --host nixos-desktop
a folder on .27, typed on .27:
office ~/src/thing --new "thing"
then attach; nothing may move your client from inside a pane:
tuios attach <session>
tuios attach --host nixos-desktop <session>
AFTER A REBOOT --host copies these commands to the other machine at
TUIOS restores a session with a fresh shell in every pane and keeps the pane ~/.local/share/paperclip-harness/bin/ and leaves ~/.local/bin/office there, so
names. Run board once for that project: it checks what each pane is actually a machine that has never seen this repository still works.
running and replaces any pane that is only a prompt.
MERGE AND REVIEW
office merge <task> rebase onto the base, run the project's check, run ocr,
merge, re-run the check, roll back if it fails. It asks
for confirmation before touching anything.
office review <task> ocr over one task's diff; --post records the verdict
office triage <task> one four-option Mouse-1 question, posted as a comment
KEYS INSIDE THE PANES KEYS INSIDE THE PANES
( ) change tab tab change panel enter open / choose ( ) change tab tab change panel enter open / choose
r re-read Paperclip R rebuild all panes ? list the keys r re-read Paperclip R rebuild all panes ? list the keys
t open lazygit in the project folder (stage, ignore, commit) t tools (lazygit, yazi, nvim, hunk, fzf, ocr, shell)
g lazygit, straight away, in the project folder
h j k l z n and digits belong to TUIOS and never reach the program. h j k l z n and digits belong to TUIOS and never reach the program.
THE PROJECT PANE
overview · git · facts · costs · settings · projects
The git tab reads the folder and Paperclip together: repository, branch,
commits, remote, what is staged, what .gitignore holds, the worktree policy,
and Paperclip's own scan of every task worktree (dirty, ahead, behind, merged).
WHILE A FOLDER CANNOT HOLD A WORKTREE
Every pane carries one line at the top, naming what is wrong and where to look.
It is read when the pane starts; fix the folder and press R to clear it.
MORE MORE
docs/STARTUP.md these commands, the rule, and the three rebuild levels docs/STARTUP.md these commands, the rule, the rebuild levels
docs/WORKSPACES.md what each of the eight panes is for docs/WORKSPACES.md what each of the eight panes is for
docs/RUNS-AND-MERGE.md how a task becomes merged work
docs/CLEAN-TESTS.md the two end-to-end tests and what they measured docs/CLEAN-TESTS.md the two end-to-end tests and what they measured
""" """
@@ -680,6 +698,38 @@ def note_ignore(folder, opts):
h.say(" take Paperclip's task worktrees with it. Add it before you commit.") h.say(" take Paperclip's task worktrees with it. Add it before you commit.")
# The commands that have their own script next to this one. `office board` and
# `board` must do the same thing: a person should only have to remember office.
SIBLINGS = {
"board": "board",
"pages": "board",
"build": "board",
"reset": "project-reset",
"project-reset": "project-reset",
"delete": "project-delete",
"project-delete": "project-delete",
"forget": "project-delete",
"merge": "merge",
"review": "review",
"triage": "triage",
}
def forward(argv):
"""Run a sibling command with the rest of the arguments, or return None."""
if not argv:
return None
name = SIBLINGS.get(argv[0])
if name is None:
return None
script = os.path.join(HERE, name)
if not os.path.isfile(script):
raise h.Refused(2, "no %s next to office; the repository looks incomplete" % name)
# exec, not a subprocess: this program is a router for this one call and has
# nothing left to do, so the sibling should be the process the person sees.
os.execv(sys.executable, [sys.executable, script] + argv[1:])
def main(): def main():
if os.geteuid() == 0: if os.geteuid() == 0:
h.say("office: run this as your own user, not with sudo.") h.say("office: run this as your own user, not with sudo.")
@@ -687,6 +737,10 @@ def main():
h.say(" neither of those exists for root, and files it wrote would belong to root.") h.say(" neither of those exists for root, and files it wrote would belong to root.")
return 2 return 2
argv = sys.argv[1:] argv = sys.argv[1:]
if argv and argv[0] in ("help", "-h", "--help"):
h.say(USAGE.strip())
return 0
forward(argv)
if not argv or "-h" in argv or "--help" in argv: if not argv or "-h" in argv or "--help" in argv:
h.say(USAGE.strip()) h.say(USAGE.strip())
return 0 return 0

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Remove every trace of a project, and nothing else. """Remove every trace of a project, and nothing else.
bin/project-delete <folder> # say what would go, change nothing office delete <folder> # say what would go, change nothing
bin/project-delete <folder> --yes # do it office delete <folder> --yes # do it
What it removes, in this order: What it removes, in this order:

View File

@@ -1,9 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Start a project again: close its pages, remove its worktrees, keep its work. """Start a project again: close its pages, remove its worktrees, keep its work.
bin/project-reset <folder> --dry-run office reset <folder> --dry-run
bin/project-reset <folder> --yes office reset <folder> --yes
bin/project-reset <folder> --yes --delete-folder office reset <folder> --yes --delete-folder
What it removes, and nothing else: What it removes, and nothing else:
@@ -36,7 +36,7 @@ def main():
argv = sys.argv[1:] argv = sys.argv[1:]
args = [a for a in argv if not a.startswith("--")] args = [a for a in argv if not a.startswith("--")]
if not args: if not args:
h.say("usage: bin/project-reset <folder> [--dry-run] [--yes] [--delete-folder]") h.say("usage: office reset <folder> [--dry-run] [--yes] [--delete-folder]")
return 2 return 2
folder = os.path.abspath(os.path.expanduser(args[0])) folder = os.path.abspath(os.path.expanduser(args[0]))
dry = "--dry-run" in argv dry = "--dry-run" in argv

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Review one task's diff and print the verdict. Merge nothing. """Review one task's diff and print the verdict. Merge nothing.
bin/review AUD-164 # review and print office review AUD-164 # review and print
bin/review AUD-164 --post # also write the verdict on the task office review AUD-164 --post # also write the verdict on the task
This is the same review `bin/merge` runs, on its own, so a person can look This is the same review `bin/merge` runs, on its own, so a person can look
before the merge is attempted. Exit 0 means nothing blocking was found; exit 5 before the merge is attempted. Exit 0 means nothing blocking was found; exit 5
@@ -20,7 +20,7 @@ def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")] args = [a for a in sys.argv[1:] if not a.startswith("--")]
post = "--post" in sys.argv post = "--post" in sys.argv
if len(args) != 1: if len(args) != 1:
h.say("usage: bin/review <task> [--post]") h.say("usage: office review <task> [--post]")
return 2 return 2
issue = h.find_issue(args[0]) issue = h.find_issue(args[0])

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Ask Mouse-1 one four-option question about a task, and record the answer. """Ask Mouse-1 one four-option question about a task, and record the answer.
bin/triage AUD-171 # what does this task need next office triage AUD-171 # what does this task need next
bin/triage AUD-171 --ask redo # a different question set office triage AUD-171 --ask redo # a different question set
Mouse-1 answers a typed multiple-choice question about a state you describe. It Mouse-1 answers a typed multiple-choice question about a state you describe. It
does not write code, run tools, or decide whether work is finished. It labels a does not write code, run tools, or decide whether work is finished. It labels a
@@ -85,7 +85,7 @@ def ask(state, question):
def main(): def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")] args = [a for a in sys.argv[1:] if not a.startswith("--")]
if not args: if not args:
h.say("usage: bin/triage <task> [--ask next|failure]") h.say("usage: office triage <task> [--ask next|failure]")
return 2 return 2
which = "next" which = "next"
if "--ask" in sys.argv: if "--ask" in sys.argv: