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

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