@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.
- package/README.md +61 -13
- package/package.json +9 -1
- package/skills/implement-spec/ARCHITECTURE.md +113 -0
- package/skills/implement-spec/README.md +214 -0
- package/skills/implement-spec/SKILL.md +18 -70
- package/skills/implement-spec/driver.py +580 -0
- package/skills/implement-spec/driver_agents.py +145 -0
- package/skills/implement-spec/driver_answers.py +79 -0
- package/skills/implement-spec/driver_decisions.py +152 -0
- package/skills/implement-spec/driver_final.py +505 -0
- package/skills/implement-spec/driver_fixloop.py +170 -0
- package/skills/implement-spec/driver_gate.py +47 -0
- package/skills/implement-spec/driver_graph.py +42 -0
- package/skills/implement-spec/driver_merge.py +105 -0
- package/skills/implement-spec/driver_report.py +115 -0
- package/skills/implement-spec/driver_review.py +126 -0
- package/skills/implement-spec/driver_review_page.py +51 -0
- package/skills/implement-spec/driver_run.py +262 -0
- package/skills/implement-spec/driver_slots.py +64 -0
- package/skills/implement-spec/driver_state.py +105 -0
- package/skills/implement-spec/driver_summary.py +40 -0
- package/skills/implement-spec/driver_tickets.py +132 -0
- package/skills/implement-spec/driver_tidy.py +57 -0
- package/skills/implement-spec/models.json +10 -0
- package/skills/implement-spec/prompts/conflict.md +32 -0
- package/skills/implement-spec/prompts/correction.md +4 -0
- package/skills/implement-spec/prompts/correctness.md +34 -0
- package/skills/implement-spec/prompts/final-fixer.md +36 -0
- package/skills/implement-spec/prompts/final-reviewer.md +36 -0
- package/skills/implement-spec/prompts/final-scoped.md +1 -0
- package/skills/implement-spec/prompts/fixer.md +35 -0
- package/skills/implement-spec/prompts/implementer.md +33 -0
- package/skills/implement-spec/prompts/interrupted.md +1 -0
- package/skills/implement-spec/prompts/moved.md +2 -0
- package/skills/implement-spec/prompts/overruled.md +5 -0
- package/skills/implement-spec/prompts/pr-body.md +19 -0
- package/skills/implement-spec/prompts/rebase-conflict.md +33 -0
- package/skills/implement-spec/prompts/rereview.md +3 -0
- package/skills/implement-spec/prompts/rerun.md +2 -0
- package/skills/implement-spec/prompts/reviewer.md +34 -0
- package/skills/implement-spec/prompts/stands.md +2 -0
- package/skills/implement-spec/prompts/test-writer.md +34 -0
- package/skills/implement-spec/prompts/timeout-restored.md +2 -0
- package/skills/implement-spec/prompts/timeout-shared.md +2 -0
- package/skills/implement-spec/prompts/timeout.md +2 -0
- package/skills/implement-spec/run.sh +101 -0
- package/skills/implement-spec/tests/conftest.py +38 -0
- package/skills/implement-spec/tests/test_agents.py +358 -0
- package/skills/implement-spec/tests/test_amendments.py +117 -0
- package/skills/implement-spec/tests/test_cli.py +220 -0
- package/skills/implement-spec/tests/test_decisions.py +434 -0
- package/skills/implement-spec/tests/test_driver.py +767 -0
- package/skills/implement-spec/tests/test_final.py +442 -0
- package/skills/implement-spec/tests/test_fixloop.py +523 -0
- package/skills/implement-spec/tests/test_graph.py +32 -0
- package/skills/implement-spec/tests/test_landing.py +355 -0
- package/skills/implement-spec/tests/test_merge.py +144 -0
- package/skills/implement-spec/tests/test_models.py +47 -0
- package/skills/implement-spec/tests/test_report.py +162 -0
- package/skills/implement-spec/tests/test_review.py +338 -0
- package/skills/implement-spec/tests/test_review_page.py +67 -0
- package/skills/implement-spec/tests/test_slots.py +32 -0
- package/skills/implement-spec/tests/test_state.py +68 -0
- package/skills/implement-spec/tests/test_stops.py +272 -0
- package/skills/implement-spec/tests/test_summary.py +28 -0
- package/skills/implement-spec/tests/test_tickets.py +72 -0
- package/skills/implement-spec/tests/test_tidy.py +582 -0
- package/skills/implement-spec/tests/test_walk.py +457 -0
- package/skills/review-page/SKILL.md +70 -0
- package/skills/review-page/schema.md +59 -0
- package/skills/review-page/template.html +232 -0
- package/skills/to-tickets/SKILL.md +19 -2
- package/skills-manifest.json +11 -5
- 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.
|