@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,582 @@
1
+ """In-process end-of-run tests: the rig and the scenarios of test_final.py without the launcher, so coverage sees
2
+ the driver, plus the cases that only a direct call or a look between two runs reaches."""
3
+ import contextlib
4
+ import io
5
+ import json
6
+ import os
7
+ from types import SimpleNamespace
8
+
9
+ import jsonschema
10
+ import pytest
11
+
12
+ import driver
13
+ import driver_final
14
+ import driver_merge
15
+ import driver_report
16
+ import driver_state
17
+ import driver_summary
18
+ import driver_tidy
19
+ import test_final
20
+ from test_cli import git
21
+ from test_final import FILES, FINAL_MODELS, RESOLVE, THREE, TITLES, final, fixer, fn, ref, stop_lines
22
+ from test_fixloop import inside # noqa: F401 (the autouse fixture puts the rig's CLIs on PATH)
23
+ from test_review import FABLE, ONE, finding
24
+ from test_stops import UNATTENDED, decisions, stage
25
+ from test_walk import plan, tw
26
+
27
+
28
+ class Inside(test_final.Final):
29
+ """The rig of test_final with the driver called in this process."""
30
+
31
+ def run(self, steps, *extra, parallel="2", watch=False):
32
+ (self.rig / "plan.json").write_text(json.dumps(steps))
33
+ args = ["--tickets", ".agent/tickets", "--gate", str(self.rig / "gate"), "--spec", "specs/csv/spec.md",
34
+ "--models", str(self.models), "--runner", str(self.rig / "run-agent"), "--parallel", parallel, *extra]
35
+ here, out = os.getcwd(), io.StringIO()
36
+ os.chdir(self.repo)
37
+ try:
38
+ with contextlib.redirect_stdout(out):
39
+ self.code = driver.main(args)
40
+ finally:
41
+ os.chdir(here)
42
+ self.out, self.err = out.getvalue(), ""
43
+ return self
44
+
45
+ def final(self):
46
+ return self.state()["run"]["final"]
47
+
48
+ def order(self):
49
+ """The ticket numbers in merge order: two tickets that run at once merge in either order."""
50
+ return [number for number, _ in self.state()["run"]["landed"]]
51
+
52
+
53
+ @pytest.fixture(autouse=True)
54
+ def tools(monkeypatch, tmp_path):
55
+ """The fake `gh` of the rig is on PATH, and no git call of an agent opens an editor."""
56
+ monkeypatch.setenv("PATH", f"{tmp_path / 'bin'}{os.pathsep}{os.environ['PATH']}")
57
+ monkeypatch.setenv("GIT_EDITOR", "true")
58
+
59
+
60
+ def stray(rig, text="by hand"):
61
+ """A commit on the PR branch that no agent of the run made."""
62
+ (rig.repo / "stray.txt").write_text(text)
63
+ git(rig.repo, "add", "-A")
64
+ git(rig.repo, "commit", "-q", "-m", text)
65
+
66
+
67
+ # ---------------------------------------------------------------- the story: local stop, rerun, push
68
+
69
+ def test_a_local_run_stops_before_the_push_and_a_push_rerun_pushes_and_opens_the_pr_once(tmp_path):
70
+ rig = Inside(tmp_path, THREE, FINAL_MODELS)
71
+ rig.run(plan())
72
+ assert rig.code == 0, rig.out + "\n".join(rig.ledger())
73
+ tip = rig.branch()
74
+ assert rig.pushes() == [] and not rig.prs() and not ref(rig.origin, "refs/heads/feat/csv"), "nothing is pushed"
75
+ shown_tip, reviewed, push = stop_lines(rig.out)
76
+ assert (shown_tip.group(1), reviewed.group(1)) == (tip, tip) and push.group(0).endswith("--parallel 2 --push")
77
+ rows, order = rig.commits(rig.start), rig.order()
78
+ assert [(r["subject"], r["body"].strip()) for r in rows] == [
79
+ (f"feat({nn}): {TITLES[nn]}", f"Ticket: {FILES[nn]}") for nn in order] and order[2] == "02"
80
+ assert len(rig.commits(rig.start, rig.recovery())) == 6
81
+ assert ref(rig.repo, "feat/csv^{tree}") == ref(rig.repo, "refs/implement/recovery^{tree}")
82
+ assert rig.led("Recovery point: feat/csv was at " + rig.recovery(), ref(rig.repo, "feat/csv^{tree}"))
83
+ assert rig.led("End of run: final-reviewer ran on", FABLE) and rig.led("End of run: the gate is green on " + tip[:7])
84
+ assert {nn: e["tip"] for nn, e in rig.state()["tickets"].items()} == {nn: r["sha"] for nn, r in zip(order, rows)}
85
+ assert f"The final review covers feat/csv up to {tip[:7]}. Nothing is pushed." in rig.out
86
+ assert git(rig.repo, "symbolic-ref", "--short", "HEAD").strip() == "feat/csv" and git(rig.repo, "status", "--short") == ""
87
+ calls, gates, ledger = len(rig.calls()), len(rig.gates()), rig.ledger()
88
+
89
+ rig.run(plan())
90
+ assert (rig.code, len(rig.calls()), len(rig.gates()), rig.branch(), rig.ledger()) == (0, calls, gates, tip, ledger)
91
+ assert all(stop_lines(rig.out)) and rig.pushes() == []
92
+
93
+ rig.run(plan(), "--push")
94
+ assert rig.code == 0 and rig.pushes() == [f"refs/heads/feat/csv {tip}"], rig.out
95
+ (pr,) = rig.prs()
96
+ assert pr["argv"][:10] == ["pr", "create", "--base", "main", "--head", "feat/csv", "--title", "csv spec",
97
+ "--body-file", ".agent/implement/final/pr-body.md"]
98
+ assert [f"- {nn}: {row['sha'][:7]} {TITLES[nn]}" for nn, row in zip(order, rows)] == pr["body"].splitlines()[4:7]
99
+ assert "## Rulings\n\nNone.\n" in pr["body"] and "Scoped review after the fix batch: not needed" in pr["body"]
100
+ assert f"Pushed feat/csv at {tip} to origin." in rig.out and "PR: https://github.example/pull/1" in rig.out
101
+ assert f"feat/csv is pushed at {tip[:7]}; its PR is https://github.example/pull/1." in rig.out
102
+ assert rig.led("The PR of feat/csv into main is open: https://github.example/pull/1.")
103
+
104
+ rig.run(plan(), "--push")
105
+ assert rig.code == 0 and len(rig.pushes()) == 1 and len(rig.prs()) == 1, "a second push rerun sends nothing"
106
+ assert (len(rig.calls()), len(rig.gates())) == (calls, gates)
107
+
108
+
109
+ def test_the_final_review_does_not_start_while_a_ticket_is_not_merged_and_the_ledger_says_so_once(tmp_path):
110
+ rig = Inside(tmp_path, THREE, gate={"red": ["ticket/01"]})
111
+ rig.run(plan())
112
+ rig.run(plan(), *UNATTENDED)
113
+ assert rig.code == 1 and not rig.final_calls("final-reviewer") and not rig.recovery()
114
+ assert rig.led("End-of-run work skipped") == ["End-of-run work skipped: these tickets are not merged: 01, 02."]
115
+ assert "The end-of-run work (rebase, tidy, final review, push) did not run: these tickets are not merged: 01, 02." in rig.out
116
+ assert stage(rig) == {"stage": "implement", "status": "blocked", "blocked": ["01"]}
117
+
118
+
119
+ def test_a_run_that_was_stopped_before_the_branch_moved_finishes_the_move_and_keeps_every_ticket_merged(tmp_path):
120
+ rig = Inside(tmp_path, THREE)
121
+ rig.run(plan())
122
+ tip, calls = rig.branch(), len(rig.calls())
123
+ git(rig.repo, "reset", "-q", "--hard", rig.recovery()) # as if the run had ended right before the move
124
+ rig.run(plan())
125
+ assert (rig.code, rig.branch(), len(rig.calls())) == (0, tip, calls), rig.out
126
+ assert not rig.led("returned to implement") and git(rig.repo, "status", "--short") == ""
127
+ assert {e["phase"] for e in rig.state()["tickets"].values()} == {"merged"}
128
+
129
+
130
+ def test_a_branch_that_cannot_move_to_the_tidied_tip_keeps_the_recorded_ticket_tips(tmp_path, monkeypatch):
131
+ rig = Inside(tmp_path, ONE)
132
+ real = driver_merge.land # the merge of the ticket moves the branch; the move to the tidied tip fails
133
+ monkeypatch.setattr(driver_merge, "land", lambda pr, new, old: (
134
+ "the ref moved" if driver_tidy.text(new).startswith("feat(01)") else real(pr, new, old)))
135
+ rig.run(plan())
136
+ assert rig.code == 1, rig.out
137
+ (line,) = rig.led("End of run: BLOCKED at the rewrite: cannot move feat/csv to the tidied tip")
138
+ assert line.endswith(f"the ref moved; feat/csv is unrewritten at its recovery point {rig.recovery()[:7]}.")
139
+ assert rig.branch() == rig.recovery() == rig.state()["tickets"]["01"]["tip"] and not rig.final().get("tidied")
140
+ assert not rig.final_calls("final-reviewer") and "End of run: BLOCKED at the rewrite" in rig.out
141
+
142
+
143
+ def test_a_tidied_tip_that_left_the_branch_stops_the_end_of_run(tmp_path):
144
+ rig = Inside(tmp_path, ONE)
145
+ rig.run(plan())
146
+ state = rig.state()
147
+ state["run"]["final"]["tidied"] = rig.recovery() # the history before the tidy is not in the branch
148
+ (rig.repo / ".agent/implement/state.json").write_text(json.dumps(state))
149
+ rig.run(plan())
150
+ assert rig.code == 1 and rig.led("End of run: BLOCKED at feat/csv does not hold the tidied tip " + rig.recovery()[:7])
151
+
152
+
153
+ def test_a_ticket_whose_merge_left_the_tree_as_it_was_gets_the_commit_before_it(tmp_path):
154
+ rig = Inside(tmp_path, ONE)
155
+ rig.run(plan(), "--dry-run")
156
+ os.chdir(rig.repo)
157
+ start = ref(rig.repo, "HEAD")
158
+ stray(rig)
159
+ tip = ref(rig.repo, "HEAD")
160
+ tickets = {nn: driver.driver_tickets.Ticket(nn, f"{nn}-t.md", f"Title {nn}", None, None, None, None, None, [], {})
161
+ for nn in ("01", "02", "03")}
162
+ commits = driver_tidy.tidy(start, [["01", start], ["02", tip], ["03", tip]], tickets)
163
+ assert commits["01"] == start and commits["02"] == commits["03"] != tip
164
+ assert driver_tidy.same_tree(commits["03"], tip) and not driver_tidy.same_tree("0" * 40, "0" * 40)
165
+ assert driver_tidy.text(commits["02"]) == "feat(02): Title 02\n\nTicket: 02-t.md\n\n"
166
+ assert git(rig.repo, "log", "-1", "--format=%an %ae", commits["02"]) == "t t@example.com\n"
167
+
168
+
169
+ # ---------------------------------------------------------------- the fix batch and its report
170
+
171
+ def test_a_fix_batch_is_named_per_commit_and_a_commit_with_no_owner_is_a_violation(tmp_path):
172
+ """The first report leaves out one of two commits; the rerun names both, one with a ticket that is not there
173
+ in a third attempt that never runs."""
174
+ rig = Inside(tmp_path, THREE)
175
+ first = fixer(fn("02"), "quote commas", "escape quotes")
176
+ first["report"]["commits"].pop()
177
+ retry = {"report": dict(first["report"], commits=[{"sha": "$HEAD~1", "ticket": "02"}, {"sha": "$HEAD", "ticket": "01"}])}
178
+ retry["files"] = {"wip.txt": "not committed\n"}
179
+ rig.run(plan(**{"final/final-reviewer": [final(finding("Q-ONE"), finding("Q-MINOR", "minor")), final()],
180
+ "final/fixer": [first, retry]}))
181
+ assert rig.code == 1, rig.out
182
+ assert rig.led("End of run: fixer ran on", "is on feat/csv and not in `commits`: each commit of the batch needs its ticket")
183
+ assert rig.led("End of run: BLOCKED at the fix batch: commits.0.sha") # `$HEAD~1` is no SHA: a second violation
184
+ reviewed = rig.final()["reviewed"]
185
+ assert rig.branch() == reviewed, "the rejected commits are off the branch"
186
+ (line,) = rig.led(f"End of run: the fix batch was rejected, so the driver moved feat/csv back to its reviewed tip {reviewed[:7]}")
187
+ left = line.split("the commits of the batch end at ")[1].split()[0]
188
+ assert git(rig.repo, "log", "--format=%s", f"{reviewed}..{left}").split("\n")[:2] == ["escape quotes", "quote commas"]
189
+ assert line.endswith("The files of the fixer stay in slot 1 as they are.")
190
+ assert (rig.repo / "wip.txt").read_text() == "not committed\n" and "# escape quotes" in (rig.repo / "demo/export.py").read_text()
191
+ prompt = rig.final_calls("fixer")[0]["prompt"]
192
+ assert "- important: Q-ONE (demo/export.py:99)\n" in prompt and "Q-MINOR" not in prompt
193
+ assert ".agent/implement/final/findings-0.json" in prompt and prompt.rstrip().endswith("one ref store.")
194
+ assert "Locked files: tests/test_01.py tests/test_02.py tests/test_03.py\n" in prompt
195
+
196
+ rig.run(plan(), "--push") # as after any committing role that failed: the work in slot 1 stops the rerun
197
+ assert rig.code == 3 and "uncommitted change: wip.txt" in rig.out and rig.pushes() == []
198
+ git(rig.repo, "checkout", "-q", "-f", "feat/csv") # the operator discards the work
199
+ (rig.repo / "wip.txt").unlink()
200
+
201
+ stray(rig, "fix(99): by hand") # a commit with the name of a fix commit and no validated owner
202
+ rig.run(plan(), "--push")
203
+ assert rig.code == 1 and rig.pushes() == [] and len(rig.final_calls("final-reviewer")) == 1, rig.out
204
+ assert rig.led("End of run: BLOCKED at the tip", f"is not a tip the driver made: only {reviewed[:7]} gets a review")
205
+
206
+ git(rig.repo, "reset", "-q", "--hard", reviewed)
207
+ rig.run(plan(**{"final/fixer": [{}, {}, fixer(fn("02"), "quote commas")]}), "--push")
208
+ assert rig.code == 0 and [r["subject"] for r in rig.commits(reviewed)] == ["fix(02): quote commas"], rig.out
209
+ assert rig.pushes() == [f"refs/heads/feat/csv {rig.branch()}"] and rig.final()["batch"] == rig.branch()
210
+
211
+
212
+ def test_a_fixer_that_times_out_twice_keeps_its_files_and_the_branch_stays_at_the_reviewed_tip(tmp_path):
213
+ rig = Inside(tmp_path, ONE)
214
+ slow = {"files": {"wip.txt": "half done\n"}, "sleep": 30}
215
+ rig.run(plan(**{"final/final-reviewer": [final(finding("Q-ONE"))], "final/fixer": [slow, slow]}),
216
+ "--timeout", "fixer=0.01", *UNATTENDED)
217
+ reviewed = rig.final()["reviewed"]
218
+ assert rig.code == 1 and rig.pushes() == [] and rig.branch() == reviewed, rig.out
219
+ assert (rig.repo / "wip.txt").read_text() == "half done\n"
220
+ assert rig.led(f"End of run: the fix batch was rejected, so the driver moved feat/csv back to its reviewed tip "
221
+ f"{reviewed[:7]}; the fixer made no commit. The files of the fixer stay in slot 1 as they are.")
222
+
223
+
224
+ def test_a_fix_commit_for_a_ticket_that_is_not_in_the_run_is_a_violation(tmp_path):
225
+ rig = Inside(tmp_path, ONE)
226
+ rig.run(plan(**{"final/final-reviewer": [final(finding("Q-ONE")), final()],
227
+ "final/fixer": [fixer(fn("01"), "fix(01): named already", ticket_of="09"),
228
+ {"report": dict(fixer(fn("01"), "x")["report"], commits=[{"sha": "$HEAD", "ticket": "01"}])}]}))
229
+ assert rig.code == 0, rig.out
230
+ assert rig.led("End of run: fixer ran on", ": 09 is not a ticket of this run")
231
+ (row,) = rig.commits(rig.final()["tidied"])
232
+ assert row["subject"] == "fix(01): named already" and rig.final()["reviewed"] == row["sha"] == rig.branch()
233
+ assert rig.led("Fix batch of the final review: 1 commit on", f"{row['sha'][:7]} fix(01): named already.")
234
+ scoped = rig.final_calls("final-reviewer")[1]["prompt"]
235
+ assert f"Range: {rig.final()['tidied']}..{row['sha']}\n" in scoped and "This is the scoped review after the fix batch" in scoped
236
+ assert scoped.rstrip().endswith("one ref store.") and ".agent/implement/final/findings-0.json" in scoped
237
+
238
+
239
+ def test_a_final_reviewer_that_changes_the_slot_is_restored_and_runs_once_more(tmp_path):
240
+ rig = Inside(tmp_path, ONE)
241
+ dirty = final(finding("DROPPED"), files={"left.txt": "x"})
242
+ moved_head = final(files={"more.txt": "x"}, commit="by the reviewer")
243
+ rig.run(plan(**{"final/final-reviewer": [dirty, final(finding("KEPT", "minor"))]}))
244
+ assert rig.code == 0 and len(rig.final_calls("final-reviewer")) == 2 and not (rig.repo / "left.txt").exists(), rig.out
245
+ assert rig.led("End of run: a read-only role broke its rule: the tree is not as the review found it: left.txt; the "
246
+ f"driver restored slot 1 to {rig.branch()[:7]} and runs the final review again.")
247
+ assert rig.led("KEPT") and not rig.led("DROPPED") and rig.final()["reviewed"] == rig.branch()
248
+
249
+
250
+ def test_a_final_reviewer_that_changes_the_slot_twice_ends_the_run_with_a_clean_slot(tmp_path):
251
+ rig = Inside(tmp_path, ONE)
252
+ moved_head = final(files={"more.txt": "x"}, commit="by the reviewer")
253
+ rig.run(plan(**{"final/final-reviewer": [final(files={"left.txt": "x"}), moved_head]}), *UNATTENDED)
254
+ tip = rig.final()["tidied"]
255
+ assert rig.code == 1 and rig.final()["reviewed"] is None and rig.branch() == tip, rig.out
256
+ assert rig.led(f"End of run: BLOCKED at the final review: the slot is no longer on feat/csv at {tip[:7]}.")
257
+ assert git(rig.repo, "status", "--short") == "" and git(rig.repo, "symbolic-ref", "--short", "HEAD") == "feat/csv\n"
258
+ rig.run(plan(), *UNATTENDED) # the rerun is not stopped by what the reviewer left
259
+ assert rig.code == 0 and rig.pushes() == [f"refs/heads/feat/csv {tip}"], rig.out
260
+
261
+
262
+ def test_a_red_gate_on_the_tidied_tip_stops_before_the_final_review(tmp_path):
263
+ rig = Inside(tmp_path, ONE, gate={"red": ["feat/csv"]})
264
+ rig.run(plan(), *UNATTENDED)
265
+ assert rig.code == 1 and not rig.final_calls("final-reviewer") and rig.pushes() == [], rig.out
266
+ assert rig.led("End of run: BLOCKED at the gate: the gate is red on " + rig.branch()[:7])
267
+ assert stage(rig) == {"stage": "implement", "status": "blocked", "blocked": ["final"]}
268
+ assert rig.state()["run"]["gates"] == 2 and "| gates run | 2 |" in rig.out
269
+
270
+
271
+ # ---------------------------------------------------------------- the triage after the second round
272
+
273
+ def parked(tmp_path, *findings):
274
+ rig = Inside(tmp_path, ONE)
275
+ steps = plan(**{"final/final-reviewer": [final(*findings), final(*findings)],
276
+ "final/fixer": [fixer(fn("01"), "first try", ticket_of="01")]})
277
+ return rig.run(steps), steps
278
+
279
+
280
+ def test_open_findings_after_the_second_round_park_the_run_on_one_question_each(tmp_path):
281
+ rig, steps = parked(tmp_path, finding("STILL-OPEN"), finding("ALSO"))
282
+ assert rig.code == 2 and rig.pushes() == [], rig.out
283
+ first, second = decisions(rig)["questions"]
284
+ assert (first["id"], second["id"]) == ("final-finding-1", "final-finding-2")
285
+ assert first["title"] == "Final review: a finding is open after the second review round: STILL-OPEN (demo/export.py:99)"
286
+ assert [o["id"] for o in first["options"]] == ["fix", "accept"] and first["recommended"] == "fix"
287
+ assert first["evidence"] == [".agent/implement/final/findings-1.json", ".agent/implement/final/final-reviewer-1.json"]
288
+ assert "Question final-finding-1: " in rig.out and rig.state()["run"]["stops"] == 1
289
+ assert rig.led("End of run: the operator decides on 2 questions of kind final-finding")
290
+ assert len(rig.final_calls("final-reviewer")) == 2 and len(rig.final_calls("fixer")) == 1
291
+
292
+ rig.run(steps, "--answer", "final-finding-1=accept")
293
+ assert rig.code == 2 and rig.led("Ruling: final-review finding closed as it is (accept): STILL-OPEN (demo/export.py:99).")
294
+ assert [q["id"] for q in decisions(rig)["questions"]] == ["final-finding-2"] and not any(stop_lines(rig.out))
295
+
296
+ rig.run(steps, "--answer", "final-finding-2=accept", "--push")
297
+ assert rig.code == 0 and rig.pushes() == [f"refs/heads/feat/csv {rig.branch()}"], rig.out
298
+ body = rig.prs()[0]["body"]
299
+ assert "- Ruling: final-review finding closed as it is (accept): ALSO (demo/export.py:99)." in body
300
+ assert f"- {rig.branch()[:7]} fix(01): first try" in body
301
+ assert f"Scoped review after the fix batch: {rig.final()['tidied']}..{rig.branch()}" in body
302
+ assert len(rig.final_calls("final-reviewer")) == 2, "no third round"
303
+
304
+
305
+ def test_the_answer_fix_keeps_the_finding_open_and_nothing_is_pushed(tmp_path):
306
+ rig, steps = parked(tmp_path, finding("STILL-OPEN"))
307
+ rig.run(steps, "--answer", "final-finding-1=fix", *UNATTENDED)
308
+ assert rig.code == 1 and rig.pushes() == [] and not rig.prs(), rig.out
309
+ assert rig.led("Ruling: final-review finding stays open and nothing is pushed (fix): STILL-OPEN")
310
+ assert rig.led("End of run: BLOCKED at the operator keeps 1 finding of the final review open (fix): nothing is pushed.")
311
+ assert stage(rig) == {"stage": "implement", "status": "blocked", "blocked": ["final"]}
312
+
313
+
314
+ def test_a_commit_after_the_second_review_is_never_reviewed_or_pushed(tmp_path):
315
+ rig, steps = parked(tmp_path, finding("STILL-OPEN"))
316
+ rig.run(steps, "--answer", "final-finding-1=accept")
317
+ assert rig.code == 0 and all(stop_lines(rig.out))
318
+ stray(rig, "fix(01): by hand")
319
+ rig.run(steps, "--push")
320
+ assert rig.code == 1 and rig.pushes() == [] and len(rig.final_calls("final-reviewer")) == 2
321
+ assert rig.led("End of run: BLOCKED at the two review rounds are done and the tip", "is not the reviewed tip")
322
+
323
+
324
+ # ---------------------------------------------------------------- the rebase
325
+
326
+ def moved(tmp_path, base, *conflict, **kw):
327
+ rig = Inside(tmp_path, THREE, **kw)
328
+ rig.move_base(base)
329
+ return rig.run(plan(**{"final/conflict": list(conflict)}), *UNATTENDED)
330
+
331
+
332
+ def test_a_base_that_moved_with_no_conflict_is_rebased_onto_with_no_agent_and_one_gate(tmp_path):
333
+ rig = moved(tmp_path, {"docs.md": "base docs\n"})
334
+ assert rig.code == 0 and not rig.final_calls("conflict"), rig.out
335
+ base, rows = ref(rig.origin, "refs/heads/main"), rig.commits(ref(rig.origin, "refs/heads/main"))
336
+ order = rig.order()
337
+ assert [r["subject"] for r in rows] == [f"feat({nn}): {TITLES[nn]}" for nn in order] and order[2] == "02"
338
+ assert ref(rig.repo, "feat/csv~3") == base and git(rig.repo, "show", "feat/csv:docs.md") == "base docs\n"
339
+ assert [p.name for p in (rig.repo / ".agent/implement/final").glob("gate-*.log")] == [f"gate-{rig.branch()[:7]}.log"]
340
+ assert rig.final()["commits"] == {nn: row["sha"] for nn, row in zip(order, rows)}
341
+ assert rig.final()["onto"] == base and f"Range: {base}..{rig.branch()}\n" in rig.final_calls("final-reviewer")[0]["prompt"]
342
+ assert rig.pushes() == [f"refs/heads/feat/csv {rig.branch()}"] and stage(rig) == {"stage": "implement", "status": "done"}
343
+
344
+
345
+ def test_a_resolved_rebase_keeps_the_ticket_messages_and_gates_the_rebased_tip_and_the_tidied_tip(tmp_path):
346
+ cli = {"demo/cli.py": "def main():\n return 'base'\n"}
347
+ resolved = {**RESOLVE, "files": {"demo/cli.py": "def main():\n return 'both'\n"}, "git_end": []}
348
+ rig = moved(tmp_path, cli, resolved) # the agent commits as "resolve conflict" and the driver goes on with the rebase
349
+ assert rig.code == 0, rig.out + "\n".join(rig.ledger())
350
+ (call,) = rig.final_calls("conflict")
351
+ assert "Files in conflict: demo/cli.py\n" in call["prompt"] and "Base branch: main, at commit " in call["prompt"]
352
+ assert call["branch"] == "HEAD" and call["prompt"].rstrip().endswith("one ref store.")
353
+ rows = rig.commits(ref(rig.origin, "refs/heads/main"))
354
+ assert [r["subject"] for r in rows] == [f"feat({nn}): {TITLES[nn]}" for nn in rig.order()]
355
+ assert git(rig.repo, "show", "feat/csv:demo/cli.py") == "def main():\n return 'both'\n"
356
+ (line,) = rig.led("Rebase: the conflict agent resolved the conflict with main in demo/cli.py at ")
357
+ rebased = line.rstrip(".").split()[-1]
358
+ records = sorted(p.name for p in (rig.repo / ".agent/implement/final").glob("gate-*.log"))
359
+ assert records == sorted([f"gate-{rebased}.log", f"gate-{rig.branch()[:7]}.log"]) and rebased != rig.branch()[:7]
360
+ assert ref(rig.repo, f"{rebased}^{{tree}}") == ref(rig.repo, "feat/csv^{tree}")
361
+
362
+
363
+ def test_a_second_conflict_leaves_the_branch_at_its_recovery_point_with_all_its_commits(tmp_path):
364
+ step = {**RESOLVE, "files": {"demo/export.py": "resolved = 1\n"}, "report": {**RESOLVE["report"], "files": ["demo/export.py"]}}
365
+ rig = moved(tmp_path, {"demo/export.py": "def export_csv():\n return 'base'\n"}, step)
366
+ assert rig.code == 1 and len(rig.final_calls("conflict")) == 1 and not rig.final_calls("final-reviewer"), rig.out
367
+ recovery = rig.recovery()
368
+ assert rig.branch() == recovery and len(rig.commits(rig.start, recovery)) == 6, "two commits per ticket, as before"
369
+ assert rig.led("End of run: BLOCKED at the rewrite: the rebase onto main conflicts a second time, in demo/export.py; "
370
+ f"feat/csv is unrewritten at its recovery point {recovery[:7]}.")
371
+ assert git(rig.repo, "symbolic-ref", "--short", "HEAD").strip() == "feat/csv" and git(rig.repo, "status", "--short") == ""
372
+ assert rig.pushes() == [] and stage(rig)["blocked"] == ["final"] and not rig.final().get("tidied")
373
+ assert {e["phase"] for e in rig.state()["tickets"].values()} == {"merged"}
374
+
375
+ rig.run(plan(), *UNATTENDED) # the rerun starts the rewrite again from the same recovery point
376
+ assert rig.code == 1 and rig.recovery() == recovery == rig.branch()
377
+ assert len(rig.final_calls("conflict")) == 3, "the conflict agent of the rerun, which has no script, and its one rerun"
378
+ assert not rig.led("returned to implement")
379
+
380
+
381
+ def test_a_conflict_agent_that_gives_up_leaves_no_rebase_open(tmp_path):
382
+ stuck = {"report": {"status": "BLOCKED", "commits": [], "files": [], "concerns": ["both sides are needed"]}}
383
+ rig = moved(tmp_path, {"demo/cli.py": "base\n"}, stuck)
384
+ assert rig.code == 1 and rig.branch() == rig.recovery(), rig.out
385
+ assert rig.led("End of run: BLOCKED at the rewrite: the conflict agent: the conflict reported BLOCKED: both sides are needed;")
386
+ assert git(rig.repo, "status", "--short") == "" and git(rig.repo, "symbolic-ref", "--short", "HEAD") == "feat/csv\n"
387
+ assert not (rig.repo / ".git/rebase-merge").exists()
388
+
389
+
390
+ def test_a_red_gate_after_the_rebase_leaves_the_branch_unrewritten(tmp_path):
391
+ rig = Inside(tmp_path, ONE, gate={"red": ["HEAD"]}) # red on a detached commit: the rebased tip
392
+ rig.move_base({"demo/export.py": "def export_csv():\n return 'base'\n"})
393
+ rig.run(plan(**{"final/conflict": [{**RESOLVE, "files": {"demo/export.py": "x = 1\n"}}]}))
394
+ assert rig.code == 1 and rig.branch() == rig.recovery(), rig.out
395
+ assert rig.led("End of run: BLOCKED at the rewrite: the gate after the rebase: the gate is red on ")
396
+
397
+
398
+ def test_a_base_that_holds_a_ticket_commit_already_leaves_the_branch_unrewritten(tmp_path):
399
+ rig = Inside(tmp_path, ONE)
400
+ rig.move_base({**tw("01")["files"], "demo/export.py": fn("01")}) # the same change as the commit of ticket 01
401
+ rig.run(plan())
402
+ assert rig.code == 1 and rig.branch() == rig.recovery(), rig.out
403
+ assert rig.led("End of run: BLOCKED at the rewrite: the rebase onto main left 0 commits for 1;")
404
+
405
+
406
+ @pytest.mark.parametrize("patch, error", [
407
+ (lambda rig: ("ancestor", lambda commit, of, real=driver_merge.ancestor: commit != rig.start and real(commit, of)),
408
+ "feat/csv no longer holds {start}, the commit the run started from"),
409
+ (lambda rig: ("same_tree", lambda one, other: False), "the tidied history does not have the tree of {old}"),
410
+ ])
411
+ def test_a_history_the_tidy_cannot_trust_is_not_rewritten(tmp_path, monkeypatch, patch, error):
412
+ rig = Inside(tmp_path, ONE)
413
+ name, fake = patch(rig)
414
+ monkeypatch.setattr(driver_tidy if name == "same_tree" else driver_merge, name, fake)
415
+ rig.run(plan())
416
+ assert rig.code == 1 and rig.branch() == rig.recovery() and not rig.final().get("tidied"), rig.out
417
+ assert rig.led("End of run: BLOCKED at the rewrite: " + error.format(start=rig.start[:7], old=rig.recovery()[:7]))
418
+ assert len(rig.commits(rig.start)) == 2, "the two commits of the ticket, as they were"
419
+
420
+
421
+ @pytest.mark.parametrize("change, error", [
422
+ (lambda rig: git(rig.repo, "remote", "set-url", "origin", str(rig.tmp / "nowhere.git")), "origin names no default branch: "),
423
+ (lambda rig: git(rig.origin, "symbolic-ref", "HEAD", "refs/heads/feat/csv"),
424
+ "feat/csv is the default branch of origin: the end of run needs a PR branch."),
425
+ ])
426
+ def test_an_origin_with_no_base_for_the_pr_branch_stops_the_end_of_run_before_the_recovery_point(tmp_path, change, error):
427
+ rig = Inside(tmp_path, ONE)
428
+ git(rig.repo, "push", "-q", "origin", "feat/csv")
429
+ change(rig)
430
+ rig.run(plan())
431
+ assert rig.code == 1 and not rig.recovery() and len(rig.commits(rig.start)) == 2, rig.out
432
+ assert rig.led("End of run: BLOCKED at " + error)
433
+
434
+
435
+ # ---------------------------------------------------------------- the push
436
+
437
+ def test_a_push_or_a_pr_that_fails_is_tried_again_and_nothing_is_sent_twice(tmp_path):
438
+ rig = Inside(tmp_path, ONE)
439
+ gh, hook = tmp_path / "bin/gh", rig.origin / "hooks/pre-receive"
440
+ script, accept = gh.read_text(), hook.read_text()
441
+ gh.unlink()
442
+ rig.run(plan(), *UNATTENDED)
443
+ assert rig.code == 1 and rig.pushes() == [], rig.out
444
+ assert rig.led("End of run: BLOCKED at gh is not on PATH: nothing is pushed.")
445
+ assert stage(rig) == {"stage": "implement", "status": "blocked", "blocked": ["final"]}
446
+
447
+ gh.write_text("#!/bin/sh\necho no token >&2\nexit 4\n")
448
+ gh.chmod(0o755)
449
+ hook.write_text("#!/bin/sh\necho REFUSED >&2\nexit 1\n")
450
+ rig.run(plan(), *UNATTENDED)
451
+ assert rig.code == 1 and not ref(rig.origin, "refs/heads/feat/csv") and not rig.final().get("pushed")
452
+ assert rig.led("End of run: BLOCKED at git push origin feat/csv failed: ", "REFUSED")
453
+
454
+ hook.write_text(accept)
455
+ rig.run(plan(), *UNATTENDED)
456
+ assert rig.code == 1 and rig.pushes() == [f"refs/heads/feat/csv {rig.branch()}"] and not rig.final().get("pr")
457
+ assert rig.led("End of run: BLOCKED at gh pr create failed: no token.")
458
+ assert f"feat/csv is pushed at {rig.branch()[:7]}; its PR is not open." in rig.out
459
+
460
+ gh.write_text(script)
461
+ calls = len(rig.calls())
462
+ rig.run(plan(), *UNATTENDED)
463
+ assert rig.code == 0 and len(rig.pushes()) == 1 and len(rig.prs()) == 1 and len(rig.calls()) == calls, rig.out
464
+ assert stage(rig) == {"stage": "implement", "status": "done"} and rig.final()["pr"] == "https://github.example/pull/1"
465
+
466
+
467
+ def test_a_reviewed_tip_with_no_green_gate_record_is_not_pushed(tmp_path):
468
+ rig = Inside(tmp_path, ONE)
469
+ rig.run(plan())
470
+ state = rig.state()
471
+ state["run"]["final"]["green_gates"] = []
472
+ (rig.repo / ".agent/implement/state.json").write_text(json.dumps(state))
473
+ rig.run(plan(), "--push")
474
+ assert rig.code == 1 and rig.pushes() == [] and not rig.prs(), rig.out
475
+ assert rig.led(f"End of run: BLOCKED at the reviewed tip {rig.branch()[:7]} is not the tip of feat/csv with a green "
476
+ "gate record: nothing is pushed.")
477
+
478
+
479
+ def test_the_rerun_command_drops_the_answers_and_the_pr_title_falls_back_to_the_branch():
480
+ command = driver_final.again(["--gate", "make check", "--answer", "final-finding-1=accept", "--answer=x=y", "--spec", "s.md"])
481
+ assert command == f"sh {driver_final.driver_agents.SKILL / 'run.sh'} --gate 'make check' --spec s.md --push"
482
+ assert driver_final._name(SimpleNamespace(args=SimpleNamespace(spec="nowhere.md"), pr="feat/x")) == "feat/x"
483
+
484
+
485
+ # ---------------------------------------------------------------- preconditions and the skip
486
+
487
+ @pytest.mark.parametrize("remote", [True, False], ids=["origin", "no-origin"])
488
+ def test_without_the_review_diff_skill_every_repository_stops_before_any_agent(tmp_path, monkeypatch, remote):
489
+ monkeypatch.setattr(driver_final, "REVIEW_DIFF", tmp_path / "review-diff/SKILL.md")
490
+ rig = Inside(tmp_path, ONE, remote=remote).run(plan())
491
+ assert rig.code == 3 and not rig.calls(), rig.out
492
+ assert rig.out == (f"{tmp_path}/review-diff/SKILL.md is missing: the final review runs through it; install the "
493
+ "review-diff skill next to implement-spec\n")
494
+
495
+
496
+ def test_without_an_origin_the_run_ends_reviewed_and_a_push_is_refused_at_the_start_and_after_the_tidy(tmp_path):
497
+ rig = Inside(tmp_path, ONE, remote=False).run(plan())
498
+ assert rig.code == 0, rig.out
499
+ tip, final = rig.branch(), rig.final()
500
+ assert (final["base"], final["onto"], final["reviewed"]) == ("", rig.state()["run"]["start"], tip)
501
+ assert rig.led("no origin") == ["End of run: the repository has no origin remote, so the rebase and the push are "
502
+ "skipped; the tidy, the gate and the final review run."]
503
+ assert (f"1 of 1 tickets are merged into feat/csv.\nThe final review covers feat/csv up to {tip[:7]}. Nothing is "
504
+ "pushed.\n\n| Measure") in rig.out
505
+ assert rig.out.endswith(f"Reviewed tip: {tip}\nNothing is pushed. The repository has no origin remote.\n")
506
+ # A run that is to push needs the remote: it stops before the lock of a new run and before any agent.
507
+ calls = len(rig.calls())
508
+ for flags in (UNATTENDED, ("--push",)):
509
+ rig.run(plan(), *flags)
510
+ assert rig.code == 3 and len(rig.calls()) == calls and not (rig.repo / ".agent/stage.json").exists()
511
+ assert rig.out == ("the repository has no origin remote: --unattended and --push push the PR branch to origin; "
512
+ "add the remote, or run without them to stop before the push\n")
513
+ # An origin that comes after the tidy does not make the branch a PR branch: it is on no base of that origin.
514
+ git(rig.repo, "remote", "add", "origin", str(tmp_path / "late.git"))
515
+ rig.run(plan(), "--push")
516
+ assert rig.code == 1 and not rig.pushes() and not rig.prs() and rig.branch() == tip
517
+ assert rig.led("BLOCKED") == [
518
+ "End of run: BLOCKED at the end of run tidied feat/csv when the repository had no origin remote, so it is on "
519
+ "no base branch of origin: nothing is pushed."]
520
+ fresh = {"pr_branch": "feat/x", "gates": 0, "stops": 0, "agents": [], "final": driver_state._final()}
521
+ assert driver_summary.render({"run": fresh, "tickets": {}}).startswith("0 of 0 tickets are merged into feat/x.\n\n| Measure")
522
+
523
+
524
+ @pytest.mark.parametrize("name", ["final-reviewer", "final-fixer", "rebase-conflict"])
525
+ def test_the_templates_of_the_end_of_run_hold_a_valid_schema_and_end_with_the_rule(name):
526
+ jsonschema.Draft202012Validator.check_schema(driver_report.schema(name))
527
+ text = (driver_report.PROMPTS / f"{name}.md").read_text()
528
+ assert text.rstrip().endswith("one ref store.") and text.rfind("{report}") < text.rfind("DONE_WITH_CONCERNS")
529
+
530
+
531
+ # ---------------------------------------------------------------- fix round 1 of ticket 06
532
+
533
+ @pytest.mark.parametrize("ref_name", ["feat/csv", "other"])
534
+ def test_a_history_that_origin_already_has_is_not_rebased_and_not_tidied(tmp_path, ref_name):
535
+ """The PR branch has a commit of its own before the run, and origin has it, as the PR branch or under
536
+ another ref. Then the base moves."""
537
+ rig = Inside(tmp_path, ONE)
538
+ stray(rig, "published before the run")
539
+ published = rig.branch()
540
+ git(rig.repo, "push", "-q", "origin", f"feat/csv:refs/heads/{ref_name}")
541
+ rig.move_base({"docs.md": "base docs\n"})
542
+ rig.run(plan(), *UNATTENDED)
543
+ assert rig.code == 0, rig.out + "\n".join(rig.ledger())
544
+ assert [r["subject"] for r in rig.commits(published)] == ["test: 01 red", "feat: 01 rows"], "no tidy"
545
+ assert ref(rig.repo, "feat/csv~2") == published and not rig.recovery(), "no rebase, so no recovery point"
546
+ assert len(rig.led("End of run: origin has commits of feat/csv, so its history is kept as it is: no tidy and no rebase")) == 1
547
+ assert len(rig.final_calls("final-reviewer")) == 1 and rig.led("End of run: the gate is green on " + rig.branch()[:7])
548
+ assert rig.pushes()[-1] == f"refs/heads/feat/csv {rig.branch()}" and ref(rig.origin, "refs/heads/feat/csv") == rig.branch()
549
+ assert f"- 01: {rig.branch()[:7]} Export rows" in rig.prs()[0]["body"] and stage(rig)["status"] == "done"
550
+
551
+
552
+ def weak(nn="01"):
553
+ return {f"tests/test_{nn}.py": "def test_x():\n assert True\n"}
554
+
555
+
556
+ def test_a_fix_batch_that_changes_a_locked_test_gives_a_critical_finding_and_is_not_pushed(tmp_path):
557
+ rig = Inside(tmp_path, ONE)
558
+ batch = fixer(fn("01"), "weaken the test", ticket_of="01")
559
+ batch["commits"][0]["files"].update(weak())
560
+ rig.run(plan(**{"final/final-reviewer": [final(finding("Q-ONE")), final()], "final/fixer": [batch]}), *UNATTENDED)
561
+ assert rig.code == 2 and rig.pushes() == [] and not rig.prs(), rig.out
562
+ assert rig.led("Final review round 2: critical finding of kind code at tests/test_01.py:1: locked file changed: tests/test_01.py.")
563
+ (q,) = decisions(rig)["questions"]
564
+ assert q["title"].endswith("locked file changed: tests/test_01.py (tests/test_01.py:1)")
565
+
566
+
567
+ def test_a_rebase_resolution_that_changes_a_locked_test_leaves_the_branch_at_its_recovery_point(tmp_path):
568
+ rig = Inside(tmp_path, ONE)
569
+ rig.move_base({"demo/export.py": "def export_csv():\n return 'base'\n"})
570
+ rig.run(plan(**{"final/conflict": [{**RESOLVE, "files": {"demo/export.py": "x = 1\n", **weak()}}]}), *UNATTENDED)
571
+ assert rig.code == 1 and rig.pushes() == [] and rig.branch() == rig.recovery(), rig.out
572
+ assert rig.led("End of run: BLOCKED at the rewrite: the conflict agent: locked file changed: tests/test_01.py;")
573
+ assert git(rig.repo, "status", "--short") == "" and not rig.final_calls("final-reviewer")
574
+
575
+
576
+ def test_a_rebase_with_no_commit_to_replay_puts_each_ticket_on_the_fetched_base(tmp_path, monkeypatch):
577
+ rig = Inside(tmp_path, ONE)
578
+ rig.move_base({"docs.md": "base docs\n"})
579
+ git(rig.repo, "fetch", "-q", "origin")
580
+ onto = ref(rig.repo, "refs/remotes/origin/main")
581
+ monkeypatch.chdir(rig.repo)
582
+ assert driver_final._rebased(None, {"01": rig.start, "02": rig.start}, "main", onto) == {"01": onto, "02": onto}