@jesdi/skills 0.0.29 → 0.0.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +61 -13
  2. package/package.json +9 -1
  3. package/skills/implement-spec/ARCHITECTURE.md +113 -0
  4. package/skills/implement-spec/README.md +214 -0
  5. package/skills/implement-spec/SKILL.md +18 -70
  6. package/skills/implement-spec/driver.py +580 -0
  7. package/skills/implement-spec/driver_agents.py +145 -0
  8. package/skills/implement-spec/driver_answers.py +79 -0
  9. package/skills/implement-spec/driver_decisions.py +152 -0
  10. package/skills/implement-spec/driver_final.py +505 -0
  11. package/skills/implement-spec/driver_fixloop.py +170 -0
  12. package/skills/implement-spec/driver_gate.py +47 -0
  13. package/skills/implement-spec/driver_graph.py +42 -0
  14. package/skills/implement-spec/driver_merge.py +105 -0
  15. package/skills/implement-spec/driver_report.py +115 -0
  16. package/skills/implement-spec/driver_review.py +126 -0
  17. package/skills/implement-spec/driver_review_page.py +51 -0
  18. package/skills/implement-spec/driver_run.py +262 -0
  19. package/skills/implement-spec/driver_slots.py +64 -0
  20. package/skills/implement-spec/driver_state.py +105 -0
  21. package/skills/implement-spec/driver_summary.py +40 -0
  22. package/skills/implement-spec/driver_tickets.py +132 -0
  23. package/skills/implement-spec/driver_tidy.py +57 -0
  24. package/skills/implement-spec/models.json +10 -0
  25. package/skills/implement-spec/prompts/conflict.md +32 -0
  26. package/skills/implement-spec/prompts/correction.md +4 -0
  27. package/skills/implement-spec/prompts/correctness.md +34 -0
  28. package/skills/implement-spec/prompts/final-fixer.md +36 -0
  29. package/skills/implement-spec/prompts/final-reviewer.md +36 -0
  30. package/skills/implement-spec/prompts/final-scoped.md +1 -0
  31. package/skills/implement-spec/prompts/fixer.md +35 -0
  32. package/skills/implement-spec/prompts/implementer.md +33 -0
  33. package/skills/implement-spec/prompts/interrupted.md +1 -0
  34. package/skills/implement-spec/prompts/moved.md +2 -0
  35. package/skills/implement-spec/prompts/overruled.md +5 -0
  36. package/skills/implement-spec/prompts/pr-body.md +19 -0
  37. package/skills/implement-spec/prompts/rebase-conflict.md +33 -0
  38. package/skills/implement-spec/prompts/rereview.md +3 -0
  39. package/skills/implement-spec/prompts/rerun.md +2 -0
  40. package/skills/implement-spec/prompts/reviewer.md +34 -0
  41. package/skills/implement-spec/prompts/stands.md +2 -0
  42. package/skills/implement-spec/prompts/test-writer.md +34 -0
  43. package/skills/implement-spec/prompts/timeout-restored.md +2 -0
  44. package/skills/implement-spec/prompts/timeout-shared.md +2 -0
  45. package/skills/implement-spec/prompts/timeout.md +2 -0
  46. package/skills/implement-spec/run.sh +101 -0
  47. package/skills/implement-spec/tests/conftest.py +38 -0
  48. package/skills/implement-spec/tests/test_agents.py +358 -0
  49. package/skills/implement-spec/tests/test_amendments.py +117 -0
  50. package/skills/implement-spec/tests/test_cli.py +220 -0
  51. package/skills/implement-spec/tests/test_decisions.py +434 -0
  52. package/skills/implement-spec/tests/test_driver.py +767 -0
  53. package/skills/implement-spec/tests/test_final.py +442 -0
  54. package/skills/implement-spec/tests/test_fixloop.py +523 -0
  55. package/skills/implement-spec/tests/test_graph.py +32 -0
  56. package/skills/implement-spec/tests/test_landing.py +355 -0
  57. package/skills/implement-spec/tests/test_merge.py +144 -0
  58. package/skills/implement-spec/tests/test_models.py +47 -0
  59. package/skills/implement-spec/tests/test_report.py +162 -0
  60. package/skills/implement-spec/tests/test_review.py +338 -0
  61. package/skills/implement-spec/tests/test_review_page.py +67 -0
  62. package/skills/implement-spec/tests/test_slots.py +32 -0
  63. package/skills/implement-spec/tests/test_state.py +68 -0
  64. package/skills/implement-spec/tests/test_stops.py +272 -0
  65. package/skills/implement-spec/tests/test_summary.py +28 -0
  66. package/skills/implement-spec/tests/test_tickets.py +72 -0
  67. package/skills/implement-spec/tests/test_tidy.py +582 -0
  68. package/skills/implement-spec/tests/test_walk.py +457 -0
  69. package/skills/review-page/SKILL.md +70 -0
  70. package/skills/review-page/schema.md +59 -0
  71. package/skills/review-page/template.html +232 -0
  72. package/skills/to-tickets/SKILL.md +19 -2
  73. package/skills-manifest.json +11 -5
  74. package/skills/implement-spec/history.md +0 -52
