Milestones 4 and 5: project layer, review gate and serial merge, all measured

This commit is contained in:
Sam
2026-11-10 12:45:23 +11:00
parent 50fde87450
commit d03636ead3
14 changed files with 1115 additions and 5 deletions

View File

@@ -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 <folder>` writes `<folder>/.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

View File

@@ -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 <folder> [--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 <folder> [--dry-run] [--yes]` | close the pages, remove the worktrees and branches; keep the folder and every Paperclip record |
| `bin/merge <task> [--check]` | rebase, verify, review with `ocr`, merge; a refusal is a decision for the person |
| `bin/review <task> [--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.

89
bin/board Executable file
View File

@@ -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)

Binary file not shown.

Binary file not shown.

Binary file not shown.

194
bin/lib/harness.py Normal file
View File

@@ -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<cmd>[^\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)

116
bin/lib/project.py Normal file
View File

@@ -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:
<project>/.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)

176
bin/lib/tuios.py Normal file
View File

@@ -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")

136
bin/merge Executable file
View File

@@ -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 <task> [--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 <repo>/.paperclip/worktrees/<branch>; 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)

111
bin/office Executable file
View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Open a folder as a project: adopt it, remember it, build its pages.
bin/office <folder> [--project <id|urlKey|name>] [--session NAME] [--dry]
bin/office . # the folder you are in
Adopting writes one file, <folder>/.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 <id|urlKey|name>" % 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)

109
bin/project-reset Executable file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Start a project again: close its pages, remove its worktrees, keep its work.
bin/project-reset <folder> --dry-run
bin/project-reset <folder> --yes
bin/project-reset <folder> --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 <folder> [--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)

70
bin/review Executable file
View File

@@ -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 <task> [--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)

View File

@@ -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 <folder>` writes exactly one file, `<folder>/.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": "<the board user>"}`. 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.