@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,162 @@
1
+ """Report validation acceptance tests, through driver_report.validate only.
2
+
3
+ context is a dict: repo (slot path), branch, start (HEAD sha when the role started),
4
+ log (the agent's log file), round (fix-loop round, default 0).
5
+ """
6
+ import json
7
+ import subprocess
8
+
9
+ import pytest
10
+
11
+ from driver_report import validate
12
+
13
+ GIT = ["git", "-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false"]
14
+ RED = "FAILED tests/test_export.py::test_rows - AttributeError"
15
+ MUTATION = "FAILED tests/test_export.py::test_rows after breaking demo/export.py:2"
16
+
17
+
18
+ def git(root, *args):
19
+ return subprocess.run([*GIT, *args], cwd=root, check=True, capture_output=True, text=True).stdout.strip()
20
+
21
+
22
+ def commit(root, path, text):
23
+ (root / path).parent.mkdir(parents=True, exist_ok=True)
24
+ (root / path).write_text(text)
25
+ git(root, "add", "-A")
26
+ git(root, "commit", "-q", "-m", f"edit {path}")
27
+ return git(root, "rev-parse", "HEAD")
28
+
29
+
30
+ @pytest.fixture
31
+ def world(tmp_path):
32
+ """main: base. ticket/01: base -> tests (test-writer) -> impl. other: base -> stray."""
33
+ root = tmp_path / "slot"
34
+ root.mkdir()
35
+ git(root, "init", "-q", "-b", "main")
36
+ base = commit(root, "demo/export.py", "def export_csv():\n return ''\n")
37
+ git(root, "checkout", "-q", "-b", "other")
38
+ stray = commit(root, "stray.txt", "x\n")
39
+ git(root, "checkout", "-q", "-b", "ticket/01", "main")
40
+ tests = commit(root, "tests/test_export.py", "def test_rows():\n assert False\n")
41
+ impl = commit(root, "demo/export.py", "def export_csv():\n return 'rows'\n")
42
+ (tmp_path / "agent.log").write_text(f"collected 1 item\n{RED}\n{MUTATION}\n")
43
+ shas = dict(base=base, stray=stray, tests=tests, impl=impl)
44
+
45
+ def check(role, report, **context):
46
+ path = tmp_path / f"{role}-0.json"
47
+ path.write_text(report if isinstance(report, str) else json.dumps(report))
48
+ ctx = {"repo": str(root), "branch": "ticket/01", "log": str(tmp_path / "agent.log"), "round": 0}
49
+ return validate(role, str(path), {**ctx, **context})
50
+ return shas, check, tmp_path
51
+
52
+
53
+ def common(**changes):
54
+ return {"status": "DONE", "commits": [], "files": [], "concerns": [], **changes}
55
+
56
+
57
+ def writer(shas, **changes):
58
+ return common(**{"commits": [shas["tests"]], "files": ["tests/test_export.py"], "locked": ["tests/test_export.py"],
59
+ "red": [{"test": "tests/test_export.py::test_rows", "line": RED}], **changes})
60
+
61
+
62
+ def implementer(shas, **changes):
63
+ return common(**{"commits": [shas["impl"]], "files": ["demo/export.py"], "green": ["1 passed"], **changes})
64
+
65
+
66
+ def finding(**changes):
67
+ return {"severity": "minor", "file": "demo/export.py", "line": 2, "title": "t", "evidence": "e", "kind": "code",
68
+ **changes}
69
+
70
+
71
+ def reviewer(**changes):
72
+ return common(**{"findings": [finding()], "mutations": [
73
+ {"test": "tests/test_export.py::test_rows", "file": "demo/export.py", "line": 2, "red": MUTATION}], **changes})
74
+
75
+
76
+ def test_valid_reports_pass_and_unknown_status_is_named(world):
77
+ shas, check, _ = world
78
+ assert check("test-writer", writer(shas), start=shas["base"]) == []
79
+ assert check("implementer", implementer(shas), start=shas["tests"]) == []
80
+ assert check("reviewer", reviewer(), start=shas["impl"]) == []
81
+ errors = check("implementer", implementer(shas, status="FINISHED"), start=shas["tests"])
82
+ assert errors and any("status" in e and "FINISHED" in e for e in errors)
83
+
84
+
85
+ @pytest.mark.parametrize("field", ["status", "commits", "files", "concerns"])
86
+ def test_missing_common_field_is_named(world, field):
87
+ shas, check, _ = world
88
+ assert check("implementer", implementer(shas), start=shas["tests"]) == []
89
+ report = implementer(shas)
90
+ del report[field]
91
+ assert any(field in e for e in check("implementer", report, start=shas["tests"]))
92
+
93
+
94
+ @pytest.mark.parametrize("role, field", [("test-writer", "locked"), ("test-writer", "red"), ("implementer", "green")])
95
+ def test_missing_role_field_is_named(world, role, field):
96
+ shas, check, _ = world
97
+ report = writer(shas) if role == "test-writer" else implementer(shas)
98
+ start = shas["base"] if role == "test-writer" else shas["tests"]
99
+ assert check(role, report, start=start) == []
100
+ del report[field]
101
+ assert any(field in e for e in check(role, report, start=start))
102
+
103
+
104
+ @pytest.mark.parametrize("path", ["missing", "empty", "not json"])
105
+ def test_unusable_report_is_an_error_not_a_crash(world, path):
106
+ shas, check, tmp = world
107
+ assert check("implementer", implementer(shas), start=shas["tests"]) == []
108
+ if path == "missing":
109
+ assert validate("implementer", str(tmp / "nope.json"), {"repo": str(tmp / "slot"), "branch": "ticket/01",
110
+ "start": shas["tests"], "log": str(tmp / "agent.log")})
111
+ return
112
+ assert check("implementer", "" if path == "empty" else "{oops", start=shas["tests"])
113
+
114
+
115
+ def test_commit_off_the_ticket_branch_or_unknown_is_a_violation(world):
116
+ shas, check, _ = world
117
+ assert check("implementer", implementer(shas), start=shas["tests"]) == []
118
+ for sha in (shas["stray"], "0123456789abcdef0123456789abcdef01234567"):
119
+ errors = check("implementer", implementer(shas, commits=[sha]), start=shas["tests"])
120
+ assert any(sha[:7] in e for e in errors), errors
121
+
122
+
123
+ def test_commit_predating_the_role_start_is_a_violation(world):
124
+ shas, check, _ = world
125
+ assert check("implementer", implementer(shas), start=shas["tests"]) == []
126
+ errors = check("implementer", implementer(shas, commits=[shas["tests"], shas["impl"]]), start=shas["tests"])
127
+ assert any(shas["tests"][:7] in e for e in errors), errors
128
+
129
+
130
+ def test_implementer_without_a_new_commit_is_a_violation(world):
131
+ shas, check, _ = world
132
+ assert check("implementer", implementer(shas), start=shas["tests"]) == []
133
+ assert any("commit" in e for e in check("implementer", implementer(shas, commits=[]), start=shas["tests"]))
134
+ assert check("implementer", implementer(shas), start=shas["impl"]) # its only commit is not new
135
+
136
+
137
+ def test_locked_path_absent_from_the_commit_is_a_violation(world):
138
+ shas, check, _ = world
139
+ assert check("test-writer", writer(shas), start=shas["base"]) == []
140
+ errors = check("test-writer", writer(shas, locked=["tests/test_export.py", "tests/test_other.py"]),
141
+ start=shas["base"])
142
+ assert any("tests/test_other.py" in e for e in errors), errors
143
+
144
+
145
+ def test_red_line_absent_from_the_log_is_a_violation(world):
146
+ shas, check, _ = world
147
+ assert check("test-writer", writer(shas), start=shas["base"]) == []
148
+ report = writer(shas, red=[{"test": "t", "line": "FAILED tests/test_export.py::test_rows - KeyError"}])
149
+ assert "red line not found in log" in check("test-writer", report, start=shas["base"])
150
+
151
+
152
+ def test_reviewer_fields_and_mutation_proofs(world):
153
+ shas, check, _ = world
154
+ assert check("reviewer", reviewer(), start=shas["impl"], round=1) == []
155
+ assert any("severity" in e for e in check("reviewer", reviewer(findings=[finding(severity="huge")]),
156
+ start=shas["impl"]))
157
+ assert any("kind" in e for e in check("reviewer", reviewer(findings=[finding(kind="style")]), start=shas["impl"]))
158
+ no_proof = reviewer()
159
+ del no_proof["mutations"]
160
+ assert any("mutations" in e for e in check("reviewer", no_proof, start=shas["impl"], round=1))
161
+ fake = reviewer(mutations=[{"test": "t", "file": "demo/export.py", "line": 2, "red": "never ran"}])
162
+ assert any("not found in log" in e for e in check("reviewer", fake, start=shas["impl"], round=1))
@@ -0,0 +1,338 @@
1
+ """Ticket 04 acceptance tests: review, correctness pass, fix loop, fixture corrections, conflict agent.
2
+
3
+ All through `sh run.sh --runner <script>` on the fixture repository (the rig of test_walk). The fake runner also plays
4
+ reviewer, correctness, fixer, conflict and a second test-writer call; a role it has no script for reports nothing wrong.
5
+
6
+ Interface choices (ticket 04):
7
+ - the runner replaces the CLI, so a model entry is observed in the ledger: the line of a call names its role and entry.
8
+ - a fix round runs the role `fixer` (report `fixer-<round>.json`) on the implementer's model list: entry 0 in rounds
9
+ 1-2, entry 1 in rounds 3-4. `state.json` `round` counts fix rounds only. The review that follows round K writes
10
+ `reviewer-K.json` (the first review `reviewer-0.json`); a correctness pass writes `correctness-K.json`.
11
+ - `mutations[].test` is `<locked file>::test_x`; a re-review prompt names each mutation proof to repeat as
12
+ `<file>:<line>`, with the line number it has at the new tip.
13
+ - an installed skill without a sibling `review-diff/correctness.md` makes the driver exit 3 before any agent, and the
14
+ message names `review-diff/correctness.md`.
15
+ - a second conflict on one merge: the PR branch moved again while the conflict agent worked.
16
+ """
17
+ import json
18
+ import shutil
19
+ import subprocess
20
+ from pathlib import Path
21
+
22
+ import pytest
23
+
24
+ from test_cli import SKILL, ticket, three, git, models_file
25
+ from test_walk import RUNNER, Rig, impl, plan, tw
26
+
27
+ # The fake runner of test_walk plus two hooks: `git` runs before the step's files, `git_after` right after them.
28
+ HOOKED = RUNNER.replace(
29
+ 'for path, text in step.get("files", {}).items():',
30
+ 'for cmd in step.get("git", []):\n git(*cmd)\nfor path, text in step.get("files", {}).items():', 1).replace(
31
+ 'sys.stdout.write(step.get("stdout", ""))',
32
+ 'for cmd in step.get("git_after", []):\n git(*cmd)\nsys.stdout.write(step.get("stdout", ""))', 1)
33
+ assert HOOKED.count("git_after") == 1 and HOOKED.count('step.get("git", [])') == 1
34
+
35
+ ONE = {"01-export-rows.md": ticket("01", "Export rows")}
36
+ RISKY = {"02-quote-fields.md": ticket("02", "Quote fields", risk="persistence")}
37
+ OPUS, FABLE, SOL = "claude-opus-5@medium", "claude-fable-5-1@high", "openai/gpt-sol@medium"
38
+ LONG = "".join(f"v{i} = {i}\n" for i in range(1, 41))
39
+
40
+
41
+ class Review(Rig):
42
+ """The rig of test_walk with the hooked runner and per-test model lists."""
43
+
44
+ def __init__(self, tmp, files=ONE, models=None, **kw):
45
+ super().__init__(tmp, files, **kw)
46
+ (self.rig / "run-agent").write_text(HOOKED)
47
+ self.models = models_file(tmp, **(models or {}))
48
+
49
+ def led(self, *words):
50
+ return [l for l in self.ledger() if all(w in l for w in words)]
51
+
52
+ def tip(self):
53
+ return self.walked() # the tip the walk made: the end of run tidies it away (ticket 08)
54
+
55
+ def folder(self, nn="01"):
56
+ return self.repo / ".agent/implement" / nn
57
+
58
+
59
+ def finding(title, severity="important", kind="code", file="demo/export.py", line=99):
60
+ return dict(severity=severity, file=file, line=line, title=title, evidence=title, kind=kind)
61
+
62
+
63
+ def red(nn, line):
64
+ return f"FAILED tests/test_{nn}.py::test_x - AssertionError at demo/export.py:{line}"
65
+
66
+
67
+ def review(nn="01", findings=(), lines=(1,), **extra):
68
+ """A reviewer step: findings plus a mutation proof per production line, its red line printed to the log."""
69
+ proofs = [dict(test=f"tests/test_{nn}.py::test_x", file="demo/export.py", line=n, red=red(nn, n)) for n in lines]
70
+ report = dict(status="DONE", commits=[], files=[], concerns=[], findings=list(findings), mutations=proofs)
71
+ return {"stdout": "".join(p["red"] + "\n" for p in proofs), "report": report, **extra}
72
+
73
+
74
+ def correct(findings=(), **extra):
75
+ report = dict(status="DONE", commits=[], files=[], concerns=[], findings=list(findings))
76
+ return {"report": report, **extra}
77
+
78
+
79
+ def fix(nn, n, text=None, **changes):
80
+ """A fixer step: one commit on the ticket branch."""
81
+ step = impl(nn, text=text or f"def fn_{nn}():\n return 'fix{n}'\n", commit=f"fix: {nn} round {n}", **changes)
82
+ step["report"]["ticket"] = nn
83
+ return step
84
+
85
+
86
+ def edit(text, **new):
87
+ """`text` with line N replaced (keyword `l<N>`)."""
88
+ lines = text.splitlines(keepends=True)
89
+ for key, value in new.items():
90
+ lines[int(key[1:]) - 1] = value + "\n"
91
+ return "".join(lines)
92
+
93
+
94
+ # ---------------------------------------------------------------- locked-file check
95
+
96
+ def test_a_changed_locked_file_is_a_critical_finding_that_starts_a_fix_round(tmp_path):
97
+ rig = Review(tmp_path)
98
+ locked = tw("01")["files"]
99
+ cheat = impl("01", files={"demo/export.py": "def fn_01():\n return '01'\n", "tests/test_01.py": "# edited\n"})
100
+ steps = plan(**{"01/implementer": [cheat], "01/fixer": [fix("01", 1, files={**locked, "demo/export.py": "x = 1\n"})]})
101
+ rig.run(steps)
102
+ assert rig.code == 0, rig.output()
103
+ (line,) = rig.led("locked file changed: tests/test_01.py")
104
+ assert "critical" in line.lower()
105
+ assert len(rig.calls("01", "fixer")) == 1 and rig.state()["tickets"]["01"]["round"] == 1
106
+ assert git(rig.repo, "show", "feat/csv:tests/test_01.py") == locked["tests/test_01.py"]
107
+
108
+
109
+ # ---------------------------------------------------------------- correctness pass
110
+
111
+ @pytest.fixture(scope="module")
112
+ def pair(tmp_path_factory):
113
+ """Ticket 02 (Risk: persistence), codex on PATH: reviewer and correctness each wait for the other to start."""
114
+ rig = Review(tmp_path_factory.mktemp("pair"), RISKY, {"correctness": [SOL, OPUS], "implementer": [OPUS]})
115
+ steps = plan(**{
116
+ "02/reviewer": [review("02", [finding("R-MINOR", "minor")], touch=["r"], wait=["c"])],
117
+ "02/correctness": [correct([finding("C-IMPORTANT")], touch=["c"], wait=["r"])],
118
+ "02/fixer": [fix("02", 1)]})
119
+ return rig.run(steps)
120
+
121
+
122
+ def test_reviewer_and_correctness_run_at_the_same_time_on_their_models(pair):
123
+ assert pair.code == 0, pair.output()
124
+ (r,), (c,) = pair.calls("02", "reviewer")[:1], pair.calls("02", "correctness")[:1]
125
+ assert r["t0"] < c["t1"] and c["t0"] < r["t1"], "each started before the other ended"
126
+ assert pair.led("reviewer", OPUS) and pair.led("correctness", "gpt-sol@medium")
127
+
128
+
129
+ def test_findings_of_both_reports_are_merged_with_their_severities(pair):
130
+ assert len(pair.calls("02", "fixer")) == 1, "the important finding of the correctness pass starts a round"
131
+ assert pair.led("R-MINOR", "minor") and pair.led("C-IMPORTANT", "important")
132
+ assert pair.state()["tickets"]["02"]["phase"] == "merged"
133
+
134
+
135
+ # Ticket 08: the pass is never folded into the reviewer. A correctness entry with no CLI on PATH stops the run at the
136
+ # start: test_amendments.py test_a_correctness_entry_whose_cli_is_not_on_path_stops_the_run_before_any_agent.
137
+
138
+ def test_an_installed_skill_without_review_diff_fails_clearly_before_any_agent(tmp_path):
139
+ rig = Review(tmp_path, RISKY)
140
+ copy = tmp_path / "installed/skills/implement-spec"
141
+ shutil.copytree(SKILL, copy, ignore=shutil.ignore_patterns("__pycache__", ".pytest_cache", "test_*"))
142
+ (rig.rig / "plan.json").write_text(json.dumps(plan()))
143
+ args = ["--tickets", ".agent/tickets", "--gate", str(rig.rig / "gate"), "--spec", "specs/csv/spec.md",
144
+ "--models", str(rig.models), "--runner", str(rig.rig / "run-agent")]
145
+ proc = subprocess.run(["sh", str(copy / "run.sh"), *args], cwd=rig.repo, env=rig.env, text=True,
146
+ capture_output=True, timeout=120)
147
+ assert proc.returncode == 3, proc.stdout + proc.stderr
148
+ assert "review-diff/correctness.md" in proc.stdout + proc.stderr
149
+ assert not rig.calls() and not (rig.repo / ".agent/implement/summary.md").exists()
150
+
151
+
152
+ @pytest.fixture(scope="module")
153
+ def plain(tmp_path_factory):
154
+ """A ticket without a Risk line whose reviewer reports two minor findings."""
155
+ rig = Review(tmp_path_factory.mktemp("plain"), ONE, {"correctness": [SOL, OPUS]})
156
+ return rig.run(plan(**{"01/reviewer": [review("01", [finding("M-ONE", "minor"), finding("M-TWO", "minor")])]}))
157
+
158
+
159
+ def test_a_ticket_without_a_risk_line_runs_no_correctness_pass(plain):
160
+ assert plain.code == 0, plain.output()
161
+ assert len(plain.calls("01", "reviewer")) == 1 and not plain.calls("01", "correctness")
162
+ assert not plain.led("folded")
163
+
164
+
165
+ def test_a_review_with_only_minor_findings_passes_and_writes_them_to_the_ledger(plain):
166
+ assert not plain.calls("01", "fixer") and plain.state()["tickets"]["01"]["phase"] == "merged"
167
+ assert plain.state()["tickets"]["01"]["round"] == 0
168
+ assert plain.led("M-ONE", "minor") and plain.led("M-TWO", "minor")
169
+
170
+
171
+ # ---------------------------------------------------------------- fix loop
172
+
173
+ STUCK = finding("STUCK")
174
+
175
+
176
+ @pytest.fixture(scope="module")
177
+ def escalated(tmp_path_factory):
178
+ """One important finding survives the re-reviews after rounds 1 and 2; round 3 is clean."""
179
+ rig = Review(tmp_path_factory.mktemp("esc"), ONE, {"implementer": [OPUS, FABLE]})
180
+ bad = review("01", [STUCK])
181
+ return rig.run(plan(**{"01/reviewer": [bad, bad, bad, review("01")],
182
+ "01/fixer": [fix("01", n) for n in (1, 2, 3)]}))
183
+
184
+
185
+ def test_round_3_starts_a_fresh_implementer_on_the_next_model(escalated):
186
+ assert escalated.code == 0, escalated.output()
187
+ assert escalated.led("01 round 3: escalated to claude-fable-5-1@high")
188
+ assert not escalated.led("round 1: escalated") and not escalated.led("round 2: escalated")
189
+ assert len(escalated.led("fixer", OPUS)) == 2 and len(escalated.led("fixer", FABLE)) == 1
190
+ assert escalated.state()["tickets"]["01"]["round"] == 3
191
+
192
+
193
+ def test_each_round_ends_with_one_gate_record_and_a_re_review_scoped_to_the_fix(escalated):
194
+ tips = [escalated.sha("01", "implementer")] + [c["sha"] for c in escalated.calls("01", "fixer")]
195
+ commits = [g["commit"] for g in escalated.gates()]
196
+ assert len(tips) == 4 and all(commits.count(t) == 1 for t in tips), commits
197
+ assert all((escalated.folder() / f"gate-{t[:7]}.log").exists() for t in tips)
198
+ reviews = escalated.calls("01", "reviewer")
199
+ assert len(reviews) == 4
200
+ for k in (1, 2, 3):
201
+ assert tips[k - 1][:7] in reviews[k]["prompt"] and tips[k][:7] in reviews[k]["prompt"], k
202
+
203
+
204
+ def test_reports_are_named_by_role_and_round(escalated):
205
+ for name in ("reviewer-0", "reviewer-1", "reviewer-2", "reviewer-3", "fixer-1", "fixer-2", "fixer-3"):
206
+ assert (escalated.folder() / f"{name}.json").exists(), name
207
+
208
+
209
+ @pytest.fixture(scope="module")
210
+ def capped(tmp_path_factory):
211
+ """The three-ticket default; 01's reviewer keeps the same finding through round 4."""
212
+ rig = Review(tmp_path_factory.mktemp("cap"), three(), {"implementer": [OPUS, FABLE]})
213
+ return rig.run(plan(**{"01/reviewer": [review("01", [STUCK])] * 5,
214
+ "01/fixer": [fix("01", n) for n in (1, 2, 3, 4)]}))
215
+
216
+
217
+ def test_a_finding_open_after_round_4_blocks_the_ticket_and_the_run_goes_on(capped):
218
+ assert capped.code == 1, capped.output()
219
+ assert capped.led("01", "BLOCKED", "STUCK")
220
+ assert len(capped.calls("01", "fixer")) == 4 and len(capped.calls("01", "reviewer")) == 5
221
+ assert not capped.calls("02"), "02 is blocked by 01"
222
+ assert capped.state()["tickets"]["01"]["phase"] == "blocked"
223
+ assert capped.state()["tickets"]["03"]["phase"] == "merged" and "feat: 03 rows" in capped.pr_subjects()
224
+
225
+
226
+ # ---------------------------------------------------------------- fixture correction
227
+
228
+ def test_a_fixture_finding_runs_one_test_writer_correction_outside_the_rounds(tmp_path):
229
+ rig = Review(tmp_path)
230
+ new = "def test_x():\n assert '01' == 'x'\n"
231
+ correction = {**tw("01"), "files": {"tests/test_01.py": new}, "commit": "test: 01 fixture fix"}
232
+ stale = finding("STALEMARK stale path", file="tests/test_01.py", kind="fixture", line=1)
233
+ rig.run(plan(**{"01/test-writer": [tw("01"), correction], "01/reviewer": [review("01", [stale])]}))
234
+ assert rig.code == 0, rig.output()
235
+ assert len(rig.calls("01", "test-writer")) == 2 and not rig.calls("01", "fixer")
236
+ assert rig.led("tests/test_01.py", "STALEMARK")
237
+ assert rig.state()["tickets"]["01"]["round"] == 0 and rig.state()["tickets"]["01"]["phase"] == "merged"
238
+ assert git(rig.repo, "show", "feat/csv:tests/test_01.py") == new
239
+
240
+
241
+ # ---------------------------------------------------------------- mutation proofs
242
+
243
+ def scoped(tmp, lines, fixes, last_lines):
244
+ """01's implementer writes 40 lines; the reviewer proves `lines`; each fix replaces the file."""
245
+ rig = Review(tmp)
246
+ bad = review("01", [STUCK], lines=lines)
247
+ reviews = [bad] * len(fixes) + [review("01", lines=last_lines)]
248
+ rig.run(plan(**{"01/implementer": [impl("01", text=LONG)], "01/reviewer": reviews,
249
+ "01/fixer": [fix("01", n, text=t) for n, t in enumerate(fixes, 1)]}))
250
+ return rig
251
+
252
+
253
+ def test_a_re_review_asks_the_mutation_proof_only_for_the_lines_the_fix_changed(tmp_path):
254
+ first = edit(LONG, l12="v12 = 'a'", l30="v30 = 'a'")
255
+ rig = scoped(tmp_path, (12, 30), [first, edit(first, l30="v30 = 'b'")], (30,))
256
+ assert rig.code == 0, rig.output()
257
+ after1, after2 = (c["prompt"] for c in rig.calls("01", "reviewer")[1:])
258
+ assert "demo/export.py:12" in after1 and "demo/export.py:30" in after1
259
+ assert "demo/export.py:30" in after2 and "demo/export.py:12" not in after2
260
+
261
+
262
+ def test_a_mutation_proof_follows_its_production_line_when_a_fix_moves_it(tmp_path):
263
+ lines = LONG.splitlines(keepends=True)
264
+ moved = "".join(lines[:19] + ["i1 = 1\n", "i2 = 2\n"] + lines[19:29] + ["v30 = 'changed'\n"] + lines[30:])
265
+ rig = scoped(tmp_path, (12, 30, 35), [moved], (32,))
266
+ assert rig.code == 0, rig.output()
267
+ prompt = rig.calls("01", "reviewer")[1]["prompt"]
268
+ assert "demo/export.py:32" in prompt, "line 30 is line 32 after two inserted lines"
269
+ for gone in (":30", ":12", ":35", ":37"): # the old number, and the lines that did not change
270
+ assert f"demo/export.py{gone}" not in prompt, gone
271
+
272
+
273
+ def test_a_first_review_without_a_mutation_for_a_locked_test_is_a_violation(tmp_path):
274
+ rig = Review(tmp_path)
275
+ rig.run(plan(**{"01/reviewer": [review("01", lines=()), review("01")]}))
276
+ assert rig.code == 0, rig.output()
277
+ first, second = rig.calls("01", "reviewer")
278
+ appended = second["prompt"].replace(first["prompt"].strip(), "")
279
+ assert first["prompt"].strip() in second["prompt"] and "tests/test_01.py" in appended
280
+ assert "mutation" in appended.lower()
281
+
282
+
283
+ @pytest.mark.parametrize("second_matches", [True, False])
284
+ def test_a_red_line_missing_from_the_reviewer_log_reruns_once_then_blocks(tmp_path, second_matches):
285
+ rig = Review(tmp_path)
286
+ bad = review("01")
287
+ bad["report"]["mutations"][0]["red"] = "NEVER PRINTED"
288
+ rig.run(plan(**{"01/reviewer": [bad, review("01") if second_matches else bad]}))
289
+ first, second = rig.calls("01", "reviewer")
290
+ assert "red line not found in log" in second["prompt"] and "red line not found" not in first["prompt"]
291
+ assert rig.code == (0 if second_matches else 1), rig.output()
292
+ assert len(rig.calls("01", "reviewer")) == 2
293
+ if not second_matches:
294
+ assert rig.led("01", "BLOCKED") and "feat: 01 rows" not in rig.pr_subjects()
295
+
296
+
297
+ # ---------------------------------------------------------------- conflict agent
298
+
299
+ def cli(text):
300
+ return f"def main():\n return {text!r}\n"
301
+
302
+
303
+ def conflicted(tmp, moves_again=False):
304
+ """01 and 03 both add demo/cli.py; 03 merges first, so 01's merge conflicts."""
305
+ rig = Review(tmp, {k: v for k, v in three().items() if not k.startswith("02")})
306
+ resolve = {"files": {"demo/cli.py": cli("resolved")}, "commit": "merge: resolve cli",
307
+ "report": {"status": "DONE", "commits": ["$SHA"], "files": ["demo/cli.py"], "concerns": []}}
308
+ if moves_again: # while the agent works, another commit lands on the PR branch and changes the same file
309
+ third = str(tmp / "third")
310
+ resolve["git"] = [["worktree", "add", "--detach", third, "feat/csv"]]
311
+ resolve["files"][third + "/demo/cli.py"] = cli("third")
312
+ resolve["git_after"] = [["-C", third, "add", "-A"], ["-C", third, "commit", "-q", "-m", "feat: third"],
313
+ ["-C", third, "branch", "-f", "feat/csv", "HEAD"],
314
+ ["worktree", "remove", "--force", third]]
315
+ one = impl("01", files={"demo/export.py": "def fn_01():\n return '01'\n", "demo/cli.py": cli("one")})
316
+ return rig.run(plan(**{
317
+ "01/test-writer": [{**tw("01"), "touch": ["s1"]}],
318
+ "03/implementer": [{**impl("03", "demo/cli.py", cli("three")), "wait": ["s1"]}],
319
+ "01/implementer": [{**one, "wait_pr_moves": True}],
320
+ "01/conflict": [resolve]}))
321
+
322
+
323
+ def test_a_conflicting_merge_runs_the_conflict_agent_once_then_the_gate_on_the_merge_commit(tmp_path):
324
+ rig = conflicted(tmp_path)
325
+ assert rig.code == 0, rig.output()
326
+ (call,) = rig.calls("01", "conflict")
327
+ assert call["cwd"] == rig.calls("01", "implementer")[0]["cwd"], "in the ticket's slot"
328
+ assert rig.tip() in [g["commit"] for g in rig.gates()], "the merge commit is gated"
329
+ assert git(rig.repo, "show", "feat/csv:demo/cli.py") == cli("resolved")
330
+ assert rig.led("demo/cli.py", "onflict")
331
+
332
+
333
+ def test_a_second_conflict_on_the_same_merge_blocks_the_ticket(tmp_path):
334
+ rig = conflicted(tmp_path, moves_again=True)
335
+ assert rig.code == 1, rig.output()
336
+ assert len(rig.calls("01", "conflict")) == 1
337
+ assert rig.led("01", "BLOCKED") and rig.state()["tickets"]["01"]["phase"] == "blocked"
338
+ assert rig.state()["tickets"]["03"]["phase"] == "merged"
@@ -0,0 +1,67 @@
1
+ """Ticket 05 acceptance tests for `driver_review_page.render(decisions) -> html`.
2
+
3
+ Interface choice: `decisions` is the dict of decisions.json (`revision`, `questions[]` with `id`, `title`, `why`,
4
+ `evidence` (list of strings), `options` (list of `{id, description}`), `recommended` (an option id)); the result is the
5
+ whole page as a string. The checks are only those that skills/review-page/SKILL.md and schema.md state.
6
+ """
7
+ import re
8
+ from pathlib import Path
9
+
10
+ import driver_review_page
11
+
12
+ REVIEW_PAGE = Path(__file__).resolve().parents[2] / "review-page"
13
+ TEMPLATE = (REVIEW_PAGE / "template.html").read_text()
14
+ ID = re.compile(r"[a-z0-9_-]{1,64}")
15
+ WHAT = "<b>&\"quoted\"</b>"
16
+
17
+
18
+ def question(qid, options, recommended, title=None):
19
+ return {"id": qid, "title": title or f"Title of {qid}", "why": f"Why {qid} matters",
20
+ "evidence": [".agent/implement/01/implementer.json", ".agent/implement/ledger.md:3"],
21
+ "options": [{"id": o, "description": f"Trade-off of {o}"} for o in options], "recommended": recommended}
22
+
23
+
24
+ DECISIONS = {"revision": "ab" * 32, "questions": [
25
+ question("01-locked-test-1", ["overrule-test", "overrule-implementer"], "overrule-implementer"),
26
+ question("02-finding-1", ["accept", "split", "redesign", "other"], "split", title=WHAT)]}
27
+
28
+
29
+ def blocks(html):
30
+ """(data-q value, block html) per question block, in page order."""
31
+ return re.findall(r'<div class="q" data-q="([^"]*)">(.*?)</textarea>', html, re.S)
32
+
33
+
34
+ def check_page(html, decisions):
35
+ """Every check the review-page skill states, for a page in questionnaire mode."""
36
+ questions = decisions["questions"]
37
+ assert 'name="agent-ops-review"' in html and 'content="1"' in html, "the dispatcher looks for the marker"
38
+ assert '<body data-mode="questionnaire"' in html
39
+ assert html.count('data-q="') == len(questions) == len(blocks(html)), "one data-q block per question"
40
+ for style_or_script in re.findall(r"<(?:style|script)>.*?</(?:style|script)>", TEMPLATE, re.S):
41
+ assert style_or_script in html, "script and CSS are never edited"
42
+ for slot in ("tickets", "questions", "corrections", "track"):
43
+ assert f'<section data-slot="{slot}">' in html, "the wrappers stay"
44
+ for forbidden in ("<link", "src=", "http:", "https:", "<button", 'data-q="track"', 'data-q="format"'):
45
+ assert forbidden not in html, forbidden
46
+ for (qid, block), want in zip(blocks(html), questions):
47
+ assert qid == want["id"] and ID.fullmatch(qid), qid
48
+ assert f'id="note-{qid}"' in block and 'id="state"' not in block, "the script owns id state"
49
+ radios = re.findall(r'<input type="radio" name="([^"]*)" value="([^"]*)"', block)
50
+ assert radios == [(qid, o["id"]) for o in want["options"]], "name is the question id, value is the option id"
51
+ labels = block.split('<label class="opt">')[1:]
52
+ recommended = [i for i, label in enumerate(labels) if "chip rec" in label]
53
+ assert block.count("chip rec") == 1 and len(recommended) == 1, "exactly one recommended option"
54
+ assert f'value="{want["recommended"]}"' in labels[recommended[0]]
55
+
56
+
57
+ def test_the_page_carries_the_marker_questionnaire_mode_and_one_block_per_question():
58
+ check_page(driver_review_page.render(DECISIONS), DECISIONS)
59
+
60
+
61
+ def test_titles_whys_and_descriptions_show_up_and_markup_in_them_is_escaped():
62
+ html = driver_review_page.render(DECISIONS)
63
+ for want in DECISIONS["questions"]:
64
+ assert f"Why {want['id']} matters" in html
65
+ assert all(f"Trade-off of {o['id']}" in html for o in want["options"])
66
+ assert "Title of 01-locked-test-1" in html
67
+ assert WHAT not in html and "&lt;b&gt;" in html, "a title is text, never markup"
@@ -0,0 +1,32 @@
1
+ """Slots 2..N: created once, prepared once, reused only when clean."""
2
+ import subprocess
3
+
4
+ import driver_slots
5
+
6
+
7
+ def test_a_slot_is_created_and_prepared_once_then_reused_off_any_branch(repo, tmp_path):
8
+ repo()
9
+ log = tmp_path.parent / f"{tmp_path.name}-prepare.log"
10
+ assert driver_slots.setup(3, f"pwd >> {log}") == []
11
+ assert len(log.read_text().splitlines()) == 2 and driver_slots.path(1) == "."
12
+ slot = driver_slots.path(3)
13
+ subprocess.run(["git", "-C", slot, "checkout", "-q", "-b", "ticket/07"], check=True)
14
+ assert driver_slots.setup(3, f"pwd >> {log}") == [] and len(log.read_text().splitlines()) == 2
15
+ assert driver_slots.git(slot, "symbolic-ref", "-q", "HEAD").returncode != 0 # detached: the branch is free
16
+
17
+
18
+ def test_a_slot_with_uncommitted_work_stops_the_run_and_keeps_the_work(repo, tmp_path):
19
+ repo()
20
+ assert driver_slots.setup(2, None) == []
21
+ (tmp_path / ".agent/worktrees/slot-2/wip.txt").write_text("half done\n")
22
+ assert driver_slots.setup(3, None) == [
23
+ "slot 2: .agent/worktrees/slot-2 has uncommitted changes: commit or discard them"]
24
+ assert (tmp_path / ".agent/worktrees/slot-2/wip.txt").exists() and not (tmp_path / ".agent/worktrees/slot-3").exists()
25
+
26
+
27
+ def test_a_slot_that_cannot_be_created_is_an_error(repo, tmp_path):
28
+ repo()
29
+ (tmp_path / ".agent/worktrees").mkdir(parents=True)
30
+ (tmp_path / ".agent/worktrees/slot-2").write_text("a file is in the way\n")
31
+ (error,) = driver_slots.setup(2, None)
32
+ assert error.startswith("slot 2: cannot create .agent/worktrees/slot-2: ")