@@ -0,0 +1,457 @@
1
+ """Ticket 03 acceptance tests: walk the graph with slots, gate by the driver, merge, resume.
2
+
3
+ All through `sh run.sh --runner <script> --parallel N [--prepare <cmd>] --gate <cmd>` on the three-ticket fixture
4
+ repository. The fake runner plays test-writer and implementer per ticket; it logs, per call, its cwd, branch, HEAD,
5
+ start and end time into `<rig>/calls/`. The gate is a small script that logs its commit, status, branch and
6
+ environment into `<rig>/gates/`. Concurrency is steered with signal files (`touch` / `wait` plan keys), never sleeps.
7
+
8
+ Interface choices (ticket 03):
9
+ - gate record: `.agent/implement/<NN>/gate-<first 7 hex of the commit>.log`.
10
+ - `summary.md` sits in `.agent/implement/` and its table is repeated on stdout.
11
+ - `state.json` is `{"tickets": {"NN": {phase, round, tip, test_writer_sha, locked, green_gates, slot}}}`;
12
+ `green_gates` holds full SHAs (a dict with a `commit` key is accepted too).
13
+
14
+ Ticket 08: this fixture has no `origin`, and a run that merges every ticket now ends with the tidy, one gate on the
15
+ tidied tip and the final review. The history as the walk made it is at `refs/implement/recovery`: `Rig.walked()` is
16
+ that tip, `Rig.pr_subjects()` reads it, and `Rig.walk_gates()` leaves out the one gate of the end of run.
17
+ """
18
+ import json
19
+ import os
20
+ import re
21
+ import shutil
22
+ import signal
23
+ import subprocess
24
+ import sys
25
+ import time
26
+ from pathlib import Path
27
+
28
+ import pytest
29
+
30
+ from test_cli import RUN, TOOLS, git, make_repo, models_file, ticket, three
31
+
32
+ RUNNER = f"""#!{sys.executable}
33
+ import json, os, subprocess, sys, time
34
+ from pathlib import Path
35
+ rig = Path(__file__).resolve().parent
36
+ role, prompt, report = sys.argv[1:4]
37
+ nn = Path(report).parent.name
38
+ key = f"{{nn}}/{{role}}"
39
+ def git(*a):
40
+ cmd = ["git", "-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", *a]
41
+ return subprocess.run(cmd, capture_output=True, text=True).stdout.strip()
42
+ def save(rec):
43
+ tmp = rig / "calls" / (rec["id"] + ".tmp")
44
+ tmp.write_text(json.dumps(rec))
45
+ os.replace(tmp, rig / "calls" / (rec["id"] + ".json"))
46
+ def where():
47
+ return dict(branch=git("rev-parse", "--abbrev-ref", "HEAD"), head=git("rev-parse", "HEAD"),
48
+ status=git("status", "--porcelain"))
49
+ def wait(done):
50
+ end = time.time() + 6
51
+ while not done():
52
+ if time.time() > end:
53
+ sys.exit(7)
54
+ time.sleep(0.02)
55
+ (rig / "calls").mkdir(exist_ok=True)
56
+ (rig / "sig").mkdir(exist_ok=True)
57
+ n = sum(1 for f in (rig / "calls").glob("*.json") if json.loads(f.read_text())["key"] == key)
58
+ rec = dict(id=f"{{time.time_ns()}}-{{os.getpid()}}", key=key, nn=nn, role=role, t0=time.time(),
59
+ cwd=os.path.realpath(os.getcwd()), prompt=Path(prompt).read_text(), **where(),
60
+ log=git("log", "--format=%s", "HEAD"), pr_log=git("log", "--format=%s", "feat/csv"))
61
+ save(rec)
62
+ step = json.loads((rig / "plan.json").read_text()).get(key, [])
63
+ step = step[n] if n < len(step) else {{}}
64
+ if role in ("reviewer", "correctness", "final-reviewer") and "report" not in step: # a role the test does not script reports no findings
65
+ reds = [f"FAILED {{p}}::test_x - AttributeError" for p in git("diff", "--name-only", "feat/csv...HEAD", "--", "tests").split()]
66
+ proofs = [dict(test=r.split()[1], file="demo/export.py", line=1, red=r) for r in reds]
67
+ step = dict(step, stdout="".join(r + "\\n" for r in reds), report=dict(
68
+ status="DONE", commits=[], files=[], concerns=[], findings=[], mutations=proofs))
69
+ for name in step.get("touch", []):
70
+ (rig / "sig" / name).touch()
71
+ for name in step.get("wait", []):
72
+ wait(lambda: (rig / "sig" / name).exists())
73
+ if step.get("wait_pr_moves"):
74
+ wait(lambda: git("rev-parse", "feat/csv") != git("merge-base", "HEAD", "feat/csv"))
75
+ rec["after_wait"] = where()
76
+ for path, text in step.get("files", {{}}).items():
77
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
78
+ Path(path).write_text(text)
79
+ sys.stdout.write(step.get("stdout", ""))
80
+ sys.stdout.flush()
81
+ time.sleep(step.get("sleep", 0))
82
+ sha = ""
83
+ if "commit" in step:
84
+ git("add", "-A")
85
+ git("commit", "-q", "-m", step["commit"])
86
+ sha = git("rev-parse", "HEAD")
87
+ if step.get("report") is not None:
88
+ Path(report).parent.mkdir(parents=True, exist_ok=True)
89
+ Path(report).write_text(json.dumps(step["report"]).replace("$SHA", sha))
90
+ rec.update(t1=time.time(), sha=sha)
91
+ save(rec)
92
+ """
93
+
94
+ GATE = f"""#!{sys.executable}
95
+ import json, os, subprocess, sys, time
96
+ from pathlib import Path
97
+ rig = Path(__file__).resolve().parent
98
+ def git(*a):
99
+ return subprocess.run(["git", *a], capture_output=True, text=True).stdout.strip()
100
+ conf = json.loads((rig / "gate.json").read_text())
101
+ (rig / "gates").mkdir(exist_ok=True)
102
+ branch = git("rev-parse", "--abbrev-ref", "HEAD")
103
+ code = 1 if conf.get("all") or branch in conf.get("red", []) else 0
104
+ rec = dict(t0=time.time(), cwd=os.path.realpath(os.getcwd()), commit=git("rev-parse", "HEAD"), branch=branch,
105
+ status=git("status", "--short"), base=os.environ.get("IMPLEMENT_SPEC_BASE"),
106
+ pycache=os.environ.get("PYTHONPYCACHEPREFIX"), exit=code)
107
+ print("GATE-OUTPUT-MARK", rec["commit"])
108
+ (rig / "gates" / f"{{time.time_ns()}}-{{os.getpid()}}.json").write_text(json.dumps(rec))
109
+ sys.exit(code)
110
+ """
111
+
112
+ COST = '{"total_cost_usd": 0.25}\n'
113
+
114
+
115
+ def tw(nn):
116
+ path, red = f"tests/test_{nn}.py", f"FAILED tests/test_{nn}.py::test_x - AttributeError"
117
+ return {"files": {path: f"def test_x():\n assert {nn} == 'x'\n"}, "stdout": red + "\n" + COST,
118
+ "commit": f"test: {nn} red",
119
+ "report": {"status": "DONE", "commits": ["$SHA"], "files": [path], "concerns": [], "locked": [path],
120
+ "red": [{"test": f"{path}::test_x", "line": red}]}}
121
+
122
+
123
+ def impl(nn, path="demo/export.py", text=None, **changes):
124
+ step = {"files": {path: text or f"def fn_{nn}():\n return {nn!r}\n"}, "stdout": COST,
125
+ "commit": f"feat: {nn} rows",
126
+ "report": {"status": "DONE", "commits": ["$SHA"], "files": [path], "concerns": [], "green": ["1 passed"]}}
127
+ return {**step, **changes}
128
+
129
+
130
+ PATHS = {"01": "demo/export.py", "02": "demo/export.py", "03": "demo/cli.py"}
131
+
132
+
133
+ def plan(**changes):
134
+ """Every ticket gets a test-writer and an implementer step; `changes` replace or extend single steps."""
135
+ steps = {}
136
+ for nn, path in PATHS.items():
137
+ steps[f"{nn}/test-writer"] = [tw(nn)]
138
+ steps[f"{nn}/implementer"] = [impl(nn, path)]
139
+ steps.update({key.replace("_", "/", 1).replace("_", "-"): value for key, value in changes.items()})
140
+ return steps
141
+
142
+
143
+ def make_env(tmp):
144
+ bin_dir = tmp / "bin"
145
+ bin_dir.mkdir()
146
+ for name in TOOLS:
147
+ if shutil.which(name):
148
+ (bin_dir / name).symlink_to(shutil.which(name))
149
+ for cli in ("claude", "codex"):
150
+ (bin_dir / cli).write_text("#!/bin/sh\nexit 1\n")
151
+ (bin_dir / cli).chmod(0o755)
152
+ return dict(os.environ, PATH=str(bin_dir))
153
+
154
+
155
+ class Rig:
156
+ def __init__(self, tmp, files=None, gate=None, ignore=None):
157
+ self.tmp, self.env = tmp, make_env(tmp)
158
+ self.repo = make_repo(tmp / "demo", files or three())
159
+ (self.repo / "specs/csv").mkdir(parents=True)
160
+ (self.repo / "specs/csv/spec.md").write_text("# csv spec\n")
161
+ if ignore:
162
+ with (self.repo / ".gitignore").open("a") as f:
163
+ f.write(ignore + "\n")
164
+ git(self.repo, "add", "-A")
165
+ git(self.repo, "commit", "-q", "-m", "spec")
166
+ self.rig = tmp / "rig"
167
+ self.rig.mkdir()
168
+ for name, text in (("run-agent", RUNNER), ("gate", GATE)):
169
+ (self.rig / name).write_text(text)
170
+ (self.rig / name).chmod(0o755)
171
+ self.models = models_file(tmp)
172
+ self.gate_conf(**(gate or {}))
173
+ self.slot2 = (self.repo / ".agent/worktrees/slot-2").resolve()
174
+
175
+ def gate_conf(self, **conf):
176
+ (self.rig / "gate.json").write_text(json.dumps(conf))
177
+
178
+ def run(self, steps, *extra, parallel="2", watch=False):
179
+ """Run the driver once; with `watch`, read state.json in a tight loop meanwhile (torn reads, snapshots)."""
180
+ (self.rig / "plan.json").write_text(json.dumps(steps))
181
+ args = ["--tickets", ".agent/tickets", "--gate", str(self.rig / "gate"), "--spec", "specs/csv/spec.md",
182
+ "--models", str(self.models), "--runner", str(self.rig / "run-agent"), "--parallel", parallel, *extra]
183
+ out, err = self.tmp / "out.txt", self.tmp / "err.txt"
184
+ with out.open("w") as o, err.open("w") as e:
185
+ proc = subprocess.Popen(["sh", str(RUN), *args], cwd=self.repo, env=self.env, text=True, stdout=o,
186
+ stderr=e, start_new_session=True)
187
+ self.snaps, self.torn = [], []
188
+ state, end = self.repo / ".agent/implement/state.json", time.time() + 120
189
+ while proc.poll() is None and time.time() < end:
190
+ if watch:
191
+ try:
192
+ self.snaps.append(json.loads(state.read_text()))
193
+ except FileNotFoundError:
194
+ pass
195
+ except json.JSONDecodeError as exc:
196
+ self.torn.append(str(exc))
197
+ time.sleep(0.002)
198
+ if proc.poll() is None:
199
+ os.killpg(proc.pid, signal.SIGKILL)
200
+ proc.wait()
201
+ self.code, self.out, self.err = proc.returncode, out.read_text(), err.read_text()
202
+ return self
203
+
204
+ def calls(self, nn=None, role=None):
205
+ folder = self.rig / "calls"
206
+ rows = [json.loads(p.read_text()) for p in folder.glob("*.json")] if folder.exists() else []
207
+ rows = [r for r in rows if nn in (None, r["nn"]) and role in (None, r["role"])]
208
+ return sorted(rows, key=lambda r: r["t0"])
209
+
210
+ def sha(self, nn, role):
211
+ return self.calls(nn, role)[0]["sha"]
212
+
213
+ def gates(self):
214
+ folder = self.rig / "gates"
215
+ rows = [json.loads(p.read_text()) for p in folder.glob("*.json")] if folder.exists() else []
216
+ return sorted(rows, key=lambda r: r["t0"])
217
+
218
+ def ledger(self):
219
+ path = self.repo / ".agent/implement/ledger.md"
220
+ return path.read_text().splitlines() if path.exists() else []
221
+
222
+ def state(self):
223
+ return json.loads((self.repo / ".agent/implement/state.json").read_text())
224
+
225
+ def walked(self):
226
+ """The PR tip as the walk left it: the recovery point of the end-of-run tidy, or the branch with no tidy."""
227
+ out = subprocess.run(["git", "rev-parse", "--verify", "-q", "refs/implement/recovery"], cwd=self.repo,
228
+ capture_output=True, text=True).stdout.strip()
229
+ return out or git(self.repo, "rev-parse", "feat/csv").strip()
230
+
231
+ def walk_gates(self):
232
+ """The gates of the walk: all but the gate of the end of run, which is on a tidied tip the walk never made."""
233
+ made = git(self.repo, "rev-list", self.walked()).split()
234
+ return [g for g in self.gates() if g["commit"] in made]
235
+
236
+ def pr_subjects(self):
237
+ return git(self.repo, "log", "--format=%s", self.walked()).splitlines()
238
+
239
+ def branches(self):
240
+ return git(self.repo, "branch", "--list", "ticket/*").split()
241
+
242
+ def output(self):
243
+ return f"exit {self.code}\n{self.out}{self.err}"
244
+
245
+
246
+ def commit_of(entry):
247
+ return entry if isinstance(entry, str) else entry["commit"]
248
+
249
+
250
+ def green(state, nn):
251
+ return {commit_of(e) for e in state["tickets"][nn]["green_gates"]}
252
+
253
+
254
+ @pytest.fixture(scope="module")
255
+ def std(tmp_path_factory):
256
+ """The default scenario: three tickets, --parallel 2, 01's implementer holds until 03 is in flight."""
257
+ rig = Rig(tmp_path_factory.mktemp("std"))
258
+ steps = plan(**{"03_test-writer": [{**tw("03"), "touch": ["s3"]}], "01_implementer": [{**impl("01"), "wait": ["s3"]}]})
259
+ return rig.run(steps, watch=True)
260
+
261
+
262
+ @pytest.fixture(scope="module")
263
+ def blocked(tmp_path_factory):
264
+ """The default scenario with a red gate on ticket/01."""
265
+ return Rig(tmp_path_factory.mktemp("blocked"), gate={"red": ["ticket/01"]}).run(plan())
266
+
267
+
268
+ def test_01_starts_in_slot_1_03_in_slot_2_and_02_after_the_merge_of_01(std):
269
+ assert std.code == 0, std.output()
270
+ first = lambda nn: std.calls(nn)[0]
271
+ assert first("01")["cwd"] == str(std.repo.resolve()) and first("03")["cwd"] == str(std.slot2)
272
+ assert "feat: 01 rows" in first("02")["pr_log"] and "feat: 01 rows" in first("02")["log"]
273
+ assert first("02")["t0"] > std.calls("01")[-1]["t1"] and first("02")["branch"] == "ticket/02"
274
+
275
+
276
+ def test_touches_overlap_holds_03_until_01_is_merged(tmp_path):
277
+ files = three(**{"03-cli-flag.md": ticket("03", "CLI flag", seam="`demo export` CLI",
278
+ touches="`demo/export.py: export_csv`")})
279
+ rig = Rig(tmp_path, files).run(plan())
280
+ assert rig.code == 0, rig.output()
281
+ assert rig.calls("03"), "03 never started"
282
+ third = rig.calls("03")[0]
283
+ assert third["t0"] >= rig.calls("01")[-1]["t1"]
284
+ assert "feat: 01 rows" in third["pr_log"] and "feat: 01 rows" in third["log"]
285
+
286
+
287
+ def test_prepare_runs_once_in_slot_2_and_the_slot_is_reused(tmp_path):
288
+ files = three(**{"02-quote-fields.md": ticket("02", "Quote fields", "03", touches="`demo/other.py: g`")})
289
+ rig = Rig(tmp_path, files, ignore="node_modules")
290
+ (rig.repo / "node_modules").mkdir()
291
+ prepare = f"ln -s ../../../node_modules node_modules && pwd -P >> {rig.tmp}/prepare.log"
292
+ steps = plan(**{"02_test-writer": [{**tw("02"), "touch": ["s2"]}],
293
+ "01_implementer": [{**impl("01"), "wait": ["s2"]}],
294
+ "02_implementer": [impl("02", "demo/other.py")]})
295
+ rig.run(steps, "--prepare", prepare)
296
+ assert rig.code == 0, rig.output()
297
+ assert (rig.tmp / "prepare.log").read_text().split() == [str(rig.slot2)]
298
+ assert rig.calls("03")[0]["cwd"] == rig.calls("02")[0]["cwd"] == str(rig.slot2)
299
+ assert (rig.slot2 / "node_modules").is_symlink() and all(g["status"] == "" for g in rig.gates())
300
+
301
+
302
+ def test_failing_prepare_exits_3_before_any_agent_naming_slot_and_command(tmp_path):
303
+ rig = Rig(tmp_path).run(plan(), "--prepare", "false")
304
+ assert rig.code == 3, rig.output()
305
+ assert re.search(r"slot[- ]?2", rig.out + rig.err) and "false" in rig.out + rig.err
306
+ assert not rig.calls() and not rig.gates()
307
+
308
+
309
+ def test_fast_forward_merge_has_exactly_one_gate_record_and_no_merge_gate(tmp_path):
310
+ rig = Rig(tmp_path, {"01-export-rows.md": ticket("01", "Export rows")}).run(plan())
311
+ assert rig.code == 0, rig.output()
312
+ a1 = rig.sha("01", "implementer")
313
+ assert rig.walked() == a1
314
+ records = sorted((rig.repo / ".agent/implement/01").glob("gate-*.log"))
315
+ assert [p.name for p in records] == [f"gate-{a1[:7]}.log"]
316
+ text = records[0].read_text()
317
+ assert a1[:7] in text and "GATE-OUTPUT-MARK" in text and len(re.findall(r"\d\d:\d\d:\d\d", text)) >= 2
318
+ assert re.search(r"exit\D*0", text) and not re.search(r"^(\?\?| M|M |A )", text, re.M)
319
+ (gate,) = rig.walk_gates()
320
+ assert (gate["commit"], gate["status"], gate["cwd"]) == (a1, "", str(rig.repo.resolve()))
321
+
322
+
323
+ def test_gate_environment_names_the_pr_branch_and_a_pycache_prefix_under_agent_implement(tmp_path):
324
+ rig = Rig(tmp_path, {"01-export-rows.md": ticket("01", "Export rows")}).run(plan())
325
+ assert rig.code == 0, rig.output()
326
+ (gate,) = rig.walk_gates()
327
+ assert gate["base"] == "feat/csv" and gate["pycache"]
328
+ assert os.path.realpath(gate["pycache"]).startswith(str((rig.repo / ".agent/implement").resolve()) + os.sep)
329
+
330
+
331
+ def test_red_gate_blocks_the_ticket_names_the_record_and_launches_no_later_role(blocked):
332
+ assert blocked.code == 1, blocked.output()
333
+ a1 = blocked.sha("01", "implementer")
334
+ assert [l for l in blocked.ledger() if "01" in l and "BLOCKED" in l and f"gate-{a1[:7]}.log" in l], blocked.ledger()
335
+ assert re.search(r"exit\D*1", (blocked.repo / f".agent/implement/01/gate-{a1[:7]}.log").read_text())
336
+ assert {c["role"] for c in blocked.calls("01")} == {"test-writer", "implementer"}
337
+ assert blocked.state()["tickets"]["01"]["phase"] == "blocked"
338
+ assert "ticket/01" in blocked.branches() and "feat: 01 rows" not in blocked.pr_subjects()
339
+
340
+
341
+ def test_blocked_ticket_stops_only_the_tickets_it_blocks(blocked):
342
+ assert not blocked.calls("02"), "02 is blocked by 01 and must not start"
343
+ assert "feat: 03 rows" in blocked.pr_subjects() and blocked.state()["tickets"]["03"]["phase"] == "merged"
344
+ assert blocked.code == 1
345
+
346
+
347
+ def test_non_fast_forward_merge_is_a_plumbing_merge_commit_gated_once(tmp_path):
348
+ files = {k: v for k, v in three().items() if not k.startswith("02")}
349
+ rig = Rig(tmp_path, files)
350
+ steps = plan(**{"01_test-writer": [{**tw("01"), "touch": ["s1"]}],
351
+ "03_implementer": [{**impl("03", "demo/cli.py"), "wait": ["s1"]}],
352
+ "01_implementer": [{**impl("01"), "wait_pr_moves": True}]})
353
+ rig.run(steps)
354
+ assert rig.code == 0, rig.output()
355
+ a01, a03 = rig.sha("01", "implementer"), rig.sha("03", "implementer")
356
+ tip = rig.walked()
357
+ assert len(git(rig.repo, "rev-list", "--parents", "-n1", tip).split()) == 3, "tip is a merge commit"
358
+ commits = [g["commit"] for g in rig.walk_gates()]
359
+ assert sorted(commits) == sorted([a01, a03, tip]), commits
360
+ waited = rig.calls("01", "implementer")[0]
361
+ assert waited["after_wait"]["branch"] == "ticket/01" and waited["after_wait"]["head"] == waited["head"]
362
+ assert waited["after_wait"]["status"] == ""
363
+ assert git(rig.repo, "rev-parse", "--abbrev-ref", "HEAD").strip() == "feat/csv"
364
+ assert git(rig.repo, "status", "--short") == "" and (rig.repo / "demo/cli.py").exists()
365
+ assert "fn_01" in (rig.repo / "demo/export.py").read_text()
366
+
367
+
368
+ def test_merged_ticket_branches_are_deleted_and_their_commits_are_ancestors(std):
369
+ assert std.code == 0, std.output()
370
+ assert std.branches() == []
371
+ for nn in ("01", "02", "03"):
372
+ for role in ("test-writer", "implementer"):
373
+ git(std.repo, "merge-base", "--is-ancestor", std.sha(nn, role), std.walked())
374
+ assert git(std.repo, "rev-parse", "--abbrev-ref", "HEAD").strip() == "feat/csv"
375
+ assert git(std.repo, "status", "--short") == ""
376
+
377
+
378
+ def test_state_json_is_atomic_and_no_transition_is_lost_with_two_tickets_in_flight(std):
379
+ assert std.code == 0, std.output()
380
+ assert not std.torn and std.snaps, std.torn
381
+ seen_phase, seen_green = {}, {}
382
+ for snap in std.snaps:
383
+ for nn, entry in snap["tickets"].items():
384
+ if seen_phase.get(nn) == "merged":
385
+ assert entry["phase"] == "merged", f"{nn} regressed to {entry['phase']}"
386
+ seen_phase[nn] = entry["phase"]
387
+ assert seen_green.get(nn, set()) <= green(snap, nn), f"{nn} lost a recorded gate"
388
+ seen_green[nn] = green(snap, nn)
389
+ final = std.state()
390
+ for nn in ("01", "02", "03"):
391
+ entry = final["tickets"][nn]
392
+ assert entry["phase"] == "merged" and entry["round"] == 0
393
+ assert std.sha(nn, "implementer") in green(final, nn)
394
+ assert entry["test_writer_sha"] == std.sha(nn, "test-writer") and entry["locked"] == [f"tests/test_{nn}.py"]
395
+
396
+
397
+ def test_ledger_is_plain_sentences_written_by_the_driver_and_named_in_no_prompt(std):
398
+ ledger = std.ledger()
399
+ assert ledger and all(l.strip() == l and l and not l.startswith(("#", "|", "-", "*", "{")) for l in ledger), ledger
400
+ for nn in ("01", "02", "03"):
401
+ assert [l for l in ledger if nn in l and "merged" in l.lower()], (nn, ledger)
402
+ # the final reviewer is the one agent that reads it (requirement 19): it runs here since ticket 08
403
+ assert not [c for c in std.calls() if c["nn"] != "final" and "ledger" in c["prompt"].lower()]
404
+
405
+
406
+ def single_blocked(tmp_path):
407
+ """Run 1 of a resume test: one ticket, red gate, so state.json holds a test-writer commit and a tip."""
408
+ rig = Rig(tmp_path, {"01-export-rows.md": ticket("01", "Export rows")}, gate={"all": True})
409
+ rig.run({**plan(), "01/implementer": [impl("01"), impl("01", text="def fn_01():\n return 'again'\n",
410
+ commit="feat: 01 again")]})
411
+ assert rig.code == 1, rig.output()
412
+ return rig
413
+
414
+
415
+ def test_resume_returns_a_ticket_with_an_unverifiable_tip_to_implement_from_the_test_writer_commit(tmp_path):
416
+ rig = single_blocked(tmp_path)
417
+ state, fake = rig.state(), "1" * 40
418
+ tw_sha = rig.sha("01", "test-writer")
419
+ assert state["tickets"]["01"]["test_writer_sha"] == tw_sha
420
+ state["tickets"]["01"].update(phase="review", tip=fake, green_gates=[])
421
+ (rig.repo / ".agent/implement/state.json").write_text(json.dumps(state))
422
+ before = rig.ledger()
423
+ rig.gate_conf()
424
+ rig.run({**plan(), "01/implementer": [impl("01"), impl("01", text="def fn_01():\n return 'again'\n",
425
+ commit="feat: 01 again")]})
426
+ assert rig.code == 0, rig.output()
427
+ again = rig.calls("01", "implementer")[1]
428
+ assert (again["branch"], again["head"]) == ("ticket/01", tw_sha) and len(rig.calls("01", "test-writer")) == 1
429
+ assert [l for l in rig.ledger()[len(before):] if "01" in l and fake[:7] in l], rig.ledger()
430
+ assert "feat: 01 again" in rig.pr_subjects()
431
+
432
+
433
+ def test_resume_keeps_a_settled_gate_and_runs_no_gate_again(tmp_path):
434
+ rig = single_blocked(tmp_path)
435
+ a1, state = rig.sha("01", "implementer"), rig.state()
436
+ state["tickets"]["01"].update(phase="merge", tip=a1, green_gates=[a1])
437
+ (rig.repo / ".agent/implement/state.json").write_text(json.dumps(state))
438
+ gates_before = len(rig.gates())
439
+ rig.gate_conf(red=["ticket/01"]) # any gate run on the ticket would block it; the end of run gates feat/csv
440
+ rig.run(plan())
441
+ assert rig.code == 0, rig.output()
442
+ assert len(rig.walk_gates()) == gates_before and rig.walked() == a1
443
+ assert [g["commit"] for g in rig.gates()].count(a1) == 1, "the settled commit is gated once in all"
444
+
445
+
446
+ def test_summary_md_and_stdout_carry_the_measurement_table(std):
447
+ summary = (std.repo / ".agent/implement/summary.md").read_text()
448
+ rows = [l for l in summary.splitlines() if l.startswith("|")]
449
+ assert rows and all(row in std.out for row in rows), summary
450
+ lines = summary.splitlines()
451
+ line = lambda *words: [l for l in lines if all(w in l.lower() for w in words)]
452
+ assert [l for l in line("gate") if re.search(rf"\b{len(std.gates())}\b", l)], summary
453
+ for role in ("test-writer", "implementer"):
454
+ assert [l for l in line(role) if "anthropic/claude-opus-5@medium" in l and re.search(r"\b3\b", l)], summary
455
+ assert [l for l in line(role) if re.search(r"\d+(\.\d+)?\s*s\b", l)], summary
456
+ assert all(line(f"{nn}: 0") for nn in ("01", "02", "03")) and [l for l in line("stop") if "0" in l], summary
457
+ assert re.search(r"1\.5", summary), "six agents at 0.25 each"
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: review-page
3
+ description: One interactive HTML page for a plan review or a questionnaire, built from a fixed template. Use when a pipeline session must deliver a plan review or a questionnaire as an interactive page the console shows.
4
+ ---
5
+
6
+ # Review page
7
+
8
+ The session fills a fixed template. The console shows the page in an iframe and reads the operator's answers from it. Messages and the answers file are in [schema.md](schema.md).
9
+
10
+ ## Modes
11
+
12
+ Set the mode on the body: `<body data-mode="plan">` or `<body data-mode="questionnaire">`.
13
+
14
+ - `plan`: tickets, open questions, corrections, a track, and a bar with the count of answered questions.
15
+ - `questionnaire`: questions only. The template hides the other sections.
16
+
17
+ The page has no buttons. The console shows "Send changes" and "Approve" (or "Send answers") outside the page, so a page can never submit or approve. Never add a button or a `submit` value to the page.
18
+
19
+ ## Steps
20
+
21
+ 1. Copy [template.html](template.html) to the path the prompt names: `.agent/review.html` (plan) or `.agent/questionnaire.html` (questionnaire).
22
+ 2. Set `data-mode` on the body.
23
+ 3. Replace the content between `<!-- slot: NAME -->` and `<!-- /slot -->` in each slot. Keep the `<section data-slot="...">` wrappers. Slots: `header` (eyebrow, `h1`, lede), `tickets`, `questions`, `corrections`, `track`. Do not nest a `<section>` in a slot.
24
+ 4. Open the file and check that it renders.
25
+
26
+ ## Slot shapes
27
+
28
+ Ticket, in `ol.tickets`:
29
+
30
+ ```html
31
+ <li><span class="n">01</span><span>Title <span class="by">· blocked by none · seam: file.py</span></span></li>
32
+ ```
33
+
34
+ Question, in `questions`. The `data-q` value, the radio `name` and the note id `note-ID` use the same id:
35
+
36
+ ```html
37
+ <div class="q" data-q="format">
38
+ <header><h3>Question text</h3><span class="chip waiting">needs you</span></header>
39
+ <p class="why">Why it matters.</p>
40
+ <div class="opts">
41
+ <label class="opt"><input type="radio" name="format" value="a"><span><span class="t">Option A <span class="chip rec">recommended</span></span><br><span class="d">Trade-off.</span></span></label>
42
+ <label class="opt"><input type="radio" name="format" value="b"><span><span class="t">Option B</span><br><span class="d">Trade-off.</span></span></label>
43
+ </div>
44
+ <textarea id="note-format" placeholder="Note (optional)"></textarea>
45
+ </div>
46
+ ```
47
+
48
+ Correction, in `corrections`:
49
+
50
+ ```html
51
+ <div class="corr"><p class="was">Old statement.</p><p>New statement.</p></div>
52
+ ```
53
+
54
+ Track pills, in `track`. Check the proposed track:
55
+
56
+ ```html
57
+ <div class="tracks" role="radiogroup" aria-label="track">
58
+ <label><input type="radio" name="track" value="standard" checked>standard</label>
59
+ </div>
60
+ ```
61
+
62
+ ## Rules
63
+
64
+ - Never edit the `<script>` or the CSS. Only fill slots.
65
+ - Every question has an id that matches `[a-z0-9_-]{1,64}`. The id `track` is reserved for the track pills. Slot content never carries the id `state`, or the name `track` outside the track slot: the script owns them.
66
+ - Every question has exactly one recommended option, marked with `chip rec`. It is one recommended option, never zero and never two.
67
+ - Option values are short ids. Put no option text in a value.
68
+ - The page has no network, no storage and no external file: no script `src`, no `<link>`, no image or font URL.
69
+ - Keep `<meta name="agent-ops-review" content="1">`. The dispatcher looks for it.
70
+ - Re-write the file after a plan revision. Remove the questions and tickets that no longer apply.
@@ -0,0 +1,59 @@
1
+ # Review page schema
2
+
3
+ Every message carries `v`. `v` is the integer `1`. It changes only when the console cannot read the old shape.
4
+
5
+ ## Messages
6
+
7
+ Page to console, once, when its script listens:
8
+
9
+ ```json
10
+ {"type": "ready", "v": 1}
11
+ ```
12
+
13
+ Page to console, on every change:
14
+
15
+ ```json
16
+ {"type": "answers", "v": 1, "answers": {"format": "a", "track": "standard"}, "submit": null}
17
+ ```
18
+
19
+ `submit` is always `null`: a page sends drafts only, and the console ignores any other value. The buttons belong to the console: when the operator presses "Send changes" or "Send answers" it posts the last set with `"changes"`, and on the second tap on "Approve" with `"approve"`. The console adds the request's `revision` when it posts the set to the box.
20
+
21
+ Console to page, after `ready` and whenever saved answers arrive:
22
+
23
+ ```json
24
+ {"type": "restore", "v": 1, "answers": {"format": "b", "track": "standard"}}
25
+ ```
26
+
27
+ The page accepts `restore` only from its parent window, and only when `v` is `1`.
28
+
29
+ ## The `answers` object
30
+
31
+ `answers` is a flat JSON object. A key is a question id, a question id followed by `.note`, or `track`. A value is a string (one chosen option id, a note, or a track name), a list of strings (a multi-choice question), or a boolean. A question with no choice has no key. A note appears only when it is not empty.
32
+
33
+ A question id is the `data-q` value of the question block. It matches `[a-z0-9_-]{1,64}`.
34
+
35
+ ## Answers file
36
+
37
+ The dispatcher writes `.agent/review-answers.json` (plan) or `.agent/questionnaire-answers.json` (spec) in the task worktree. Sessions read it. A session that takes a text answer writes it in the same shape.
38
+
39
+ ```json
40
+ {
41
+ "v": 1,
42
+ "stage": "plan",
43
+ "submitted": "changes",
44
+ "submitted_at": "2026-10-12T10:12:03+00:00",
45
+ "actor": "jesdi",
46
+ "revision": "9f2c…",
47
+ "answers": {"debounce": "draft", "debounce.note": "flush on pagehide", "track": "standard"}
48
+ }
49
+ ```
50
+
51
+ - `v`: the integer `1`.
52
+ - `stage`: `"plan"` for `.agent/review-answers.json`, `"spec"` for `.agent/questionnaire-answers.json`.
53
+ - `submitted`: `null` for a draft, `"changes"` or `"approve"` for a submission.
54
+ - `submitted_at`: the time of the intent that was applied, or `null` for a draft.
55
+ - `actor`: the operator's console login, or `"text"` when the session wrote the file from a typed reply.
56
+ - `revision`: the page revision the answers were made on (the request's `revision`). A session that writes the file from a typed reply leaves it out; such a submission blocks later drafts until the page changes.
57
+ - `answers`: the object above.
58
+
59
+ The file holds no option texts.