@jesdi/skills 0.0.30 → 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 (72) hide show
  1. package/README.md +12 -6
  2. package/package.json +2 -2
  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/template.html +1 -1
  70. package/skills/to-tickets/SKILL.md +19 -2
  71. package/skills-manifest.json +7 -7
  72. 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"
@@ -55,7 +55,7 @@ ol.tickets .n { font: 500 12px var(--mono); color: var(--muted); font-variant-nu
55
55
  ol.tickets .by { color: var(--muted); font-size: 12px }
56
56
 
57
57
  /* Questions: the one boxed element, because it takes input. */
58
- .q { background: var(--raised); border: 1px solid var(--border); border-radius: 6px; padding: 12px }
58
+ .q { background: var(--raised); border: 1px solid var(--border); border-radius: 6px; padding: 12px; overflow-wrap: anywhere }
59
59
  .q.answered { border-color: color-mix(in srgb, var(--running-fg) 40%, var(--border)) }
60
60
  .q header { display: flex; justify-content: space-between; gap: 8px; align-items: baseline }
61
61
  .q h3 { font-size: 14px; font-weight: 600; margin: 0; text-wrap: balance }
@@ -10,7 +10,8 @@ Changes: tickets always go to `.agent/tickets/<NN>-<slug>.md` (no tracker path);
10
10
  numbering is contiguous from 01 in dependency order; acceptance criteria must state
11
11
  observable behavior; every ticket names its **Seam** (the public interface its
12
12
  acceptance tests hit) and what it **Touches** (files and functions, for
13
- scheduling); trivial slices fold into a neighbour; tickets never carry the
13
+ scheduling) and may carry optional **Risk** and **Suspicions** lines for
14
+ the review; trivial slices fold into a neighbour; tickets never carry the
14
15
  implementation; the approval quiz is skipped when unattended; model invocation is
15
16
  allowed.
16
17
  -->
@@ -41,7 +42,7 @@ Break the work into **tracer bullet** tickets.
41
42
  - A completed slice is demoable or verifiable on its own
42
43
  - Each slice is sized to fit in a single fresh context window
43
44
  - Any prefactoring should be done first
44
- - No trivial tickets: when a slice's whole change is a small guard or fix (a few lines) and no other ticket is blocked by it, fold its acceptance criteria into the neighbouring ticket that touches the same behaviour. Every ticket costs a test-writer, implementer, reviewer and merger; a two-line guard doesn't earn four agents
45
+ - No trivial tickets: when a slice's whole change is a small guard or fix (a few lines) and no other ticket is blocked by it, fold its acceptance criteria into the neighbouring ticket that touches the same behaviour. Every ticket costs a test-writer, an implementer and a reviewer; a two-line guard doesn't earn three agents
45
46
 
46
47
  </vertical-slice-rules>
47
48
 
@@ -93,6 +94,10 @@ Do NOT close or modify any parent issue.
93
94
 
94
95
  **Touches:** the files, and the functions within them, this ticket expects to change.
95
96
 
97
+ **Risk:** optional. The shared state, persistence, concurrency, money or auth this ticket touches. Omit the line when there is none.
98
+
99
+ **Suspicions:** optional. What the reviewer should test first: a sibling caller, a key built from the old identity, a guard another ticket added.
100
+
96
101
  - [ ] Acceptance criterion 1
97
102
  - [ ] Acceptance criterion 2
98
103
 
@@ -116,6 +121,18 @@ The **Touches** line is a best guess, for scheduling: `implement-spec` never run
116
121
 
117
122
  </touches-rule>
118
123
 
124
+ <risk-rule>
125
+
126
+ The optional **Risk** line names the shared state, persistence keys or identities, concurrency, money or auth the ticket touches, and turns on `implement-spec`'s correctness pass: a second, independent reviewer on another provider. The driver does not start when it has no CLI of another provider: the reviewer never does this pass. Any text on the line turns the pass on, so a ticket with no such risk has no **Risk** line, never `**Risk:** none`.
127
+
128
+ </risk-rule>
129
+
130
+ <suspicions-rule>
131
+
132
+ The optional **Suspicions** line is passed verbatim to `implement-spec`'s reviewer, which tests each one: named suspicions find regressions a generic review misses.
133
+
134
+ </suspicions-rule>
135
+
119
136
  Never write the implementation into a ticket. `implement-spec`'s test-writer reads the ticket and must not know the solution, or its acceptance tests stop being independent of the code they judge. Describe behaviour, not the fix.
120
137
 
121
138
  Outside the **Touches** line, avoid specific file paths or code snippets: they go stale fast. Exception: if a prototype produced a snippet that encodes a decision more precisely than prose can (state machine, reducer, schema, type shape), inline it and note briefly that it came from a prototype. Trim to the decision-rich parts, the shape of the decision, never the code that implements the ticket.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-08T23:26:31.695Z",
