@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,355 @@
1
+ """Ticket 09 acceptance tests: locked tests are checked on the merge result; one driver per repository.
2
+
3
+ All through `sh run.sh --runner <script> --parallel 2` on the three-ticket fixture repository (the rigs of test_review
4
+ and test_final), and through two worktrees of one fixture repository for the run lock. Tickets are ordered with signal
5
+ files and with `wait_pr_moves`, never with a sleep.
6
+
7
+ Interface choices (ticket 09):
8
+ - a ticket that "does not land" ends the run with exit 1 and phase `blocked`; one ledger sentence names the ticket and
9
+ `locked file changed: <path>`; no commit of the ticket is on the PR branch. Whether a fix round ran before is free:
10
+ each plan gives that ticket fixers that write no report.
11
+ - an implementer "merges the PR branch" with the `git` hook of the fake runner: `git merge --no-edit feat/csv` in its
12
+ slot, after `wait_pr_moves`, before it writes its files and commits.
13
+ - a rejected test-writer report gets the usual one rerun: the second prompt is the first plus a note that names the
14
+ file the report did not list.
15
+ - check and ref move as one step: no black-box test can stop the driver between the two. The one window a test can
16
+ hold open is the merge gate, which runs between the merge commit and the ref move. The gate of the first merge
17
+ commit stays open until the reviewer of the other ticket ended, plus a bounded time for that ticket to reach its
18
+ landing. Either order of the two landings is correct, and so is a driver that gives the stale merge commit up;
19
+ a landing after the lock with the weakened file is not, and a landing that is lost is not.
20
+ - the lock sentence is the whole stdout of the second driver, one line, and it names the lock by a path that exists
21
+ when it is resolved in the second worktree (an absolute path, or a path relative to that worktree).
22
+ - "the second one starts" and "the lock is released": a driver in the other worktree runs its one ticket to exit 0.
23
+ - "never reported as an uncommitted change": no line of `git status --short` in either worktree has `lock` in it.
24
+ """
25
+ import json
26
+ import os
27
+ import signal
28
+ import subprocess
29
+ import time
30
+
31
+ import pytest
32
+
33
+ from test_cli import RUN, git, models_file, snapshot, three, ticket
34
+ from test_final import Final, ref
35
+ from test_review import HOOKED, Review, cli
36
+ from test_stops import UNATTENDED, blocked as contradicted
37
+ from test_walk import GATE, Rig, impl, make_env, plan, tw
38
+
39
+ T01, T03, SHARED = "tests/test_01.py", "tests/test_03.py", "tests/test_shared.py"
40
+ LOCKED_01, LOCKED_03 = tw("01")["files"][T01], tw("03")["files"][T03]
41
+ WEAK = "def test_x():\n assert True\n"
42
+ # A test file of the repository's start. A weaker first test and a new last test merge with no conflict.
43
+ SEED = ("def test_first():\n assert 1 + 1 == 2\n\n\n# one\n# two\n# three\n# four\n# five\n# six\n\n\n"
44
+ "def test_last():\n assert 2 + 2 == 4\n")
45
+ WEAK_SEED = SEED.replace("assert 1 + 1 == 2", "assert True")
46
+ MORE_SEED = SEED + "\n\ndef test_more():\n assert 3 + 3 == 'x'\n"
47
+ NO_REPORT = [{}, {}] # a fixer that writes no report: a fix round, when one starts, ends BLOCKED
48
+
49
+
50
+ def on_pr(rig, path):
51
+ proc = subprocess.run(["git", "show", f"feat/csv:{path}"], cwd=rig.repo, capture_output=True, text=True)
52
+ return proc.stdout if proc.returncode == 0 else None
53
+
54
+
55
+ def phase(rig, nn):
56
+ return rig.state()["tickets"][nn]["phase"]
57
+
58
+
59
+ def holds(rig, sha, commit="feat/csv"):
60
+ return subprocess.run(["git", "merge-base", "--is-ancestor", sha, commit], cwd=rig.repo).returncode == 0
61
+
62
+
63
+ def kept_out(rig, nn):
64
+ """Ticket `nn` did not land: the run ends with exit 1 and the PR branch has none of its commits."""
65
+ assert rig.code == 1, rig.output()
66
+ assert phase(rig, nn) == "blocked"
67
+ assert not {f"test: {nn} red", f"feat: {nn} rows"} & set(rig.pr_subjects()), rig.pr_subjects()
68
+ assert not any(holds(rig, c["sha"]) for c in rig.calls(nn) if c["sha"])
69
+
70
+
71
+ def refused(rig, nn, path):
72
+ """The landing of ticket `nn` was refused for the locked file `path` of a merged ticket."""
73
+ kept_out(rig, nn)
74
+ assert rig.led(nn, f"locked file changed: {path}"), rig.ledger()
75
+
76
+
77
+ def writer(nn, path, text, listed=True):
78
+ """The test-writer of `nn` that also writes `path`, and lists it as locked or not."""
79
+ step = tw(nn)
80
+ step["files"][path] = text
81
+ step["report"]["locked"] += [path] * listed
82
+ return step
83
+
84
+
85
+ def seeded(rig):
86
+ (rig.repo / SHARED).parent.mkdir()
87
+ (rig.repo / SHARED).write_text(SEED)
88
+ git(rig.repo, "add", "-A")
89
+ git(rig.repo, "commit", "-q", "-m", "seed")
90
+ return rig
91
+
92
+
93
+ # ---------------------------------------------------------------- the implementer of a later ticket
94
+
95
+ def test_an_implementer_that_weakens_the_locked_test_of_a_merged_ticket_does_not_land_and_nothing_is_pushed(tmp_path):
96
+ rig = Final(tmp_path, three())
97
+ weak = impl("02", files={"demo/export.py": "def fn_02():\n return '02'\n", T01: WEAK})
98
+ rig.run(plan(**{"02/implementer": [weak], "02/fixer": NO_REPORT}), *UNATTENDED)
99
+ refused(rig, "02", T01)
100
+ assert phase(rig, "01") == "merged" and on_pr(rig, T01) == LOCKED_01
101
+ assert rig.pushes() == [] and not rig.prs() and not ref(rig.origin, "refs/heads/feat/csv")
102
+
103
+
104
+ # ---------------------------------------------------------------- a ticket branch that takes the PR branch in
105
+
106
+ def takes_in(*more):
107
+ """03's implementer in slot 2: it waits until the PR branch moved, merges it into ticket/03, then commits."""
108
+ return {**impl("03", "demo/cli.py"), "wait_pr_moves": True, "git": [["merge", "-q", "--no-edit", "feat/csv"], *more]}
109
+
110
+
111
+ def test_a_ticket_that_merged_the_pr_branch_into_itself_lands_with_the_tests_of_the_merged_ticket(tmp_path):
112
+ rig = Review(tmp_path, three()).run(plan(**{"03/implementer": [takes_in()], "03/fixer": NO_REPORT}))
113
+ (call,) = rig.calls("03", "implementer")
114
+ assert call["cwd"] == str(rig.slot2) and holds(rig, rig.sha("01", "implementer"), call["sha"]), "01 is in ticket/03"
115
+ assert not rig.led("locked file changed"), rig.ledger()
116
+ assert rig.code == 0, rig.output()
117
+ assert phase(rig, "03") == "merged" and on_pr(rig, T01) == LOCKED_01 and on_pr(rig, T03) == LOCKED_03
118
+
119
+
120
+ def test_a_ticket_that_merged_the_pr_branch_and_deleted_a_locked_test_of_the_merged_ticket_does_not_land(tmp_path):
121
+ rig = Review(tmp_path, three())
122
+ rig.run(plan(**{"03/implementer": [takes_in(["rm", "-q", T01])], "03/fixer": NO_REPORT}))
123
+ (call,) = rig.calls("03", "implementer")
124
+ assert holds(rig, rig.sha("01", "implementer"), call["sha"]), "01 is in ticket/03"
125
+ refused(rig, "03", T01)
126
+ assert phase(rig, "01") == "merged" and on_pr(rig, T01) == LOCKED_01
127
+
128
+
129
+ # ---------------------------------------------------------------- a file that no ticket had locked yet
130
+
131
+ def test_a_ticket_that_weakened_a_test_file_before_a_ticket_locked_it_does_not_land_after_that_ticket(tmp_path):
132
+ """03 passes its gate and starts its review while 01 runs; 01 then locks the file and merges first."""
133
+ rig = seeded(Review(tmp_path, three()))
134
+ weak = impl("03", files={"demo/cli.py": cli("three"), SHARED: WEAK_SEED})
135
+ rig.run(plan(**{"01/test-writer": [writer("01", SHARED, MORE_SEED)],
136
+ "01/implementer": [{**impl("01"), "wait": ["r3"]}],
137
+ "03/implementer": [weak], "03/reviewer": [{"touch": ["r3"], "wait_pr_moves": True}],
138
+ "03/fixer": NO_REPORT}))
139
+ assert phase(rig, "01") == "merged" and SHARED in rig.state()["tickets"]["01"]["locked"], rig.output()
140
+ refused(rig, "03", SHARED)
141
+ assert on_pr(rig, SHARED) == MORE_SEED
142
+
143
+
144
+ # ---------------------------------------------------------------- the test-writer of a later ticket
145
+
146
+ def test_a_test_writer_that_extends_a_locked_file_of_a_merged_ticket_and_lists_it_lands_with_the_extension(tmp_path):
147
+ more = LOCKED_01 + "\n\ndef test_y():\n assert '02' == 'y'\n"
148
+ rig = Review(tmp_path, three()).run(plan(**{"02/test-writer": [writer("02", T01, more)]}))
149
+ assert rig.code == 0, rig.output()
150
+ assert not rig.led("locked file changed") and on_pr(rig, T01) == more
151
+ assert phase(rig, "02") == "merged" and rig.state()["tickets"]["02"]["locked"] == ["tests/test_02.py", T01]
152
+
153
+
154
+ @pytest.mark.parametrize("second_lists", [False, True])
155
+ def test_a_test_writer_that_changes_a_locked_file_of_a_merged_ticket_and_does_not_list_it_is_rerun_once(tmp_path, second_lists):
156
+ one = LOCKED_01 + "\n\ndef test_y():\n assert '02' == 'y'\n"
157
+ two = one + "\n\ndef test_z():\n assert '02' == 'z'\n"
158
+ again = {**writer("02", T01, two, listed=second_lists), "commit": "test: 02 red again"}
159
+ rig = Review(tmp_path, three()).run(plan(**{"02/test-writer": [writer("02", T01, one, listed=False), again]}))
160
+ calls = rig.calls("02", "test-writer")
161
+ assert len(calls) == 2, rig.output()
162
+ note = calls[1]["prompt"].replace(calls[0]["prompt"].strip(), "")
163
+ assert calls[0]["prompt"].strip() in calls[1]["prompt"] and T01 in note and "locked" in note
164
+ if second_lists:
165
+ assert rig.code == 0, rig.output()
166
+ assert phase(rig, "02") == "merged" and on_pr(rig, T01) == two and not rig.led("locked file changed")
167
+ else:
168
+ assert rig.code == 1, rig.output()
169
+ assert rig.led("02", "BLOCKED", T01) and phase(rig, "02") == "blocked"
170
+ assert not rig.calls("02", "implementer") and on_pr(rig, T01) == LOCKED_01
171
+
172
+
173
+ # ---------------------------------------------------------------- the conflict agent
174
+
175
+ @pytest.mark.parametrize("changes", [True, False])
176
+ def test_a_conflict_resolution_lands_only_when_it_leaves_the_locked_files_of_the_merged_tickets_alone(tmp_path, changes):
177
+ """01 and 03 both add demo/cli.py; 03 merges first, so the merge of 01 goes to the conflict agent."""
178
+ resolve = {"files": {"demo/cli.py": cli("resolved"), **({T03: WEAK} if changes else {})}, "commit": "merge: resolve cli",
179
+ "report": {"status": "DONE", "commits": ["$SHA"], "files": ["demo/cli.py"], "concerns": []}}
180
+ one = impl("01", files={"demo/export.py": "def fn_01():\n return '01'\n", "demo/cli.py": cli("one")})
181
+ rig = Review(tmp_path, three()).run(plan(**{
182
+ "01/test-writer": [{**tw("01"), "touch": ["s1"]}],
183
+ "03/implementer": [{**impl("03", "demo/cli.py", cli("three")), "wait": ["s1"]}],
184
+ "01/implementer": [{**one, "wait_pr_moves": True}], "01/conflict": [resolve]}))
185
+ assert len(rig.calls("01", "conflict")) == 1 and phase(rig, "03") == "merged", rig.output()
186
+ assert on_pr(rig, T03) == LOCKED_03
187
+ if changes:
188
+ refused(rig, "01", T03)
189
+ assert on_pr(rig, "demo/cli.py") == cli("three") and not rig.calls("02")
190
+ else:
191
+ assert rig.code == 0, rig.output()
192
+ assert on_pr(rig, "demo/cli.py") == cli("resolved") and not rig.led("locked file changed")
193
+
194
+
195
+ # ---------------------------------------------------------------- the check and the ref move are one step
196
+
197
+ # The gate of test_walk. The gate of the first merge commit stays open until the call `hold` ended, and 1 s more.
198
+ HELD = GATE.replace('print("GATE-OUTPUT-MARK"', '''def ended():
199
+ calls = [json.loads(p.read_text()) for p in (rig / "calls").glob("*.json")]
200
+ return any(c["key"] == conf["hold"] and "t1" in c for c in calls)
201
+ merge = len(git("rev-list", "--parents", "-n1", "HEAD").split()) == 3
202
+ if conf.get("hold") and merge and not (rig / "sig/held").exists():
203
+ (rig / "sig/held").touch()
204
+ end = time.time() + 30
205
+ while not ended() and time.time() < end:
206
+ time.sleep(0.02)
207
+ time.sleep(1) # not an order: the time the other ticket gets to try its landing in the open window
208
+ rec["held"] = ended()
209
+ print("GATE-OUTPUT-MARK"''', 1)
210
+ assert HELD.count("sig/held") == 2
211
+
212
+
213
+ def landed(rig, sha):
214
+ """The place, in the first-parent history the walk made, of the first PR commit that holds `sha`."""
215
+ line = git(rig.repo, "rev-list", "--first-parent", "--reverse", rig.walked()).split()
216
+ return next(i for i, commit in enumerate(line) if holds(rig, sha, commit))
217
+
218
+
219
+ def test_a_ticket_that_lands_while_the_merge_gate_of_another_is_open_does_not_let_that_one_land_unchecked(tmp_path):
220
+ """03 weakens a file that 02 locks. 03 is at its merge gate (after any check on its merge commit, before the ref
221
+ move) when 02 passes its review and wants to land."""
222
+ rig = seeded(Review(tmp_path, three()))
223
+ (rig.rig / "gate").write_text(HELD)
224
+ rig.gate_conf(hold="02/reviewer")
225
+ weak = impl("03", files={"demo/cli.py": cli("three"), SHARED: WEAK_SEED})
226
+ rig.run(plan(**{"02/test-writer": [writer("02", SHARED, MORE_SEED)],
227
+ "03/implementer": [{**weak, "wait_pr_moves": True}], "03/fixer": NO_REPORT}))
228
+ assert [g for g in rig.gates() if g.get("held")], "the merge gate of 03 was open while 02 ended its review"
229
+ assert phase(rig, "02") == "merged" and "test_more" in on_pr(rig, SHARED), rig.output()
230
+ if phase(rig, "03") == "merged": # 03 landed first, when no merged ticket had locked the file
231
+ assert rig.code == 0, rig.output()
232
+ assert landed(rig, rig.sha("03", "implementer")) < landed(rig, rig.sha("02", "test-writer")), rig.ledger()
233
+ else: # 02 landed in the window: the driver looked again, or it gave the stale merge commit up
234
+ kept_out(rig, "03")
235
+ assert on_pr(rig, SHARED) == MORE_SEED
236
+
237
+
238
+ # ---------------------------------------------------------------- one driver for each repository
239
+
240
+ OTHER = {"04/test-writer": [tw("04")], "04/implementer": [impl("04", "demo/other.py")]}
241
+ PATIENT = HOOKED.replace("time.time() + 6", "time.time() + 60") # an agent that waits for the test, not for 6 s
242
+ assert PATIENT != HOOKED
243
+
244
+
245
+ class Other(Rig):
246
+ """A second worktree of the repository of `first`: its own PR branch feat/other, ticket 04, runner and gate."""
247
+
248
+ def __init__(self, first):
249
+ self.tmp = first.tmp.parent / "other"
250
+ self.tmp.mkdir()
251
+ self.env, self.repo, self.rig = make_env(self.tmp), self.tmp / "demo", self.tmp / "rig"
252
+ git(first.repo, "worktree", "add", "-q", "-b", "feat/other", str(self.repo), "feat/csv")
253
+ (self.repo / ".agent/tickets").mkdir(parents=True)
254
+ (self.repo / ".agent/tickets/04-other.md").write_text(ticket("04", "Other", touches="`demo/other.py: other`"))
255
+ self.rig.mkdir()
256
+ for name, text in (("run-agent", HOOKED.replace("feat/csv", "feat/other")), ("gate", GATE)):
257
+ (self.rig / name).write_text(text)
258
+ (self.rig / name).chmod(0o755)
259
+ self.models = models_file(self.tmp)
260
+ self.gate_conf()
261
+
262
+
263
+ def pair(tmp_path):
264
+ (tmp_path / "first").mkdir()
265
+ first = Review(tmp_path / "first", three())
266
+ (first.rig / "run-agent").write_text(PATIENT)
267
+ return first, Other(first)
268
+
269
+
270
+ def start(rig, steps, *extra):
271
+ """The driver of `rig` in the background: `Rig.run` returns only when the driver ended."""
272
+ (rig.rig / "plan.json").write_text(json.dumps(steps))
273
+ args = ["--tickets", ".agent/tickets", "--gate", str(rig.rig / "gate"), "--spec", "specs/csv/spec.md",
274
+ "--models", str(rig.models), "--runner", str(rig.rig / "run-agent"), "--parallel", "2", *extra]
275
+ return subprocess.Popen(["sh", str(RUN), *args], cwd=rig.repo, env=rig.env, text=True, stdout=subprocess.PIPE,
276
+ stderr=subprocess.STDOUT, start_new_session=True)
277
+
278
+
279
+ def ended(proc):
280
+ """No driver outlives its test."""
281
+ if proc.poll() is None:
282
+ os.killpg(proc.pid, signal.SIGKILL)
283
+ proc.communicate()
284
+
285
+
286
+ def until(done, seconds=30):
287
+ end = time.time() + seconds
288
+ while not done():
289
+ assert time.time() < end, "the first driver did not reach its agent"
290
+ time.sleep(0.02)
291
+
292
+
293
+ def world(*rigs):
294
+ """Every file of each worktree, and every ref and worktree HEAD of the one repository."""
295
+ return ([snapshot(r.repo) for r in rigs], git(rigs[0].repo, "for-each-ref"), git(rigs[0].repo, "worktree", "list"))
296
+
297
+
298
+ def no_lock_is_dirty(*rigs):
299
+ for rig in rigs:
300
+ status = git(rig.repo, "status", "--short")
301
+ assert "lock" not in status, status
302
+
303
+
304
+ def test_a_second_driver_in_another_worktree_exits_3_naming_the_lock_and_starts_when_the_first_ended(tmp_path):
305
+ first, second = pair(tmp_path)
306
+ held = {f"{nn}/test-writer": [{**tw(nn), "wait": ["go"]}] for nn in ("01", "03")}
307
+ proc = start(first, plan(**held))
308
+ try:
309
+ until(lambda: len(first.calls(role="test-writer")) == 2) # the first driver holds both its slots in a role
310
+ before = world(first, second)
311
+ second.run(OTHER)
312
+ assert second.code == 3, second.output()
313
+ (sentence,) = second.out.splitlines()
314
+ named = [word.rstrip(".,;:") for word in sentence.split() if "/" in word or word.rstrip(".,;:").endswith("lock")]
315
+ assert "lock" in sentence and any((second.repo / word).is_file() for word in named), sentence
316
+ assert world(first, second) == before and not second.calls()
317
+ no_lock_is_dirty(first, second)
318
+ (first.rig / "sig/go").touch()
319
+ out, _ = proc.communicate(timeout=120)
320
+ finally:
321
+ ended(proc)
322
+ assert proc.returncode == 0, out
323
+ second.run(OTHER)
324
+ assert second.code == 0 and second.calls("04", "implementer"), second.output()
325
+ no_lock_is_dirty(first, second)
326
+
327
+
328
+ ENDS = {
329
+ 0: (plan(), {}, ()),
330
+ 1: (plan(), {"red": ["ticket/01"]}, ()),
331
+ 2: (plan(**{"01/implementer": [contradicted()]}), {}, ()),
332
+ 3: (plan(), {}, ("--prepare", "false")), # slot 2 cannot be made: the run has started and holds the lock
333
+ 130: (plan(**{"01/test-writer": [{**tw("01"), "wait": ["never"]}]}), {}, ()),
334
+ }
335
+
336
+
337
+ @pytest.mark.parametrize("code", ENDS)
338
+ def test_the_lock_is_free_after_each_end_of_a_run_and_is_never_an_uncommitted_change(tmp_path, code):
339
+ first, second = pair(tmp_path)
340
+ steps, gate, extra = ENDS[code]
341
+ first.gate_conf(**gate)
342
+ proc = start(first, steps, *extra)
343
+ try:
344
+ if code == 130:
345
+ until(lambda: first.calls("01", "test-writer"))
346
+ no_lock_is_dirty(first, second)
347
+ proc.send_signal(signal.SIGINT)
348
+ out, _ = proc.communicate(timeout=120)
349
+ finally:
350
+ ended(proc)
351
+ assert proc.returncode == code, out
352
+ no_lock_is_dirty(first, second)
353
+ second.run(OTHER)
354
+ assert second.code == 0 and second.calls("04", "implementer"), second.output()
355
+ no_lock_is_dirty(first, second)
@@ -0,0 +1,144 @@
1
+ """Plumbing merges: fast-forward, merge commit, conflict, the guarded ref move and the guarded branch delete."""
2
+ import os
3
+ import subprocess
4
+
5
+ import driver_merge
6
+ from conftest import GIT
7
+
8
+
9
+ def sh(*command):
10
+ return subprocess.run(command, capture_output=True, text=True, check=True).stdout.strip()
11
+
12
+
13
+ def commit_on(branch, file, text, change=None):
14
+ """A commit on `branch`, made in a scratch worktree so the main one never moves. `change(path)` replaces the write."""
15
+ sh("git", "worktree", "add", "-q", "-f", "../scratch", branch)
16
+ if change:
17
+ change(f"../scratch/{file}")
18
+ else:
19
+ with open(f"../scratch/{file}", "w") as out:
20
+ out.write(text)
21
+ sh(*GIT, "-C", "../scratch", "add", "-A")
22
+ sh(*GIT, "-C", "../scratch", "commit", "-q", "-m", f"{branch} {file}")
23
+ sh("git", "worktree", "remove", "--force", "../scratch")
24
+ return sh("git", "rev-parse", branch)
25
+
26
+
27
+ def test_commit_is_the_tip_for_a_fast_forward_and_the_pr_tip_when_already_merged(repo):
28
+ repo()
29
+ base = sh("git", "rev-parse", "HEAD")
30
+ sh("git", "branch", "ticket/01")
31
+ tip = commit_on("ticket/01", "a.txt", "a\n")
32
+ assert driver_merge.commit(base, tip, "m") == (tip, "")
33
+ assert driver_merge.commit(tip, base, "m") == (tip, "")
34
+
35
+
36
+ def test_a_merge_commit_has_both_parents_and_touches_no_worktree(repo, monkeypatch):
37
+ repo()
38
+ sh("git", "branch", "ticket/01")
39
+ tip = commit_on("ticket/01", "a.txt", "a\n")
40
+ sh("git", "branch", "pr")
41
+ pr = commit_on("pr", "b.txt", "b\n")
42
+ merge, error = driver_merge.commit(pr, tip, "Merge ticket/01 into pr")
43
+ assert error == "" and sh("git", "rev-list", "--parents", "-n1", merge).split() == [merge, pr, tip]
44
+ assert sh("git", "ls-tree", "--name-only", merge).split() == [".gitignore", "a.txt", "b.txt"]
45
+ assert sh("git", "status", "--porcelain") == "" and sh("git", "symbolic-ref", "--short", "HEAD") == "feat/x"
46
+ # With no identity of its own, git gets the identity of the ticket's last commit.
47
+ monkeypatch.setenv("GIT_CONFIG_GLOBAL", "/dev/null")
48
+ monkeypatch.setenv("GIT_CONFIG_SYSTEM", "/dev/null")
49
+ monkeypatch.setenv("GIT_CONFIG_KEY_0", "user.useConfigOnly")
50
+ monkeypatch.setenv("GIT_CONFIG_VALUE_0", "true")
51
+ monkeypatch.setenv("GIT_CONFIG_COUNT", "1")
52
+ merge, error = driver_merge.commit(pr, tip, "m")
53
+ assert error == "" and sh("git", "log", "-1", "--format=%an <%ae> %cn", merge) == "t <t@example.com> t"
54
+
55
+
56
+ def test_a_conflict_names_its_files_and_makes_no_commit(repo):
57
+ repo()
58
+ sh("git", "branch", "ticket/01")
59
+ tip = commit_on("ticket/01", "a.txt", "one\n")
60
+ sh("git", "branch", "pr")
61
+ pr = commit_on("pr", "a.txt", "two\n")
62
+ assert driver_merge.commit(pr, tip, "m") == ("", "the merge into the PR branch conflicts in a.txt")
63
+ assert driver_merge.commit(pr, "1" * 40, "m")[0] == "" # no such commit
64
+
65
+
66
+ def test_land_moves_the_branch_only_from_the_tip_the_merge_was_made_on(repo):
67
+ repo()
68
+ base = sh("git", "rev-parse", "HEAD")
69
+ sh("git", "branch", "pr")
70
+ sh("git", "branch", "ticket/01")
71
+ tip = commit_on("ticket/01", "a.txt", "a\n")
72
+ moved = commit_on("pr", "b.txt", "b\n")
73
+ assert driver_merge.land("pr", tip, base) != "" and sh("git", "rev-parse", "pr") == moved
74
+ assert driver_merge.land("pr", tip, moved) == "" and sh("git", "rev-parse", "pr") == tip
75
+
76
+
77
+ def test_delete_keeps_a_branch_a_slot_has_checked_out_and_a_branch_that_is_not_merged(repo, tmp_path):
78
+ repo()
79
+ sh("git", "branch", "ticket/01")
80
+ slot = tmp_path / ".agent/worktrees/slot-2"
81
+ sh("git", "worktree", "add", "-q", str(slot), "ticket/01")
82
+ assert driver_merge.delete("ticket/01", "feat/x") is False
83
+ assert sh("git", "-C", str(slot), "symbolic-ref", "--short", "HEAD") == "ticket/01" # the slot is not detached
84
+ sh("git", "-C", str(slot), "checkout", "-q", "--detach")
85
+ sh("git", "branch", "ticket/02")
86
+ commit_on("ticket/02", "a.txt", "a\n")
87
+ assert driver_merge.delete("ticket/02", "feat/x") is False and driver_merge.delete("ticket/01", "feat/x") is True
88
+ assert sh("git", "branch", "--format=%(refname:short)").split() == ["feat/x", "ticket/02"]
89
+
90
+
91
+ def test_the_landing_check_refuses_what_it_cannot_read(repo, monkeypatch):
92
+ """Ticket 09: the check fails closed. Only a path that a commit which exists does not have is "absent"."""
93
+ repo()
94
+ base = sh("git", "rev-parse", "HEAD")
95
+ sh("git", "branch", "pr")
96
+ sh("git", "branch", "ticket/02")
97
+ same = commit_on("ticket/02", "a.txt", "a\n")
98
+ tickets = {"01": {"phase": "merged", "locked": ["t.py", "gone.py"]}, "02": {"phase": "merge", "locked": []}}
99
+ assert driver_merge.refused(tickets, "02", base, same) == "" # absent on both sides of two commits that exist
100
+ there = commit_on("ticket/02", "t.py", "t\n")
101
+ assert driver_merge.refused(tickets, "02", base, there) == "locked file changed: t.py"
102
+ assert driver_merge.refused(tickets, "02", there, base) == "locked file changed: t.py"
103
+ # A candidate that is no commit: an id that does not resolve, and a tree.
104
+ for bad in ("1" * 40, sh("git", "rev-parse", f"{base}^{{tree}}")):
105
+ assert driver_merge.refused(tickets, "02", base, bad).startswith(f"cannot read the locked files at {bad}: ")
106
+ assert driver_merge.refused(tickets, "02", bad, base).startswith(f"cannot read the locked files at {bad}: ")
107
+ # A record with no `locked` key is a ticket that locked nothing: no error in the middle of a landing.
108
+ assert driver_merge.protected({"01": {"phase": "merged"}, "02": {"phase": "merge"}}, "02") == []
109
+ assert driver_merge.protected({"01": {"phase": "merged", "locked": ["a"]}}, "02") == ["a"]
110
+ # git fails on both sides: the two answers are equal, and the landing is refused all the same.
111
+ monkeypatch.setenv("GIT_DIR", "/nowhere")
112
+ assert driver_merge.refused(tickets, "02", base, there).startswith(f"cannot read the locked files at {base}: ")
113
+ monkeypatch.delenv("GIT_DIR")
114
+ assert driver_merge.land("pr", there, "1" * 40) != "" and sh("git", "rev-parse", "pr") == base
115
+
116
+
117
+ def test_the_landing_check_compares_the_mode_and_the_type_of_a_locked_file_too(repo):
118
+ """Review round 1, Codex 4: a symlink whose target is the old text has the blob id of the file it replaced."""
119
+ repo()
120
+ sh("git", "branch", "ticket/01")
121
+ file = commit_on("ticket/01", "t.py", "t\n")
122
+ tickets = {"01": {"phase": "merged", "locked": ["t.py"]}, "02": {"phase": "merge", "locked": []}}
123
+ sh("git", "branch", "ticket/02", file)
124
+ runs = commit_on("ticket/02", "t.py", "", lambda path: os.chmod(path, 0o755))
125
+ link = commit_on("ticket/02", "t.py", "", lambda path: (os.remove(path), os.symlink("t\n", path)))
126
+ blob = lambda commit: sh("git", "rev-parse", f"{commit}:t.py")
127
+ assert blob(file) == blob(runs) == blob(link) and sh("git", "ls-tree", link, "t.py").startswith("120000 ")
128
+ assert driver_merge.refused(tickets, "02", file, file) == ""
129
+ assert driver_merge.refused(tickets, "02", file, runs) == "locked file changed: t.py"
130
+ assert driver_merge.refused(tickets, "02", file, link) == "locked file changed: t.py"
131
+
132
+
133
+ def test_begin_opens_the_conflicting_merge_on_a_machine_with_no_git_identity(repo, monkeypatch, tmp_path):
134
+ repo()
135
+ sh("git", "branch", "ticket/01")
136
+ commit_on("ticket/01", "a.txt", "ticket\n")
137
+ sh("git", "branch", "pr")
138
+ pr_tip = commit_on("pr", "a.txt", "pr\n")
139
+ sh("git", "checkout", "-q", "ticket/01")
140
+ (tmp_path / "noident").write_text("[user]\n\tuseConfigOnly = true\n") # git may not guess a name, as on CI
141
+ monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(tmp_path / "noident"))
142
+ monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1")
143
+ monkeypatch.delenv("GIT_CONFIG_COUNT")
144
+ assert driver_merge.begin(".", pr_tip) == ["a.txt"]
@@ -0,0 +1,47 @@
1
+ """Ticket 07: models.json through the driver and in content; README against the help text of run.sh."""
2
+ import json
3
+ import re
4
+ import subprocess
5
+
6
+ import pytest
7
+
8
+ from test_cli import RUN, SKILL, drive, env, make_repo, three # noqa: F401 (env is a fixture)
9
+
10
+ STANDARD = {
11
+ "implementer": ["claude-sonnet-5@medium", "openai/gpt-sol@medium"],
12
+ "test-writer": ["claude-sonnet-5@medium", "openai/gpt-sol@medium"],
13
+ "reviewer": ["openai/gpt-astra@medium", "claude-opus-5@medium"],
14
+ "final-reviewer": ["openai/gpt-astra@medium", "claude-opus-5@medium"],
15
+ }
16
+ ENTRY = re.compile(r"^(?:(?:anthropic|openai)/)?[\w.-]+@(?:minimal|low|medium|high|xhigh|max)$")
17
+
18
+
19
+ def models():
20
+ return json.loads((SKILL / "models.json").read_text())
21
+
22
+
23
+ def test_models_json_loads_through_the_driver(env, tmp_path):
24
+ repo = make_repo(tmp_path / "demo", three())
25
+ proc = drive(env, repo) # no --models: the skill's own file; fake claude and codex on PATH
26
+ assert proc.returncode == 0, proc.stdout + proc.stderr
27
+ assert "max frontier width" in proc.stdout
28
+
29
+
30
+ def test_every_role_has_an_ordered_list_of_provider_model_effort_entries():
31
+ for role, entries in models().items():
32
+ assert isinstance(entries, list) and entries, role
33
+ assert all(ENTRY.match(entry) for entry in entries), (role, entries)
34
+ assert {"implementer", "test-writer", "reviewer", "final-reviewer"} <= models().keys()
35
+
36
+
37
+ @pytest.mark.parametrize("role", STANDARD)
38
+ def test_the_standard_track_in_order(role):
39
+ assert models()[role] == STANDARD[role]
40
+
41
+
42
+ def test_readme_documents_every_flag_of_the_help():
43
+ help_text = subprocess.run(["sh", str(RUN), "--help"], capture_output=True, text=True, timeout=60).stdout
44
+ flags = sorted(set(re.findall(r"--[a-z][a-z-]*", help_text)))
45
+ assert len(flags) >= 11, flags
46
+ readme = (SKILL / "README.md").read_text()
47
+ assert [flag for flag in flags if flag not in readme] == []