From d03636ead360e0c28b2845262ea67332cfa99ad3 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 10 Nov 2026 12:45:23 +1100 Subject: [PATCH] Milestones 4 and 5: project layer, review gate and serial merge, all measured --- CHECKLIST.md | 24 ++- README.md | 11 ++ bin/board | 89 +++++++++ bin/lib/__pycache__/harness.cpython-314.pyc | Bin 0 -> 10901 bytes bin/lib/__pycache__/project.cpython-314.pyc | Bin 0 -> 7180 bytes bin/lib/__pycache__/tuios.cpython-314.pyc | Bin 0 -> 10070 bytes bin/lib/harness.py | 194 ++++++++++++++++++++ bin/lib/project.py | 116 ++++++++++++ bin/lib/tuios.py | 176 ++++++++++++++++++ bin/merge | 136 ++++++++++++++ bin/office | 111 +++++++++++ bin/project-reset | 109 +++++++++++ bin/review | 70 +++++++ docs/PAPERCLIP-API-NOTES.md | 84 +++++++++ 14 files changed, 1115 insertions(+), 5 deletions(-) create mode 100755 bin/board create mode 100644 bin/lib/__pycache__/harness.cpython-314.pyc create mode 100644 bin/lib/__pycache__/project.cpython-314.pyc create mode 100644 bin/lib/__pycache__/tuios.cpython-314.pyc create mode 100644 bin/lib/harness.py create mode 100644 bin/lib/project.py create mode 100644 bin/lib/tuios.py create mode 100755 bin/merge create mode 100755 bin/office create mode 100755 bin/project-reset create mode 100755 bin/review diff --git a/CHECKLIST.md b/CHECKLIST.md index e48ef23..99c8b59 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -60,15 +60,29 @@ been measured. ## Milestone 4 — the project layer -- [ ] Adopt a folder as a project; remember which host it is on. -- [ ] Build the page layout; repair it; reset it. +- [x] Adopt a folder as a project; remember which host it is on. + → `bin/office ` writes `/.harness/harness.toml` (session, project, + machine, folder, task watched) and builds the pages. Re-running changes nothing. +- [x] Build the page layout; repair it; reset it. + → `bin/board` adopts or creates each page and places them on workspaces 1–5 in order; + `--replace` rebuilds from nothing; `bin/project-reset` removed the panes, three + worktrees and three branches, and `bin/office` built them all back. - [ ] Pass: a project on another machine can be opened and used from here. + **Not tested.** This pane runs on .13 and I have no way to drive .27 or .51 from + here; `bin/office --host` and the runbook command for it are written but unproven. ## Milestone 5 — review and merge -- [ ] `ocr` runs over the diff; a failing review refuses the task. -- [ ] Merges are one at a time, re-checked after each. -- [ ] Pass: the next task cannot merge until the first is settled. +- [x] `ocr` runs over the diff; a failing review refuses the task. + → `AUD-165` added a `cleanup.sh` with `rm -rf "$HOME/tmp"`; `bin/merge --check` + refused with exit 5 and named the critical finding, and `cleanup.sh` never reached + `main`. +- [x] Merges are one at a time, re-checked after each. + → a second merge while one holds the lock exits 6; after the rebase the task's verify + command runs again, and again on the base branch after the merge. +- [x] Pass: the next task cannot merge until the first is settled. + → the lock refuses the second merge outright, and a refusal leaves the branch where + it was. ## Interface rules that are not optional diff --git a/README.md b/README.md index f1e7e01..c1d1409 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,17 @@ clicks relative to the pane, so the check is a real TUIOS pane read back with `capture-pane`. The commands are in `docs/TUIOS-RUNBOOK.md`, which is the one place they live. +## The commands + +| command | what it does | +|---|---| +| `bin/office [--project X] [--session Y]` | adopt a folder: write `.harness/harness.toml`, build its pages | +| `bin/board [--folder DIR] [--replace]` | build or repair the pages; run twice and nothing is duplicated | +| `bin/project-reset [--dry-run] [--yes]` | close the pages, remove the worktrees and branches; keep the folder and every Paperclip record | +| `bin/merge [--check]` | rebase, verify, review with `ocr`, merge; a refusal is a decision for the person | +| `bin/review [--post]` | the same review on its own | +| `bin/screen tree\|reply\|log\|new [task]` | open one page | + ## Rules for this project - No interface work before a real task has run end to end. diff --git a/bin/board b/bin/board new file mode 100755 index 0000000..68d4b68 --- /dev/null +++ b/bin/board @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Build or repair a project's panes, in a fixed order. + + bin/board # the project in the current folder + bin/board --folder ~/src/x # or another one + bin/board --replace # close this project's panes and build again + +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 +lose its first workspace to TUIOS cleanup. + +Nothing here holds a workspace number. Each page asks for its workspace by name +at the moment it opens, because closing a pane deletes an emptied workspace and +renumbers the rest. Run it twice and nothing is duplicated. +""" + +import os +import shlex +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "lib")) +import harness as h # noqa: E402 +import tuios # noqa: E402 +import project as pj # noqa: E402 + +BIN = os.path.join(os.path.dirname(HERE), "bin", "harness") + + +def parse(argv): + opts = {"folder": os.getcwd(), "replace": "--replace" in argv} + for i, a in enumerate(argv): + if a == "--folder" and i + 1 < len(argv): + opts["folder"] = argv[i + 1] + return opts + + +def pages(cfg): + """The pages, in the order they must appear on the workspace rail.""" + bin_path = BIN + proj = cfg["paperclip"]["project"] + task = pj.watch_task(cfg) + return task, [ + ("shell", "shell", "exec zsh -i"), + ("tree", "tree", "%s -screen tree -project %s" % (shlex.quote(bin_path), proj)), + ("reply", "reply", "%s -screen reply -issue %s" % (shlex.quote(bin_path), task)), + ("log", "log", "%s -screen log -issue %s" % (shlex.quote(bin_path), task)), + ("new", "new", "%s -screen new -project %s" % (shlex.quote(bin_path), proj)), + ] + + +def main(): + opts = parse(sys.argv[1:]) + folder = os.path.abspath(os.path.expanduser(opts["folder"])) + cfg = pj.load(folder) + pj.check_machine(cfg) + session = cfg["session"] + tuios.require_session(session) + os.environ["PAPERCLIP_HARNESS_SESSION"] = session + + if not os.path.isfile(BIN): + raise h.Refused(2, "no harness binary at %s; build it with 'cd tui && go build -o ../bin/harness .'" % BIN) + + task, page_list = pages(cfg) + h.say("board: %s (session %s, folder %s)" % (cfg["paperclip"]["project"], session, folder)) + h.say("watching: %s" % task) + + if opts["replace"]: + for name, _, _ in page_list: + if tuios.close_pane(name, session): + h.say(" closed %s" % name) + + for name, workspace, command in page_list: + result, ident, ws = tuios.open_pane( + name, workspace, folder, command, session, + adopt_existing=(name == "shell"), + ) + h.say(" %-6s %-8s workspace %s" % (name, result, ws)) + + for number, name in tuios.arrange([p[0] for p in page_list], session): + h.say(" placed %-6s workspace %s" % (name, number)) + + in_use = [w for w in tuios.workspaces(session) if (w.get("window_count") or 0) > 0] + h.say("workspaces in use: %d" % len(in_use)) + return 0 + + +if __name__ == "__main__": + h.main_guard(main) diff --git a/bin/lib/__pycache__/harness.cpython-314.pyc b/bin/lib/__pycache__/harness.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..142488ad56a667e00a4cb4741e3751fd5f769644 GIT binary patch literal 10901 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 N*>(2?%tJTi{{Tsgz`v0 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 Ag8%>k literal 0 HcmV?d00001 diff --git a/bin/lib/__pycache__/tuios.cpython-314.pyc b/bin/lib/__pycache__/tuios.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..db39c0265deae19b080bb426c7d5bfe8f61f7687 GIT binary patch literal 10070 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 literal 0 HcmV?d00001 diff --git a/bin/lib/harness.py b/bin/lib/harness.py new file mode 100644 index 0000000..e898228 --- /dev/null +++ b/bin/lib/harness.py @@ -0,0 +1,194 @@ +"""Shared helpers for the harness commands. + +Everything here talks to Paperclip with the person's key, or runs git and ocr. +No fact is stored: a task's worktree, branch and verify command are read from +Paperclip every time, so a stale copy cannot exist. +""" + +import json +import os +import re +import shutil +import subprocess +import sys +import urllib.error +import urllib.request + +API = os.environ.get("PAPERCLIP_API_URL", "http://127.0.0.1:3100").rstrip("/") +COMPANY = os.environ.get("PAPERCLIP_COMPANY_ID", "d4921013-e5da-4d88-8016-57d5746e2d03") +KEY = os.environ.get("PAPERCLIP_BOARD_KEY", "") + +BLOCKING_SEVERITIES = {"critical", "high"} + + +class Refused(Exception): + """A check that did not pass, with the reason a person needs.""" + + def __init__(self, code, message): + super().__init__(message) + self.code = code + + +def api(method, path, body=None, timeout=30): + if not KEY: + raise Refused(2, "PAPERCLIP_BOARD_KEY is not set") + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request( + API + path, + data=data, + method=method, + headers={"Authorization": "Bearer " + KEY, "Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as f: + raw = f.read().decode() + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + raise Refused(2, "%s %s → %s %s" % (method, path, e.code, e.read().decode()[:300])) + + +def find_issue(ref): + """Find one task by id or identifier (AUD-163), then read it in full. + + The company list is a summary: it carries no worktree and no blockers. The + single-task read does, so it is always a second call. + """ + issues = api("GET", "/api/companies/%s/issues" % COMPANY) + for i in issues: + if i.get("identifier") == ref or i.get("id") == ref: + return api("GET", "/api/issues/%s" % i["id"]) + raise Refused(2, "no task matches %r" % ref) + + +def me(): + """The person the board key acts as.""" + d = api("GET", "/api/cli-auth/me") + uid = d.get("userId") + if not uid: + raise Refused(2, "the board key has no user id") + return uid + + +def hand_to_person(issue): + """Take the task off the agent before writing on it. + + A comment on a task assigned to an agent wakes that agent. That is right for + a person's reply and wrong for a tool's verdict: a merge result written on a + finished task would wake the agent to read it and spend money doing nothing. + Handing the task back to the person first is the documented way to leave a + task waiting for a human, and it stops the wake. + """ + if not issue.get("assigneeAgentId"): + return + api( + "PATCH", + "/api/issues/%s" % issue["id"], + {"assigneeAgentId": None, "assigneeUserId": me()}, + ) + issue["assigneeAgentId"] = None + + +def comment(issue_id, body): + return api("POST", "/api/issues/%s/comments" % issue_id, {"body": body}) + + +def verdict(issue, body): + """Write a tool's verdict on a task without waking its agent.""" + hand_to_person(issue) + return comment(issue["id"], body) + + +VERIFY_RE = re.compile( + r"Done when this command succeeds:\s*\n+\s*(?P[^\n]+)", re.IGNORECASE +) + + +def verify_command(issue, worktree): + """The command that decides the task is finished. + + It comes from the task's own description, then the project convention + .harness/verify.sh. A task with neither says so out loud. + """ + m = VERIFY_RE.search(issue.get("description") or "") + if m: + return m.group("cmd").strip(), "from the task description" + project_script = os.path.join(worktree, ".harness", "verify.sh") + if os.path.isfile(project_script): + return ".harness/verify.sh", "the project's .harness/verify.sh" + return None, "none" + + +def worktree_of(issue): + ws = issue.get("currentExecutionWorkspace") or {} + cwd = ws.get("cwd") or "" + if not cwd or not os.path.isdir(cwd): + raise Refused( + 7, + "%s has no worktree on this machine, so there is nothing to review or merge" + % issue.get("identifier"), + ) + return cwd, ws.get("branchName") or "", ws.get("baseRef") or "main" + + +def run(cmd, cwd=None, timeout=900): + p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout) + return p.returncode, (p.stdout or "") + (p.stderr or "") + + +def need(binary): + if not shutil.which(binary): + raise Refused(2, "%s is not installed" % binary) + + +def ocr_review(repo, base, head): + """Run ocr over one range and return its JSON report.""" + need("ocr") + code, out = run( + [ + "ocr", "review", + "--repo", repo, + "--from", base, + "--to", head, + "--format", "json", + "--audience", "agent", + "--color", "never", + ], + timeout=1800, + ) + try: + report = json.loads(out[out.index("{") :]) + except ValueError: + raise Refused(5, "ocr did not return a report (exit %d): %s" % (code, out[-400:])) + return report + + +def blocking_findings(report): + out = [] + for c in report.get("comments") or []: + sev = (c.get("severity") or "").lower() + if sev in BLOCKING_SEVERITIES: + out.append(c) + return out + + +def describe(c): + where = c.get("path") or "?" + line = c.get("start_line") + if line: + where += ":%s" % line + body = (c.get("content") or "").strip().replace("\n", " ") + if len(body) > 300: + body = body[:300] + "…" + return "- `%s` **%s/%s** — %s" % (where, c.get("severity"), c.get("category"), body) + + +def say(msg=""): + print(msg, flush=True) + + +def main_guard(fn): + try: + sys.exit(fn()) + except Refused as e: + say("refused: %s" % e) + sys.exit(e.code) diff --git a/bin/lib/project.py b/bin/lib/project.py new file mode 100644 index 0000000..c35bfc6 --- /dev/null +++ b/bin/lib/project.py @@ -0,0 +1,116 @@ +"""The project convention: one project, one folder, one machine. + +Paperclip owns the project record, its tasks, blockers, comments, runs, cost and +worktrees. What it cannot know is which folder on which machine holds the work, +which TUIOS session shows it, and which task the viewer is looking at. That is +this file's whole job: + + /.harness/harness.toml + +Nothing else is stored. The task list, the blocker edges and the worktrees are +read from Paperclip every time. +""" + +import os +import socket +import sys +import tomllib + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import harness as h # noqa: E402 + +REL = os.path.join(".harness", "harness.toml") + + +def path(root): + return os.path.join(root, REL) + + +def exists(root): + return os.path.isfile(path(root)) + + +def load(root): + p = path(root) + if not os.path.isfile(p): + raise h.Refused(2, "no %s here; run bin/office %s first" % (REL, root)) + with open(p, "rb") as f: + return tomllib.load(f) + + +def save(root, cfg): + os.makedirs(os.path.dirname(path(root)), exist_ok=True) + lines = [ + "# Written by bin/office. One project, one folder, one machine.", + "# Paperclip owns the project, its tasks and its worktrees; this file only", + "# remembers where the work is and what to show.", + "", + 'session = "%s"' % cfg["session"], + 'watching = "%s"' % (cfg.get("watching") or ""), + "", + "[paperclip]", + 'company = "%s"' % cfg["paperclip"]["company"], + 'project = "%s"' % cfg["paperclip"]["project"], + "", + "[machine]", + 'host = "%s"' % cfg["machine"]["host"], + 'folder = "%s"' % cfg["machine"]["folder"], + "", + ] + with open(path(root), "w") as f: + f.write("\n".join(lines)) + + +def this_machine(): + return socket.gethostname() + + +def check_machine(cfg): + """Refuse to act on a project that belongs to another machine.""" + want = (cfg.get("machine") or {}).get("host") or "" + here = this_machine() + if want and want not in (here, here.split(".")[0]): + raise h.Refused( + 6, + "this project lives on %r but this machine is %r; run the command there, " + "or pass --host %s" % (want, here, want), + ) + + +def resolve_project(ref): + """Accept a project id, url key or name, and return the Paperclip record.""" + projects = h.api("GET", "/api/companies/%s/projects" % h.COMPANY) + for p in projects: + if ref in (p.get("id"), p.get("urlKey"), p.get("name")): + return p + raise h.Refused(2, "no Paperclip project matches %r" % ref) + + +def active_tasks(project_id): + issues = h.api("GET", "/api/companies/%s/issues" % h.COMPANY) + mine = [i for i in issues if i.get("projectId") == project_id] + order = {"in_progress": 0, "blocked": 1, "in_review": 2, "todo": 3, "backlog": 4, "done": 5, "cancelled": 6} + mine.sort(key=lambda i: (order.get(i.get("status"), 9), i.get("updatedAt") or ""), reverse=False) + return mine + + +def watch_task(cfg): + """Which task the reply and log panes should show. + + A person's last choice wins if it still exists; otherwise the most active + task in the project, so the panes are never empty for no reason. + """ + tasks = active_tasks(cfg["paperclip"]["project"]) + wanted = cfg.get("watching") or "" + for t in tasks: + if t.get("identifier") == wanted or t.get("id") == wanted: + return t["identifier"] + for t in tasks: + if t.get("status") not in ("done", "cancelled"): + return t["identifier"] + return tasks[0]["identifier"] if tasks else "none" + + +def set_watch(root, cfg, identifier): + cfg["watching"] = identifier + save(root, cfg) diff --git a/bin/lib/tuios.py b/bin/lib/tuios.py new file mode 100644 index 0000000..5080aa5 --- /dev/null +++ b/bin/lib/tuios.py @@ -0,0 +1,176 @@ +"""TUIOS helpers: claim a workspace by name, list what is open. + +A workspace number is never a home. TUIOS deletes a workspace whose last pane +closes and renumbers the ones after it, and it also erases a workspace's name +when it empties. So every caller asks for a name and is ready for it not to +exist yet, and the number is used once, immediately, and forgotten. +""" + +import json +import os +import subprocess +import sys + +SESSION = os.environ.get("PAPERCLIP_HARNESS_SESSION", "paperclip-harness") + + +def tuios(*args, session=None): + argv = ["tuios", *args, "-s", session or SESSION, "--json"] + p = subprocess.run(argv, capture_output=True, text=True, timeout=60) + if p.returncode != 0: + raise RuntimeError("tuios %s failed: %s" % (" ".join(args), p.stderr.strip())) + try: + return json.loads(p.stdout) + except ValueError: + raise RuntimeError("tuios %s returned unreadable JSON" % " ".join(args)) + + +def sessions(): + """Every session the daemon knows, as plain names.""" + p = subprocess.run(["tuios", "ls", "--json"], capture_output=True, text=True, timeout=30) + if p.returncode != 0: + raise RuntimeError("tuios ls failed: %s" % p.stderr.strip()) + data = json.loads(p.stdout) + rows = data if isinstance(data, list) else data.get("sessions", []) + return [r.get("name") for r in rows if isinstance(r, dict)] + + +def require_session(name): + """Sessions are made by a person, in a terminal. Say so, do not invent one.""" + names = sessions() + if name in names: + return + raise SystemExit( + "no TUIOS session named %r.\n" + " Create it in a terminal, then run this again:\n" + " tuios new %s # on the machine that owns the folder\n" + " tuios new --host nixos-desktop %s # from another machine\n" + " Sessions the daemon has: %s" % (name, name, name, ", ".join(names) or "none") + ) + + +def workspaces(session=None): + return tuios("list-workspaces", session=session).get("workspaces", []) + + +def windows(session=None): + return tuios("list-windows", session=session).get("windows", []) + + +def _name_workspace(number, name, session=None): + subprocess.run( + ["tuios", "set-workspace-name", str(number), name, "-s", session or SESSION], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + +def claim(name, session=None): + """Return the number of the workspace called `name`, naming one if needed.""" + ws = workspaces(session) + exact = next((w for w in ws if (w.get("name") or "") == name), None) + if exact is None: + same = next((w for w in ws if (w.get("name") or "").lower() == name.lower()), None) + if same is not None: + _name_workspace(same["workspace"], name, session) + return same["workspace"] + if exact is not None: + return exact["workspace"] + + # Nothing named that. Take a workspace that is unnamed and has no panes. + free = next( + (w for w in ws if "window_count" in w and (w.get("window_count") or 0) == 0 and not (w.get("name") or "")), + None, + ) + if free is None: + raise RuntimeError( + "no empty workspace left in session %r; close a pane or add a workspace" % (session or SESSION) + ) + _name_workspace(free["workspace"], name, session) + return free["workspace"] + + +def open_pane(name, workspace_name, cwd, command, session=None, adopt_existing=False): + """Open a pane in a named workspace, or say it is already there. + + With adopt_existing, a pane already in that workspace is adopted even if it + has no name yet — the person's own shell was there before we were, and it is + the pane that keeps the workspace alive. + """ + ws = claim(workspace_name, session) + for w in windows(session): + if (w.get("custom_name") or "") == name: + return "adopted", w["window_id"], ws + if adopt_existing: + for w in windows(session): + if w.get("workspace") == ws: + if not (w.get("custom_name") or ""): + subprocess.run( + ["tuios", "set-window", "-w", w["window_id"], "--name", name, + "-s", session or SESSION], + capture_output=True, text=True, timeout=30, check=False, + ) + return "adopted", w["window_id"], ws + subprocess.run( + [ + "tuios", "new-window", name, + "-s", session or SESSION, + "--workspace", str(ws), + "--cwd", cwd, + "--no-focus", "--keep-open", + "--", "/bin/sh", "-c", command, + ], + capture_output=True, text=True, timeout=60, check=False, + ) + return "created", name, ws + + +def arrange(order, session=None): + """Put these panes on workspaces 1..N in this order, and name them. + + TUIOS numbering follows the order panes land in, and closing panes renumbers + the rest, so a rebuild can scatter the pages. Moving each pane once, at the + end, is what makes the rail readable and stable. + """ + moved = [] + for number, name in enumerate(order, start=1): + win = next((w for w in windows(session) if (w.get("custom_name") or "") == name), None) + if win is None: + continue + if win.get("workspace") != number: + subprocess.run( + ["tuios", "move-window", str(number), "-w", win["window_id"], "-s", session or SESSION], + capture_output=True, text=True, timeout=30, check=False, + ) + _name_workspace(number, name, session) + moved.append((number, name)) + return moved + + +def close_pane(name, session=None): + for w in windows(session): + if (w.get("custom_name") or "") == name: + subprocess.run( + ["tuios", "close-window", w["window_id"], "-s", session or SESSION], + capture_output=True, text=True, timeout=30, check=False, + ) + return True + return False + + +def drop_empty_unnamed(session=None): + """Remove the unnamed, empty workspaces a rebuild leaves behind.""" + gone = 0 + for w in workspaces(session): + if "window_count" in w and (w.get("window_count") or 0) == 0 and not (w.get("name") or ""): + continue + return gone + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "claim": + print(claim(sys.argv[2])) + else: + print("usage: python3 lib/tuios.py claim NAME") diff --git a/bin/merge b/bin/merge new file mode 100755 index 0000000..508a8d3 --- /dev/null +++ b/bin/merge @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Merge one task at a time, re-checked after the rebase. + + bin/merge AUD-163 # check, then merge + bin/merge AUD-163 --check # do every check, merge nothing + +The order is the point: + +1. take the merge lock, so two tasks cannot merge at once +2. rebase the task's branch onto the project's base branch +3. run the task's own verify command in the worktree +4. run ocr over the rebased range; a critical or high finding refuses the merge, + and so does a review that could not run +5. merge, then run the verify command again on the base branch +6. write the verdict on the task, so the record lives with the work + +A refusal is a decision for the person: the branch is left where it was. +""" + +import fcntl +import json +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib")) +import harness as h # noqa: E402 + +LOCK = "/tmp/paperclip-harness-merge.lock" + + +def main(): + args = [a for a in sys.argv[1:]] + check_only = "--check" in args + args = [a for a in args if not a.startswith("--")] + if len(args) != 1: + h.say("usage: bin/merge [--check]") + return 2 + + issue = h.find_issue(args[0]) + ref = issue["identifier"] + worktree, branch, base = h.worktree_of(issue) + base_repo = os.path.dirname(worktree) + # The worktree lives under /.paperclip/worktrees/; the base + # checkout is the project folder itself. + if ".paperclip/worktrees" in base_repo: + base_repo = base_repo.split("/.paperclip/worktrees")[0] + + h.say("%s %s" % (ref, issue["title"])) + h.say(" worktree %s" % worktree) + h.say(" branch %s onto %s" % (branch, base)) + + lock_file = open(LOCK, "w") + try: + fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise h.Refused(6, "another merge is in progress; this one waits its turn") + + # 1. Rebase, so the checks run on what would actually land. + code, out = h.run(["git", "-C", worktree, "rebase", base]) + if code != 0: + h.run(["git", "-C", worktree, "rebase", "--abort"]) + raise h.Refused(3, "rebase onto %s conflicts; a person decides:\n%s" % (base, out[-800:])) + h.say(" rebased onto %s" % base) + + # 2. The task's own command decides whether it is finished. + cmd, source = h.verify_command(issue, worktree) + if cmd is None: + h.say(" no verify command: this would pass on the agent's word") + else: + code, out = h.run(["sh", "-c", cmd], cwd=worktree) + if code != 0: + h.verdict(issue, "## Merge refused: the verify command failed\n\n" + "`%s` (%s) exited %d in the task's worktree.\n\n```\n%s\n```" + % (cmd, source, code, out[-1500:])) + raise h.Refused(4, "verify command failed: %s" % cmd) + h.say(" verify ok: %s (%s)" % (cmd, source)) + + # 3. Review, on the rebased range. + report = h.ocr_review(worktree, base, "HEAD") + status = report.get("status") + reviewed = (report.get("summary") or {}).get("files_reviewed", 0) + if status == "skipped" and reviewed == 0: + # Nothing in the change is reviewable (a documentation-only diff, for + # one). That is not a failure, but it is not a review either, and the + # record has to say which. + h.say(" review saw nothing to review (0 files); recorded, not hidden") + elif status != "complete": + # "Could not run" is not a pass. Never merge what a check could not see. + h.verdict(issue, "## Merge refused: the review could not complete\n\n" + "ocr returned `%s` — %s" % (status, report.get("message") or "no reason given")) + raise h.Refused(5, "review could not complete (%s)" % status) + + findings = h.blocking_findings(report) + if findings: + body = "## Merge refused: the review found %d blocking item(s)\n\n%s" % ( + len(findings), "\n".join(h.describe(c) for c in findings)) + h.verdict(issue, body) + h.say(body) + raise h.Refused(5, "review blocked the merge") + + n = len(report.get("comments") or []) + if status == "complete": + h.say(" review ok: %d comment(s), none blocking" % n) + + if check_only: + h.say(" checked only; nothing merged") + return 0 + + # 4. Merge, then prove the base branch still passes the task's command. + code, out = h.run(["git", "-C", base_repo, "merge", "--no-ff", branch, + "-m", "Merge %s: %s" % (ref, issue["title"])]) + if code != 0: + raise h.Refused(8, "merge failed:\n%s" % out[-800:]) + + if cmd is not None: + code, out = h.run(["sh", "-c", cmd], cwd=base_repo) + if code != 0: + h.run(["git", "-C", base_repo, "reset", "--hard", "HEAD^"]) + h.verdict(issue, "## Merge undone: the verify command failed on %s\n\n" + "`%s` exited %d after merging, so the merge was rolled back.\n\n```\n%s\n```" + % (base, cmd, code, out[-1500:])) + raise h.Refused(4, "verify failed on %s after merging; rolled back" % base) + h.say(" verify ok on %s after the merge" % base) + + head = h.run(["git", "-C", base_repo, "rev-parse", "--short", "HEAD"])[1].strip() + review_line = ("%d comment(s), none blocking" % n) if status == "complete" \ + else "nothing reviewable in the change (0 files)" + h.verdict(issue, "## Merged\n\n- branch `%s` → `%s` at `%s`\n" + "- verify: %s\n- review: %s" + % (branch, base, head, ("`%s` ok" % cmd) if cmd else "none agreed", review_line)) + h.say(" merged into %s at %s" % (base, head)) + return 0 + + +if __name__ == "__main__": + h.main_guard(main) diff --git a/bin/office b/bin/office new file mode 100755 index 0000000..c00642e --- /dev/null +++ b/bin/office @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Open a folder as a project: adopt it, remember it, build its pages. + + bin/office [--project ] [--session NAME] [--dry] + bin/office . # the folder you are in + +Adopting writes one file, /.harness/harness.toml, and nothing else. An +existing folder is never overwritten: what is already there stays, and only the +missing file is written. Paperclip's project record is read, never created — +bin/office adopts a project that exists, it does not invent one. + +The machine is remembered, so the flag is stated once. Running the same command +on a machine that is not the recorded one is refused rather than silently +producing a second copy of the same project. +""" + +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "lib")) +import harness as h # noqa: E402 +import project as pj # noqa: E402 +import tuios # noqa: E402 + + +def parse(argv): + opts = {"folder": ".", "project": "", "session": "", "dry": "--dry" in argv, "host": ""} + args = [a for a in argv if not a.startswith("--")] + if args: + opts["folder"] = args[0] + for i, a in enumerate(argv): + if a == "--project" and i + 1 < len(argv): + opts["project"] = argv[i + 1] + if a == "--session" and i + 1 < len(argv): + opts["session"] = argv[i + 1] + if a == "--host" and i + 1 < len(argv): + opts["host"] = argv[i + 1] + return opts + + +def main(): + opts = parse(sys.argv[1:]) + folder = os.path.abspath(os.path.expanduser(opts["folder"])) + if not os.path.isdir(folder): + raise h.Refused(2, "no folder at %s" % folder) + + host = opts["host"] or pj.this_machine() + + # An adopted project already knows its session. Only a new one needs a + # default, and a default that names a session nobody made is worse than an + # error, so the session is checked before anything is written. + existing = {} + if pj.exists(folder): + existing = pj.load(folder) + session = ( + opts["session"] + or existing.get("session") + or os.path.basename(folder) + ) + tuios.require_session(session) + + if opts["host"] and opts["host"] not in (pj.this_machine(), pj.this_machine().split(".")[0]): + # Only a note, because the panes are built by the machine that owns them. + h.say("host: %s (build the pages there)" % host) + + ref = opts["project"] + if not ref and pj.exists(folder): + ref = pj.load(folder)["paperclip"]["project"] + if not ref: + raise h.Refused( + 2, + "which Paperclip project is %s? pass --project " % folder, + ) + + proj = pj.resolve_project(ref) + h.say("project: %s (%s)" % (proj["name"], proj["id"])) + h.say("folder: %s" % folder) + h.say("session: %s" % session) + h.say("machine: %s" % host) + + tasks = pj.active_tasks(proj["id"]) + watching = next((t["identifier"] for t in tasks if t["status"] not in ("done", "cancelled")), + tasks[0]["identifier"] if tasks else "") + + cfg = { + "session": session, + "paperclip": {"company": h.COMPANY, "project": proj["id"]}, + "machine": {"host": host, "folder": folder}, + "watching": watching, + } + + if opts["dry"]: + h.say("would write %s" % pj.path(folder)) + h.say("would build pages: shell, tree, reply, log, new") + return 0 + + pj.save(folder, cfg) + h.say("wrote %s" % pj.path(folder)) + + # The pages are built by this machine, from this folder, in this session. + code = os.spawnv( + os.P_WAIT, + sys.executable, + [sys.executable, os.path.join(HERE, "board"), "--folder", folder], + ) + return code + + +if __name__ == "__main__": + h.main_guard(main) diff --git a/bin/project-reset b/bin/project-reset new file mode 100755 index 0000000..b623090 --- /dev/null +++ b/bin/project-reset @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Start a project again: close its pages, remove its worktrees, keep its work. + + bin/project-reset --dry-run + bin/project-reset --yes + bin/project-reset --yes --delete-folder + +What it removes, and nothing else: + + - the project's screen panes in its TUIOS session (the shell pane stays, so + the session survives) + - the git worktrees Paperclip made for the project's tasks, and the branches + those tasks were on + +What it leaves alone: the folder, its `.harness`, its git history, and every +Paperclip record — tasks, comments, runs, cost. Paperclip owns those, so a reset +here cannot erase the history of what happened; it only clears the machinery. + +`--delete-folder` is the only destructive option and it asks for the folder name +to be typed. Without it, nothing outside git and TUIOS is touched. +""" + +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "lib")) +import harness as h # noqa: E402 +import tuios # noqa: E402 +import project as pj # noqa: E402 + +PAGES = ["tree", "reply", "log", "new"] + + +def main(): + argv = sys.argv[1:] + args = [a for a in argv if not a.startswith("--")] + if not args: + h.say("usage: bin/project-reset [--dry-run] [--yes] [--delete-folder]") + return 2 + folder = os.path.abspath(os.path.expanduser(args[0])) + dry = "--dry-run" in argv + yes = "--yes" in argv + delete_folder = "--delete-folder" in argv + + cfg = pj.load(folder) + pj.check_machine(cfg) + session = cfg["session"] + project_id = cfg["paperclip"]["project"] + + tasks = pj.active_tasks(project_id) + worktrees = [] + for t in tasks: + full = h.api("GET", "/api/issues/%s" % t["id"]) + ws = full.get("currentExecutionWorkspace") or {} + if ws.get("cwd"): + worktrees.append((t["identifier"], ws["cwd"], ws.get("branchName") or "")) + + h.say("project: %s" % project_id) + h.say("folder: %s" % folder) + h.say("session: %s" % session) + h.say("") + h.say("would remove:") + h.say(" panes: %s in %s" % (", ".join(PAGES), session)) + for ref, cwd, branch in worktrees: + h.say(" worktree: %s (%s, branch %s)" % (cwd, ref, branch or "none")) + if delete_folder: + h.say(" the folder: %s (including .harness and git history)" % folder) + h.say("kept: the Paperclip project, its tasks, comments, runs and cost") + + if dry: + h.say("") + h.say("dry run: nothing was removed") + return 0 + + if not yes: + h.say("") + h.say("Type the folder name to continue: %s" % os.path.basename(folder)) + typed = input("> ").strip() + if typed != os.path.basename(folder): + h.say("nothing was removed") + return 1 + + for name in PAGES: + if tuios.close_pane(name, session): + h.say("closed pane %s" % name) + + repo = folder + for ref, cwd, branch in worktrees: + code, out = h.run(["git", "-C", repo, "worktree", "remove", "--force", cwd]) + h.say("worktree %s: %s" % (ref, "removed" if code == 0 else out.strip()[:120])) + if branch: + code, out = h.run(["git", "-C", repo, "branch", "-D", branch]) + h.say("branch %s: %s" % (branch, "deleted" if code == 0 else out.strip()[:120])) + + if delete_folder: + h.say("") + h.say("Type the folder name again to delete %s: " % folder) + typed = input("> ").strip() + if typed != os.path.basename(folder): + h.say("folder kept") + return 0 + code, out = h.run(["rm", "-rf", folder]) + h.say("folder %s" % ("deleted" if code == 0 else "not deleted: " + out.strip())) + return 0 + + +if __name__ == "__main__": + h.main_guard(main) diff --git a/bin/review b/bin/review new file mode 100755 index 0000000..c033929 --- /dev/null +++ b/bin/review @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Review one task's diff and print the verdict. Merge nothing. + + bin/review AUD-164 # review and print + bin/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 +before the merge is attempted. Exit 0 means nothing blocking was found; exit 5 +means the review refused; exit 7 means there is no worktree to look at. +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib")) +import harness as h # noqa: E402 + + +def main(): + args = [a for a in sys.argv[1:] if not a.startswith("--")] + post = "--post" in sys.argv + if len(args) != 1: + h.say("usage: bin/review [--post]") + return 2 + + issue = h.find_issue(args[0]) + ref = issue["identifier"] + worktree, branch, base = h.worktree_of(issue) + + h.say("%s %s" % (ref, issue["title"])) + h.say(" worktree %s" % worktree) + h.say(" range %s...HEAD (%s)" % (base, branch)) + + report = h.ocr_review(worktree, base, "HEAD") + status = report.get("status") + reviewed = (report.get("summary") or {}).get("files_reviewed", 0) + comments = report.get("comments") or [] + + if status == "skipped" and reviewed == 0: + h.say(" nothing reviewable in the change (0 files)") + if post: + h.verdict(issue, "## Review: nothing to review\n\nocr selected 0 files " + "from the change, so nothing was checked.") + return 0 + if status != "complete": + h.say(" review could not complete: %s" % (report.get("message") or status)) + if post: + h.verdict(issue, "## Review could not run\n\nocr returned `%s` — %s" + % (status, report.get("message") or "no reason given")) + return 5 + + blocking = h.blocking_findings(report) + for c in comments: + marker = "BLOCKS" if c in blocking else "note " + h.say(" %s %s" % (marker, h.describe(c))) + + verdict = ("%d comment(s); %d blocking" % (len(comments), len(blocking))) + h.say(" %s" % verdict) + + if post: + body = "## Review\n\n%s" % (verdict) + if blocking: + body += "\n\nBlocking:\n" + "\n".join(h.describe(c) for c in blocking) + h.verdict(issue, body) + + return 5 if blocking else 0 + + +if __name__ == "__main__": + h.main_guard(main) diff --git a/docs/PAPERCLIP-API-NOTES.md b/docs/PAPERCLIP-API-NOTES.md index e67bc28..e5a25d7 100644 --- a/docs/PAPERCLIP-API-NOTES.md +++ b/docs/PAPERCLIP-API-NOTES.md @@ -390,3 +390,87 @@ status, and our screens should show that second run rather than hide it. under the board identity by mistake". So `authorType: user` is not proof that the person typed it. Screen 4 currently prints `user` as "you"; that label is wrong often enough to matter, and it needs a better rule than the author type alone. + +--- + +# Milestones 4 and 5, measured, 2026-11-10 + +## The project layer + +`bin/office ` writes exactly one file, `/.harness/harness.toml`: + +```toml +session = "paperclip-harness" +watching = "AUD-163" + +[paperclip] +company = "d4921013-e5da-4d88-8016-57d5746e2d03" +project = "17adbb47-f966-4a82-a8ef-e48c941443ea" + +[machine] +host = "nixos-desktop" +folder = "/tmp/pc-sandbox-2026-11-10" +``` + +That is the whole of what we store: which folder, on which machine, in which session, +showing which task. Everything else is read from Paperclip. + +Measured, in this order, on one project: + +| Step | Result | +|---|---| +| `bin/office` | wrote the file, built five panes on workspaces 1–5 | +| run again | five panes adopted, none created | +| `bin/board --replace` | five closed, five created, then placed 1–5 in order | +| `bin/project-reset --dry-run` | listed the panes, three worktrees and three branches | +| `bin/project-reset --yes` | removed them; `git worktree list` left only the main checkout | +| `bin/office` afterwards | five panes built again from nothing | + +Two failures found by doing it: + +- **A rebuild scatters the workspace order.** Closing panes renumbers the rest, so the + pages landed as shell 2, tree 3, reply 4, log 5, new 1. The fix is a final pass that + moves each pane once to the workspace it belongs on (`tuios move-window`) and names it + again. Without that pass the rail order is meaningless. +- **Adopting a project must not invent a session.** The first version defaulted the session + to the folder name, wrote that into the file, then failed because no such session + existed — and the good session name was already overwritten. Sessions are made by a + person, so `bin/office` now reads the recorded session first, checks it exists, and + refuses with the exact `tuios new` command when it does not. + +## The merge gate + +`bin/merge` takes a lock (`flock` on `/tmp/paperclip-harness-merge.lock`), rebases the +task's branch onto the base, runs the task's own verify command, runs `ocr` over the +rebased range, and only then merges — then runs the verify command again on the base +branch and rolls the merge back if it fails. + +Every branch of that path was exercised, not reasoned about: + +| Case | What happened | +|---|---| +| clean task (`AUD-163`, docs only) | merged at `2899981`; `ocr` reported `skipped`, 0 files, and said so instead of pretending to have reviewed | +| verify command fails | exit 4, task left alone, comment written | +| review finds a critical | `AUD-165` added `cleanup.sh` with `rm -rf "$HOME/tmp"`; exit 5, finding quoted, `cleanup.sh` never reached `main` | +| review cannot run | exit 5, refused | +| another merge holds the lock | exit 6, refused immediately | + +`ocr`'s JSON has `status`, `summary.files_reviewed` and `comments[]` with `severity` +(`critical`, `high`, …), `path`, `start_line` and `content`. Blocking severities are +`critical` and `high`; `skipped` with zero files is "nothing to review", recorded +plainly, while any other non-`complete` status refuses. + +## Two API behaviours that shape the code + +**A comment on an assigned task wakes its agent.** The merge tool's first refusal comment +went onto a task still assigned to `Builder`, and `Builder` immediately woke and took the +task back to `in_progress` — paying for a run to read a message meant for a person. So a +tool's verdict is written only after the task is handed back: +`PATCH {"assigneeAgentId": null, "assigneeUserId": ""}`. A person's reply +from screen 4 is the opposite case: there the wake is the point. + +**The issue list is a summary.** `GET /api/companies/{id}/issues` carries no +`currentExecutionWorkspace` and no blockers; the single-task read has both. Any code that +needs a worktree path, a branch or a blocker must do a second `GET /api/issues/{id}`. +That is the third time this list-versus-record difference has bitten, after blockers and +after the earlier probe.