@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,442 @@
1
+ """Ticket 06 acceptance tests: rebase, tidy, final review, fix batch, push.
2
+
3
+ All through `sh run.sh --runner <script> [--push] [--unattended]` on the fixture repository of test_review after the
4
+ tickets merged. The fixture adds a local bare `origin` (HEAD `main`, holding the PR branch's start commit, so the base
5
+ is the remote's default branch, found with `git ls-remote --symref origin HEAD`) and a fake `gh` on the restricted
6
+ PATH. No test reaches a real remote or a real `gh`.
7
+
8
+ Interface choices (ticket 06):
9
+ - end-of-run starts only when every ticket is merged and every slot is idle. A repository with no `origin` skips only
10
+ the rebase and the push (ticket 08): test_amendments.py holds those tests.
11
+ - its agents report into `.agent/implement/final/`, so the fake runner keys them `final/final-reviewer`,
12
+ `final/fixer` and `final/conflict`; gate records there are `gate-<first 7 hex>.log`.
13
+ - recovery point: before the rebase the driver sets `refs/implement/recovery` to the PR tip and writes the ledger
14
+ sentence "Recovery point: ... <sha>". An ended run leaves the ref in place.
15
+ - a tidied commit has the subject `feat(NN): <ticket title>` and a body line `Ticket: <ticket file name>`.
16
+ - the final fixer's `commits` hold `{"sha", "ticket"}` objects, and the driver renames each fixer commit to
17
+ `fix(NN): <its subject>` before the gate and the scoped review run (no rewrite after a review).
18
+ - triage after the second final-review round: question `final-finding-<n>`, options `fix` and `accept`.
19
+ - local stop: stdout lines `Branch tip: <sha>`, `Reviewed tip: <sha>` and the rerun command, which has `--push`.
20
+ - the PR body lists, per ticket, a line with the ticket number and its commit's first 7 hex, and every ruling line.
21
+ - stage.json at a clean unattended end is `{"stage": "implement", "status": "done"}`.
22
+ - a second rebase conflict: the conflict agent finishes the commit and runs `git rebase --continue`; the next
23
+ commit of the rebase that touches the same file conflicts again.
24
+ """
25
+ import json
26
+ import re
27
+ import subprocess
28
+ import sys
29
+ from types import SimpleNamespace
30
+
31
+ import pytest
32
+
33
+ from test_cli import git, ticket
34
+ from test_review import FABLE, HOOKED, ONE, OPUS, Review, finding, review
35
+ from test_stops import UNATTENDED, decisions, stage
36
+ from test_walk import impl, plan, tw
37
+
38
+ SCOPED = HOOKED.replace('("reviewer", "correctness")', '("reviewer", "correctness", "final-reviewer")')
39
+ RUNNER = SCOPED.replace(
40
+ 'for cmd in step.get("git", []):',
41
+ '''if step.get("wait_pr_has"):
42
+ wait(lambda: step["wait_pr_has"] in git("log", "--format=%s", "feat/csv"))
43
+ if step.get("hold"):
44
+ before = git("rev-parse", "feat/csv")
45
+ time.sleep(step["hold"])
46
+ rec["held"] = dict(before=before, after=git("rev-parse", "feat/csv"),
47
+ recovery=git("rev-parse", "--verify", "-q", "refs/implement/recovery"))
48
+ for cmd in step.get("git", []):''', 1).replace(
49
+ 'sha = ""\nif "commit" in step:',
50
+ '''sha = ""
51
+ extras = []
52
+ for c in step.get("commits", []):
53
+ for p, t in c["files"].items():
54
+ Path(p).parent.mkdir(parents=True, exist_ok=True)
55
+ Path(p).write_text(t)
56
+ git("add", "-A")
57
+ git("commit", "-q", "-m", c["message"])
58
+ extras.append(git("rev-parse", "HEAD"))
59
+ def render(rep):
60
+ text = json.dumps(rep).replace("$SHA", sha).replace("$HEAD", rec["head"])
61
+ for i, c in enumerate(extras):
62
+ text = text.replace(f"$C{i}", c)
63
+ return text
64
+ if "commit" in step:''', 1).replace(
65
+ 'if step.get("report") is not None:',
66
+ 'for cmd in step.get("git_end", []):\n git(*cmd)\n sha = git("rev-parse", "HEAD")\nif step.get("report") is not None:', 1,
67
+ ).replace('json.dumps(step["report"]).replace("$SHA", sha)', 'render(step["report"])', 1)
68
+ assert "render(step" in RUNNER and RUNNER.count("git_end") == 1 and RUNNER.count("wait_pr_has") == 2
69
+ assert '"final-reviewer")' in RUNNER and RUNNER.count("final-reviewer") == 1
70
+
71
+ GH = f"""#!{sys.executable}
72
+ import json, os, sys
73
+ from pathlib import Path
74
+ argv, body = sys.argv[1:], None
75
+ for i, a in enumerate(argv[:-1]):
76
+ if a in ("--body", "-b"):
77
+ body = argv[i + 1]
78
+ if a in ("--body-file", "-F"):
79
+ body = sys.stdin.read() if argv[i + 1] == "-" else Path(argv[i + 1]).read_text()
80
+ with (Path(__file__).resolve().parent.parent / "gh.jsonl").open("a") as log:
81
+ log.write(json.dumps(dict(argv=argv, body=body)) + "\\n")
82
+ print("https://github.example/pull/1")
83
+ """
84
+ HOOK = '#!/bin/sh\nwhile read old new ref; do echo "$ref $new" >> "%s"; done\n'
85
+
86
+ TITLES = {"01": "Export rows", "02": "Quote fields", "03": "CLI flag"}
87
+ THREE = {
88
+ "01-export-rows.md": ticket("01", "Export rows"),
89
+ "02-quote-fields.md": ticket("02", "Quote fields", "01, 03"),
90
+ "03-cli-flag.md": ticket("03", "CLI flag", seam="`demo export` CLI", touches="`demo/cli.py: main`"),
91
+ }
92
+ FILES = {nn: f"{nn}-{TITLES[nn].lower().replace(' ', '-')}.md" for nn in TITLES}
93
+ FINAL_MODELS = {"final-reviewer": [FABLE, OPUS]}
94
+
95
+
96
+ def ref(root, name):
97
+ out = subprocess.run(["git", "rev-parse", "--verify", "-q", name], cwd=root, capture_output=True, text=True)
98
+ return out.stdout.strip()
99
+
100
+
101
+ class Final(Review):
102
+ """The rig of test_review plus a bare origin, a fake gh and the hooks of the final runner."""
103
+
104
+ def __init__(self, tmp, files=ONE, models=None, remote=True, **kw):
105
+ super().__init__(tmp, files, models, **kw)
106
+ (self.rig / "run-agent").write_text(RUNNER)
107
+ self.env["GIT_EDITOR"] = "true"
108
+ gh = tmp / "bin/gh"
109
+ gh.write_text(GH)
110
+ gh.chmod(0o755)
111
+ self.origin, self.pushed, self.gh_log = tmp / "origin.git", tmp / "pushes.log", tmp / "gh.jsonl"
112
+ if remote:
113
+ git(tmp, "init", "-q", "--bare", "-b", "main", str(self.origin))
114
+ git(self.repo, "remote", "add", "origin", str(self.origin))
115
+ git(self.repo, "push", "-q", "origin", "feat/csv:refs/heads/main")
116
+ git(self.repo, "fetch", "-q", "origin")
117
+ git(self.repo, "remote", "set-head", "origin", "main")
118
+ (self.origin / "hooks/pre-receive").write_text(HOOK % self.pushed)
119
+ (self.origin / "hooks/pre-receive").chmod(0o755)
120
+ self.start = ref(self.origin, "refs/heads/main") if remote else ""
121
+
122
+ def move_base(self, files):
123
+ clone = self.tmp / "base-clone"
124
+ if not clone.exists():
125
+ git(self.tmp, "clone", "-q", str(self.origin), str(clone))
126
+ for path, text in files.items():
127
+ (clone / path).parent.mkdir(parents=True, exist_ok=True)
128
+ (clone / path).write_text(text)
129
+ git(clone, "add", "-A")
130
+ git(clone, "commit", "-q", "-m", "base moves")
131
+ git(clone, "push", "-q", "origin", "main")
132
+
133
+ def pushes(self):
134
+ lines = self.pushed.read_text().splitlines() if self.pushed.exists() else []
135
+ return [l for l in lines if l.startswith("refs/heads/feat/csv ")]
136
+
137
+ def prs(self):
138
+ rows = [json.loads(l) for l in self.gh_log.read_text().splitlines()] if self.gh_log.exists() else []
139
+ return [r for r in rows if r["argv"][:2] == ["pr", "create"]]
140
+
141
+ def branch(self):
142
+ return ref(self.repo, "feat/csv")
143
+
144
+ def recovery(self):
145
+ return ref(self.repo, "refs/implement/recovery")
146
+
147
+ def commits(self, base, tip="feat/csv"):
148
+ raw = git(self.repo, "log", "--reverse", "--no-merges", "--format=%H%x1f%s%x1f%b%x1e", f"{base}..{tip}")
149
+ rows = [r.strip("\n").split("\x1f") for r in raw.split("\x1e") if r.strip()]
150
+ return [dict(sha=h, subject=s, body=b) for h, s, b in rows]
151
+
152
+ def final_calls(self, role):
153
+ return self.calls("final", role)
154
+
155
+ def snap(self):
156
+ return SimpleNamespace(code=self.code, out=self.out, calls=len(self.calls()), gates=len(self.gates()),
157
+ tip=self.branch(), bare=ref(self.origin, "refs/heads/feat/csv"), pushes=len(self.pushes()), prs=len(self.prs()),
158
+ ledger=len(self.ledger()))
159
+
160
+
161
+ def final(*findings, **extra):
162
+ return {"report": dict(status="DONE", commits=[], files=[], concerns=[], findings=list(findings)), **extra}
163
+
164
+
165
+ def fixer(text, *messages, ticket_of="02", **extra):
166
+ """A fixer step: one commit per message, each attributed to `ticket_of` in the report."""
167
+ commits = [{"message": m, "files": {"demo/export.py": text + "".join(f"# {x}\n" for x in messages[:i + 1])}}
168
+ for i, m in enumerate(messages)]
169
+ report = dict(status="DONE", commits=[{"sha": f"$C{i}", "ticket": ticket_of} for i in range(len(messages))],
170
+ files=["demo/export.py"], concerns=[], green=["1 passed"])
171
+ return {"commits": commits, "report": report, **extra}
172
+
173
+
174
+ def fn(nn):
175
+ return f"def fn_{nn}():\n return {nn!r}\n"
176
+
177
+
178
+ def stop_lines(out):
179
+ pattern = r"(?im)^%s[^0-9a-f\n]*([0-9a-f]{7,40})"
180
+ return (re.search(pattern % "branch tip", out), re.search(pattern % "reviewed tip", out),
181
+ re.search(r"(?im)^.*--push.*$", out))
182
+
183
+
184
+ # ---------------------------------------------------------------- the story: stop, local stop, rerun, push
185
+
186
+ NEW_03 = "def test_x():\n assert '03' == '03'\n"
187
+ CONTRA_03 = "locked test contradicts spec: tests/test_03.py::test_x"
188
+
189
+
190
+ @pytest.fixture(scope="module")
191
+ def story(tmp_path_factory):
192
+ """01 merges, 03 stops on a contradicted locked test and is overruled, 02 is held in review, then end-of-run."""
193
+ rig = Final(tmp_path_factory.mktemp("story"), THREE, FINAL_MODELS)
194
+ steps = plan(**{
195
+ "03/test-writer": [tw("03"), {**tw("03"), "files": {"tests/test_03.py": NEW_03}, "commit": "test: 03 overruled"}],
196
+ "03/implementer": [{"report": {"status": "BLOCKED", "commits": [], "files": [], "concerns": [CONTRA_03],
197
+ "green": []}}, impl("03", "demo/cli.py")],
198
+ "02/reviewer": [review("02", [finding("M-MINOR", "minor")], hold=0.8)]})
199
+ rig.run(steps)
200
+ stopped = rig.snap()
201
+ rig.run(steps, "--answer", "03-locked-test-1=overrule-test")
202
+ local = rig.snap()
203
+ rig.run(steps)
204
+ again = rig.snap()
205
+ rig.run(steps, "--push")
206
+ return SimpleNamespace(rig=rig, stopped=stopped, local=local, again=again, pushed=rig.snap())
207
+
208
+
209
+ def test_end_of_run_work_does_not_start_while_02_is_in_review(story):
210
+ rig = story.rig
211
+ assert story.local.code == 0, rig.output()
212
+ (held,) = [c["held"] for c in rig.calls("02", "reviewer")]
213
+ assert held["before"] == held["after"] and not held["recovery"], "nothing rewrote the branch while 02 ran"
214
+ finals = rig.final_calls("final-reviewer")
215
+ assert finals and finals[0]["t0"] >= rig.calls("02", "reviewer")[0]["t1"], "no final review before 02 left review"
216
+
217
+
218
+ def test_seven_commits_in_merge_order_are_tidied_into_three_ticket_commits_with_the_same_tree(story):
219
+ rig = story.rig
220
+ assert story.local.code == 0, rig.output()
221
+ assert rig.recovery(), "no recovery point"
222
+ assert len(rig.commits(rig.start, rig.recovery())) == 7
223
+ rows = rig.commits(rig.start)
224
+ assert [r["subject"] for r in rows] == [f"feat({nn}): {TITLES[nn]}" for nn in ("01", "03", "02")]
225
+ assert all(f"Ticket: {FILES[nn]}" in r["body"] for nn, r in zip(("01", "03", "02"), rows))
226
+ assert ref(rig.repo, "feat/csv^{tree}") == ref(rig.repo, "refs/implement/recovery^{tree}")
227
+
228
+
229
+ def test_one_gate_record_exists_for_the_tidied_tip_and_the_final_reviewer_is_pointed_at_it(story):
230
+ rig, tip = story.rig, story.local.tip
231
+ records = sorted((rig.repo / ".agent/implement/final").glob("gate-*.log"))
232
+ assert [p.name for p in records] == [f"gate-{tip[:7]}.log"], records
233
+ assert "GATE-OUTPUT-MARK" in records[0].read_text() and tip[:7] in records[0].read_text()
234
+ (gate,) = [g for g in rig.gates() if g["commit"] == tip]
235
+ first = rig.final_calls("final-reviewer")[0]
236
+ assert gate["t0"] < first["t0"], "the gate runs before the final review"
237
+ assert f"final/gate-{tip[:7]}.log" in first["prompt"]
238
+
239
+
240
+ def test_the_final_reviewer_runs_on_the_first_model_entry_pointed_at_review_diff_minors_and_rulings(story):
241
+ rig = story.rig
242
+ assert rig.led("final-reviewer", FABLE) and not rig.led("final-reviewer", OPUS)
243
+ prompt = rig.final_calls("final-reviewer")[0]["prompt"]
244
+ assert "review-diff" in prompt and "M-MINOR" in prompt and "locked test overruled" in prompt
245
+ assert '"severity"' in prompt and '"findings"' in prompt, "the reviewer schema"
246
+
247
+
248
+ def test_the_local_run_pushes_nothing_and_prints_the_tip_the_reviewed_tip_and_the_push_command(story):
249
+ now = story.local
250
+ assert now.code == 0 and now.pushes == 0 and now.prs == 0
251
+ assert not now.bare
252
+ tip, reviewed, push = stop_lines(now.out)
253
+ assert tip and reviewed and push, now.out
254
+ assert now.tip.startswith(tip.group(1)) and now.tip.startswith(reviewed.group(1))
255
+
256
+
257
+ def test_a_rerun_after_the_tidy_runs_no_agent_no_gate_and_sends_no_ticket_back(story):
258
+ before, now = story.local, story.again
259
+ assert now.code == 0, story.rig.output()
260
+ assert (now.calls, now.gates, now.tip, now.pushes) == (before.calls, before.gates, before.tip, 0)
261
+ assert len(story.rig.calls("01", "implementer")) == 1 and not story.rig.led("returned to implement")
262
+ assert {e["phase"] for e in story.rig.state()["tickets"].values()} == {"merged"}
263
+ assert all(stop_lines(now.out))
264
+
265
+
266
+ def test_the_push_rerun_pushes_once_opens_the_pr_with_map_and_rulings_and_runs_nothing(story):
267
+ rig, before, now = story.rig, story.again, story.pushed
268
+ assert now.code == 0, rig.output()
269
+ assert (now.calls, now.gates, now.tip) == (before.calls, before.gates, before.tip)
270
+ assert rig.pushes() == [f"refs/heads/feat/csv {now.tip}"] and ref(rig.origin, "refs/heads/feat/csv") == now.tip
271
+ (pr,) = rig.prs()
272
+ assert "--draft" not in pr["argv"]
273
+ lines = pr["body"].splitlines()
274
+ for nn, row in zip(("01", "03", "02"), rig.commits(rig.start)):
275
+ assert [l for l in lines if nn in l and row["sha"][:7] in l], (nn, pr["body"])
276
+ assert "locked test overruled" in pr["body"]
277
+
278
+
279
+ # ---------------------------------------------------------------- the fix batch
280
+
281
+ @pytest.fixture(scope="module")
282
+ def fixed(tmp_path_factory):
283
+ rig = Final(tmp_path_factory.mktemp("fixed"), THREE, FINAL_MODELS)
284
+ rig.run(plan(**{"final/final-reviewer": [final(finding("Q-ONE"), finding("Q-TWO")), final()],
285
+ "final/fixer": [fixer(fn("02"), "quote embedded commas", "escape quotes")]}))
286
+ return rig
287
+
288
+
289
+ def test_two_findings_make_one_fixer_batch_of_fix_commits_on_top_of_the_unchanged_tidied_commits(fixed):
290
+ assert fixed.code == 0, fixed.output()
291
+ (batch,) = fixed.final_calls("fixer")
292
+ assert "Q-ONE" in batch["prompt"] and "Q-TWO" in batch["prompt"]
293
+ reviewed = fixed.final_calls("final-reviewer")[0]["head"]
294
+ assert [r["subject"] for r in fixed.commits(reviewed)] == ["fix(02): quote embedded commas", "fix(02): escape quotes"]
295
+ assert len(fixed.commits(fixed.start, reviewed)) == 3
296
+ assert ref(fixed.repo, "feat/csv~2") == reviewed
297
+
298
+
299
+ def test_a_scoped_review_covers_the_range_after_the_reviewed_tip_and_one_gate_runs_on_the_new_tip(fixed):
300
+ reviewed, tip = fixed.final_calls("final-reviewer")[0]["head"], fixed.branch()
301
+ first, scoped = fixed.final_calls("final-reviewer")
302
+ assert reviewed[:7] in scoped["prompt"] and tip[:7] in scoped["prompt"]
303
+ assert len([g for g in fixed.gates() if g["commit"] == tip]) == 1
304
+ records = sorted(p.name for p in (fixed.repo / ".agent/implement/final").glob("gate-*.log"))
305
+ assert records == sorted({f"gate-{reviewed[:7]}.log", f"gate-{tip[:7]}.log"})
306
+ assert len(fixed.final_calls("final-reviewer")) == 2 and all(stop_lines(fixed.out))
307
+
308
+
309
+ # ---------------------------------------------------------------- schema violations
310
+
311
+ @pytest.fixture(scope="module")
312
+ def violation(tmp_path_factory):
313
+ """One ticket. The final reviewer's first report lacks a finding's kind; the fixer's first lacks a ticket."""
314
+ rig = Final(tmp_path_factory.mktemp("violation"), ONE)
315
+ bad = dict(severity="important", file="demo/export.py", line=1, title="NOKIND", evidence="NOKIND")
316
+ attempt = fixer(fn("01"), "tidy the header", ticket_of="01")
317
+ del attempt["report"]["commits"][0]["ticket"]
318
+ retry = {"report": dict(attempt["report"], commits=[{"sha": "$HEAD", "ticket": "01"}])}
319
+ rig.run(plan(**{"final/final-reviewer": [final(bad), final(finding("HEADER")), final()],
320
+ "final/fixer": [attempt, retry]}))
321
+ return rig
322
+
323
+
324
+ def test_a_final_review_report_follows_the_reviewer_schema_or_is_rerun_once(violation):
325
+ assert violation.code == 0, violation.output()
326
+ calls = violation.final_calls("final-reviewer")
327
+ assert len(calls) == 3 and "HEADER" in violation.final_calls("fixer")[0]["prompt"]
328
+
329
+
330
+ def test_a_fixer_commit_without_its_ticket_is_a_schema_violation(violation):
331
+ assert len(violation.final_calls("fixer")) == 2
332
+ reviewed = violation.final_calls("final-reviewer")[0]["head"]
333
+ assert [r["subject"] for r in violation.commits(reviewed)] == ["fix(01): tidy the header"]
334
+
335
+
336
+ # ---------------------------------------------------------------- the third round
337
+
338
+ @pytest.fixture(scope="module")
339
+ def third(tmp_path_factory):
340
+ rig = Final(tmp_path_factory.mktemp("third"), ONE)
341
+ steps = plan(**{"final/final-reviewer": [final(finding("STILL-OPEN")), final(finding("STILL-OPEN"))],
342
+ "final/fixer": [fixer(fn("01"), "first try", ticket_of="01")]})
343
+ rig.run(steps)
344
+ parked = rig.snap()
345
+ questions = decisions(rig)["questions"] if parked.code == 2 else []
346
+ rig.run(steps, "--answer", "final-finding-1=accept")
347
+ return SimpleNamespace(rig=rig, parked=parked, questions=questions, answered=rig.snap())
348
+
349
+
350
+ def test_findings_open_after_the_second_round_become_fix_or_accept_questions_and_no_third_round_starts(third):
351
+ rig = third.rig
352
+ assert third.parked.code == 2, third.parked.out
353
+ (q,) = third.questions
354
+ assert q["id"] == "final-finding-1" and {o["id"] for o in q["options"]} == {"fix", "accept"}
355
+ assert "STILL-OPEN" in q["title"] + q["why"] and "final-finding-1" in third.parked.out
356
+ assert len(rig.final_calls("final-reviewer")) == 2 and len(rig.final_calls("fixer")) == 1
357
+ assert third.parked.pushes == 0
358
+
359
+
360
+ def test_accepting_the_finding_ends_the_run_with_a_ruling_and_starts_no_more_agents(third):
361
+ rig = third.rig
362
+ assert third.answered.code == 0, rig.output()
363
+ assert rig.led("Ruling", "STILL-OPEN", "accept")
364
+ assert (third.answered.calls, third.answered.pushes) == (third.parked.calls, 0) and all(stop_lines(third.answered.out))
365
+
366
+
367
+ # ---------------------------------------------------------------- the rebase
368
+
369
+ CLI_RESOLVED = "def main():\n return 'resolved'\n"
370
+ RESOLVE = {"commit": "resolve conflict", "git_end": [["rebase", "--continue"]],
371
+ "report": {"status": "DONE", "commits": ["$SHA"], "files": ["demo/cli.py"], "concerns": []}}
372
+
373
+
374
+ @pytest.fixture(scope="module")
375
+ def conflicted(tmp_path_factory):
376
+ """The base moves: docs.md is new and demo/cli.py is added there too, so only 03's commit conflicts."""
377
+ rig = Final(tmp_path_factory.mktemp("conflict"), THREE)
378
+ rig.move_base({"docs.md": "base docs\n", "demo/cli.py": "def main():\n return 'base'\n"})
379
+ rig.run(plan(**{"final/conflict": [{**RESOLVE, "files": {"demo/cli.py": CLI_RESOLVED}}]}))
380
+ return rig
381
+
382
+
383
+ def test_a_conflict_with_the_fetched_base_runs_the_conflict_agent_once_then_the_gate(conflicted):
384
+ rig = conflicted
385
+ assert rig.code == 0, rig.output()
386
+ (call,) = rig.final_calls("conflict")
387
+ (gate,) = [g for g in rig.gates() if g["commit"] == call["sha"]]
388
+ assert gate["t0"] >= call["t1"], "the gate follows the resolution"
389
+ base = ref(rig.origin, "refs/heads/main")
390
+ git(rig.repo, "merge-base", "--is-ancestor", base, "feat/csv")
391
+ assert git(rig.repo, "show", "feat/csv:docs.md") == "base docs\n"
392
+ assert git(rig.repo, "show", "feat/csv:demo/cli.py") == CLI_RESOLVED
393
+ assert len(rig.commits(base)) == 3 and ref(rig.repo, "feat/csv^{tree}") == ref(rig.repo, f"{call['sha']}^{{tree}}")
394
+
395
+
396
+ @pytest.fixture(scope="module")
397
+ def conflicted_twice(tmp_path_factory):
398
+ """The base rewrites demo/export.py: 01's commit conflicts, and 02's commit conflicts again."""
399
+ rig = Final(tmp_path_factory.mktemp("twice"), THREE)
400
+ rig.move_base({"demo/export.py": "def export_csv():\n return 'base'\n"})
401
+ rig.run(plan(**{"final/conflict": [{**RESOLVE, "files": {"demo/export.py": fn("01").replace("'01'", "'resolved'")},
402
+ "report": {**RESOLVE["report"], "files": ["demo/export.py"]}}]}))
403
+ return rig
404
+
405
+
406
+ def test_a_second_conflict_ends_the_run_with_exit_1_and_the_branch_unrewritten_at_its_recovery_point(conflicted_twice):
407
+ rig = conflicted_twice
408
+ assert rig.code == 1, rig.output()
409
+ assert len(rig.final_calls("conflict")) == 1 and not rig.final_calls("final-reviewer")
410
+ recovery = rig.recovery()
411
+ assert recovery and rig.branch() == recovery and len(rig.commits(rig.start, recovery)) == 6
412
+ assert rig.led("ecovery", recovery[:7])
413
+ assert git(rig.repo, "symbolic-ref", "--short", "HEAD").strip() == "feat/csv" and git(rig.repo, "status", "--short") == ""
414
+ for name in ("rebase-merge", "rebase-apply"):
415
+ assert not (rig.repo / git(rig.repo, "rev-parse", "--git-path", name).strip()).exists()
416
+ assert rig.pushes() == [] and not rig.prs()
417
+
418
+
419
+ # ---------------------------------------------------------------- unattended
420
+
421
+ @pytest.fixture(scope="module")
422
+ def unattended(tmp_path_factory):
423
+ rig = Final(tmp_path_factory.mktemp("unattended"), ONE)
424
+ return rig.run(plan(), *UNATTENDED)
425
+
426
+
427
+ def test_unattended_with_everything_validated_pushes_once_opens_the_pr_and_writes_stage_done(unattended):
428
+ rig = unattended
429
+ assert rig.code == 0, rig.output()
430
+ tip = rig.branch()
431
+ assert rig.pushes() == [f"refs/heads/feat/csv {tip}"] and ref(rig.origin, "refs/heads/feat/csv") == tip
432
+ (pr,) = rig.prs()
433
+ assert [l for l in pr["body"].splitlines() if "01" in l and tip[:7] in l], pr["body"]
434
+ assert stage(rig) == {"stage": "implement", "status": "done"}
435
+
436
+
437
+ def test_unattended_with_a_blocked_ticket_pushes_nothing_and_exits_1(tmp_path):
438
+ rig = Final(tmp_path, THREE, gate={"red": ["ticket/01"]})
439
+ rig.run(plan(), *UNATTENDED)
440
+ assert rig.code == 1, rig.output()
441
+ assert rig.pushes() == [] and not rig.prs() and not ref(rig.origin, "refs/heads/feat/csv")
442
+ assert stage(rig) == {"stage": "implement", "status": "blocked", "blocked": ["01"]}