3
+ "generatedAt": "2026-10-14T03:34:13.674Z",
4
4
  "skills": [
5
5
  {
6
6
  "name": "backlog",
@@ -46,8 +46,8 @@
46
46
  },
47
47
  {
48
48
  "name": "implement-spec",
49
- "version": "0.1.7",
50
- "hash": "sha256-dfcd2f6a13fd06dd4fb7a45bb0d85ae39228ec804dc94c845310afa72808d3d5",
49
+ "version": "0.1.8",
50
+ "hash": "sha256-cafda530227caa971251cbe0d677f758630f96d6178f75a5e32b93417efe033e",
51
51
  "description": "Implement a specification in code."
52
52
  },
53
53
  {
@@ -76,8 +76,8 @@
76
76
  },
77
77
  {
78
78
  "name": "review-page",
79
- "version": "0.1.0",
80
- "hash": "sha256-f5fc0964e0558c6b9ca5d9ed77c3647b22cbc1d9710a9dfdf270d8c062caa741",
79
+ "version": "0.1.1",
80
+ "hash": "sha256-e8204e4c6f05e2721a20d9ac3f72b8ff84a2854647688bb9bf057312b53a59f4",
81
81
  "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."
82
82
  },
83
83
  {
@@ -94,8 +94,8 @@
94
94
  },
95
95
  {
96
96
  "name": "to-tickets",
97
- "version": "0.1.4",
98
- "hash": "sha256-66822a4d85cd7b851d9073591723dbf228f58a1f8cb3ccf0fd9f57f38c90aca1",
97
+ "version": "0.1.5",
98
+ "hash": "sha256-58d4b24183ba3ff59a97de963ab0cd8bbac91d526941eeb7ec0a84274e918faf",
99
99
  "description": "Break a spec, plan, or the current conversation into numbered tracer-bullet ticket files under .agent/tickets/, each declaring its blocking edges and acceptance criteria phrased as observable behavior. Use after a spec is approved and before implementation starts."
100
100
  },
101
101
  {
@@ -1,52 +0,0 @@
1
- # History by ticket
2
-
3
- Tidy the unpublished integration branch into one coherent commit per ticket,
4
- ordered so each ticket follows its dependencies. Each commit contains that
5
- ticket's acceptance tests, implementation, and all review or integration fixes.
6
- The reviewer's commit list should tell the story of the tickets, with the
7
- final behavior at each step.
8
-
9
- ## Before rewriting
10
-
11
- - Confirm all ticket worktrees and branches were cleaned up after their merges,
12
- and that the integration worktree is clean.
13
- - Confirm the branch has never been pushed. Once pushed, preserve its history
14
- and add reviewed follow-up commits; rewriting a published branch requires
15
- explicit user authorization.
16
- - Record the current tip and `HEAD^{tree}` in the ledger as the recovery point.
17
- Use the ticket reports and ledger to map every working commit and every fix
18
- to its owning ticket before editing history.
19
-
20
- ## Group the changes
21
-
22
- Use an interactive rebase or reconstruct the commits in an isolated worktree,
23
- operating only on branch-owned commits after the base. Fold red-test commits,
24
- implementation commits, and later fixes into their ticket commit. Flatten
25
- branch-only merge commits while preserving their conflict-resolution changes.
26
- Keep the test-writer's acceptance files unchanged from their locked versions.
27
-
28
- A fix touching several tickets is split by ownership when the resulting
29
- commits can stand alone in dependency order. If it cannot be split safely,
30
- combine the affected tickets into one coherent commit, name them in the
31
- message, and record the reason in the ledger. Every final change has a ticket
32
- owner; an out-of-scope change needs a ruling before inclusion.
33
-
34
- Use a commit subject that names the ticket's delivered behavior and put its
35
- ticket number or file pointer in the body. Keep a map from the old working
36
- commits to the final ticket commits in the ledger; ticket reports and locked
37
- test SHAs still refer to the original history.
38
-
39
- ## Completion
40
-
41
- The rewritten tip's tree must equal the saved tree: history cleanup changes
42
- the presentation of the work, not its files. A mismatch must be resolved
43
- before review. Inspect each final commit's diff against its ticket to confirm
44
- the tests, implementation, and fixes stayed together, dependencies precede
45
- their consumers, and no temporary red-test or fix-only commits remain.
46
-
47
- Use targeted checks where needed to verify a regrouped ticket. Run the full
48
- gate once at the completed branch tip, through /review-diff. Rewriting changes
49
- the review endpoints, so the next review covers the full rewritten branch;
50
- mark the old coverage superseded even if the tip's tree is identical. After a
51
- review requests fixes, finish the whole fix batch, fold it into these same
52
- ticket commits, and repeat this completion check before the next review.