@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,580 @@
1
+ #!/usr/bin/env python3
2
+ """implement-spec driver: validate the ticket set and the environment, then walk the ticket graph.
3
+
4
+ sh run.sh --gate COMMAND --spec FILE [--tickets DIR] [--models FILE] [--parallel N] [--prepare COMMAND]
5
+ [--timeout ROLE=MINUTES] [--answer QUESTION=OPTION] [--unattended [--stage-json FILE]] [--push]
6
+ [--dry-run]
7
+
8
+ Run it in the PR worktree. Exit 0 = every ticket merged (and pushed, when unattended), 1 = a ticket or the end of
9
+ run is BLOCKED, 2 = parked on a question, 3 = usage or validation error. README.md has the method and the files of
10
+ a run; ARCHITECTURE.md has the map of the modules and the rules the code keeps.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import sys
18
+ from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ import driver_agents
23
+ import driver_answers
24
+ import driver_decisions
25
+ import driver_final
26
+ import driver_fixloop
27
+ import driver_graph
28
+ import driver_merge
29
+ import driver_review
30
+ import driver_review_page
31
+ import driver_slots
32
+ import driver_state
33
+ import driver_summary
34
+ import driver_tickets
35
+ from driver_run import FINAL, OUT, Blocked, Run, during, ledger
36
+ from driver_slots import git
37
+ from driver_tickets import Ticket
38
+
39
+ STATE = OUT / "state.json"
40
+ DECISIONS = OUT / "decisions.json"
41
+ PAGE = Path(".agent/implement-review.html")
42
+ ONLY = ("test-writer", "implementer", "reviewer", "correctness") # `--only` is for tests
43
+ # The phase of a ticket while a role runs, and the phase it reaches when the role's report is accepted.
44
+ DURING = {"test-writer": "test-writer", "implementer": "implement"}
45
+ AFTER = {"test-writer": "implement", "implementer": "gate"}
46
+
47
+
48
+ # ---------------------------------------------------------------- one ticket: the roles
49
+
50
+ def _roles_left(ticket: Ticket, phase: str, only: str | None) -> list[str]:
51
+ if only:
52
+ return [only]
53
+ if phase == "implement":
54
+ return ["implementer"]
55
+ if phase not in ("pending", "test-writer"):
56
+ return []
57
+ return ["implementer"] if ticket.refactor else ["test-writer", "implementer"]
58
+
59
+
60
+ def _check_out_ticket(ticket: Ticket, slot_number: int, entry: dict, run: Run) -> str:
61
+ """Put the ticket branch in the slot and return the commit its next role starts from."""
62
+ slot, branch = driver_slots.path(slot_number), f"ticket/{ticket.number}"
63
+ if run.kept.get(ticket.number) == slot_number:
64
+ # The slot holds the uncommitted work of an interrupted role: the branch and the tree stay as they are.
65
+ return entry["tip"] or git(slot, "merge-base", "HEAD", run.tip()).stdout.strip()
66
+ made = git(slot, "checkout", "-q", "-B", branch, entry["tip"] or run.tip())
67
+ if made.returncode:
68
+ raise Blocked(f"cannot check out {branch} in slot {slot_number}: {made.stderr.strip()}")
69
+ return driver_slots.head(slot)
70
+
71
+
72
+ def _prompt_values(ticket: Ticket, slot: str, run: Run) -> dict[str, str]:
73
+ """The pointers every prompt of the ticket gets. An agent works in its slot, so paths are relative to it."""
74
+ folder = OUT / ticket.number
75
+ return {"spec": os.path.relpath(run.args.spec, slot), "seam": ticket.seam, "pr_branch": run.pr,
76
+ "branch": f"ticket/{ticket.number}", "ticket": os.path.relpath(Path(run.args.tickets) / ticket.file, slot),
77
+ "correctness": str(driver_review.CORRECTNESS),
78
+ "blocked_report": os.path.relpath(folder / "implementer-0.json", slot),
79
+ "writer_report": os.path.relpath(folder / "test-writer-0.json", slot)}
80
+
81
+
82
+ def _run_roles(ticket: Ticket, slot_number: int, run: Run) -> None:
83
+ """Run the test-writer and the implementer, as far as the phase of the ticket still needs them."""
84
+ number, slot = ticket.number, driver_slots.path(slot_number)
85
+ entry = run.state.ticket(number, slot=slot_number)
86
+ roles = _roles_left(ticket, entry["phase"], run.args.only)
87
+ with during("the start"):
88
+ start = _check_out_ticket(ticket, slot_number, entry, run)
89
+ # The first role gets one more template: the ruling that sent the ticket back, or that its work was interrupted.
90
+ if entry["note"]:
91
+ notes = [entry["note"]]
92
+ elif run.kept.get(number) == slot_number:
93
+ notes = ["interrupted"]
94
+ ledger(f"Ticket {number}: resumed in slot {slot_number} with the work the interrupted {roles[0]} left.")
95
+ else:
96
+ notes = []
97
+ values = {**_prompt_values(ticket, slot, run), "locked": " ".join(entry["locked"])}
98
+ for role in roles:
99
+ run.state.ticket(number, phase=DURING.get(role, "review"))
100
+ values["start_sha"] = start
101
+ # A test-writer on a ticket that has locked tests is the correction after a ruling.
102
+ check = {"correction": role == "test-writer" and bool(entry["test_writer_sha"])}
103
+ if check["correction"]:
104
+ check["restored"] = run.restore_locked_files(number, slot)
105
+ with during(f"the {role}"):
106
+ report = run.agent(role, number, slot, values, notes=notes, check=check)
107
+ facts = {"tip": driver_slots.head(slot)}
108
+ if role == "test-writer":
109
+ facts.update(driver_review.written(slot, entry, report["locked"], facts["tip"]))
110
+ values["locked"] = " ".join(facts["locked"])
111
+ run.state.ticket(number, phase=AFTER.get(role, "review"), note="", **facts)
112
+ start, notes = facts["tip"], []
113
+
114
+
115
+ # ---------------------------------------------------------------- one ticket: the landing on the PR branch
116
+
117
+ @dataclass
118
+ class _Landing:
119
+ ticket_tip: str
120
+ pr_tip: str # the PR tip that the landing check read
121
+ commit: str # what the PR branch moves to: `pr_tip` (merged already), `ticket_tip` (fast-forward) or a merge
122
+
123
+
124
+ def _resolve_conflict(ticket: Ticket, slot: str, run: Run, tip: str, files: list[str]) -> str:
125
+ """Run the conflict agent on the merge that is open in the slot. Returns the new ticket tip."""
126
+ number = ticket.number
127
+ values = {**_prompt_values(ticket, slot, run), "start_sha": tip, "pr_sha": run.tip(), "files": " ".join(files)}
128
+ try:
129
+ with during("the conflict agent"):
130
+ run.agent("conflict", number, slot, values)
131
+ # The resolution may not change a locked test of the ticket. Those of the merged tickets are checked
132
+ # when the resolved tip lands.
133
+ changed = driver_review.locked_changes(slot, tip, run.state.read()["tickets"][number]["locked"])
134
+ if changed:
135
+ raise Blocked("; ".join(finding["title"] for finding in changed))
136
+ except Blocked:
137
+ driver_merge.abort(slot) # closes the merge when the agent did not commit it
138
+ raise
139
+ resolved = driver_slots.head(slot)
140
+ run.state.ticket(number, tip=resolved)
141
+ ledger(f"Ticket {number}: the conflict agent resolved the merge conflict in {', '.join(files)} at {resolved[:7]}.")
142
+ return resolved
143
+
144
+
145
+ def _merge_commit(ticket: Ticket, slot: str, run: Run, tip: str, may_resolve: bool) -> _Landing:
146
+ """Merge the ticket tip with the PR tip into a commit that no ref holds yet."""
147
+ message = f"Merge ticket/{ticket.number} into {run.pr}"
148
+ pr_tip = run.tip()
149
+ commit, error = driver_merge.commit(pr_tip, tip, message)
150
+ if not error:
151
+ return _Landing(tip, pr_tip, commit)
152
+ conflicts = driver_merge.begin(slot, pr_tip) if may_resolve else []
153
+ if not conflicts:
154
+ raise Blocked(error)
155
+ tip = _resolve_conflict(ticket, slot, run, tip, conflicts)
156
+ pr_tip = run.tip()
157
+ commit, error = driver_merge.commit(pr_tip, tip, message)
158
+ if error:
159
+ raise Blocked(f"after the conflict agent, {error}")
160
+ return _Landing(tip, pr_tip, commit)
161
+
162
+
163
+ def _checked_landing(ticket: Ticket, slot: str, run: Run, may_resolve: bool) -> _Landing:
164
+ """The commit to land, with the locked-file check passed and the gate green. Call it under the merge lock."""
165
+ number = ticket.number
166
+ git(slot, "checkout", "-q", f"ticket/{number}") # an earlier try left the slot on its merge commit
167
+ tip = run.state.ticket(number, phase="merge")["tip"]
168
+ landing = _merge_commit(ticket, slot, run, tip, may_resolve)
169
+ refusal = driver_merge.refused(run.state.read()["tickets"], number, landing.pr_tip, landing.commit)
170
+ if refusal:
171
+ raise Blocked(refusal)
172
+ if landing.commit != landing.pr_tip:
173
+ # No gate has seen a merge commit. A fast-forward to a gated tip finds its green record.
174
+ # When the checkout fails, the slot is on another commit and the gate refuses to run.
175
+ git(slot, "checkout", "-q", "--detach", landing.commit)
176
+ run.gate(number, slot, landing.commit)
177
+ return landing
178
+
179
+
180
+ def _land(ticket: Ticket, slot: str, run: Run) -> _Landing:
181
+ """Move the PR branch to the checked commit. Call it under the merge lock."""
182
+ landing = _checked_landing(ticket, slot, run, may_resolve=True)
183
+ # git moves the ref only from the tip that the check read, so the check and the move are one step.
184
+ error = driver_merge.land(run.pr, landing.commit, landing.pr_tip)
185
+ if error and run.tip() != landing.pr_tip:
186
+ # Another hand moved the PR branch in between: start again from the new tip, one time, with no conflict agent.
187
+ landing = _checked_landing(ticket, slot, run, may_resolve=False)
188
+ error = driver_merge.land(run.pr, landing.commit, landing.pr_tip)
189
+ if error:
190
+ raise Blocked(error)
191
+ return landing
192
+
193
+
194
+ def _record_landing(state: dict, number: str, pr_tip: str) -> None:
195
+ """The ticket is merged and `pr_tip` is the PR tip its merge made. A later merge of the ticket replaces it."""
196
+ state["tickets"][number]["phase"] = "merged"
197
+ earlier = [landed for landed in state["run"]["landed"] if landed[0] != number]
198
+ state["run"]["landed"] = earlier + [[number, pr_tip]]
199
+
200
+
201
+ def _merge(ticket: Ticket, slot: str, run: Run) -> None:
202
+ """Land the reviewed ticket tip on the PR branch, then delete the ticket branch."""
203
+ number, branch = ticket.number, f"ticket/{ticket.number}"
204
+ # The merge gate and the conflict agent run under the lock: the commit the gate saw is the one that lands.
205
+ with run.merging, during("the merge"):
206
+ landing = _land(ticket, slot, run)
207
+ run.state.update(lambda state: _record_landing(state, number, landing.commit))
208
+ driver_slots.release(slot) # git deletes no branch that a worktree has checked out
209
+ kept = "" if driver_merge.delete(branch, run.pr) else f"; {branch} is kept"
210
+ if landing.commit == landing.pr_tip:
211
+ how = "a merge an earlier run made"
212
+ elif landing.commit == landing.ticket_tip:
213
+ how = "a fast-forward"
214
+ else:
215
+ how = "a merge commit"
216
+ ledger(f"Ticket {number}: merged into {run.pr} as {how} at {landing.commit[:7]}{kept}.")
217
+
218
+
219
+ # ---------------------------------------------------------------- one ticket, and the walk of the graph
220
+
221
+ def _block(number: str, run: Run, ledger_line: int) -> None:
222
+ """Block the ticket and add the questions it asked for, in one write of state.json."""
223
+ ask = run.asks.pop(number, None)
224
+
225
+ def change(state: dict) -> None:
226
+ state["tickets"][number]["phase"] = "blocked"
227
+ if ask:
228
+ state["tickets"][number].update(ask.fields)
229
+ evidence = [ask.report, f"{OUT / 'ledger.md'}:{ledger_line}"]
230
+ driver_decisions.stop(state["run"], ask.kind, number, ask.subjects, evidence)
231
+
232
+ run.state.update(change)
233
+ if ask and ask.subjects:
234
+ ledger(f"Ticket {number}: {driver_decisions.decides(ask.kind, len(ask.subjects))} in {DECISIONS}.")
235
+
236
+
237
+ def _run_ticket(ticket: Ticket, slot_number: int, run: Run) -> bool:
238
+ """Take one ticket from its recorded phase to merged or blocked. True: the slot is free again."""
239
+ number, slot = ticket.number, driver_slots.path(slot_number)
240
+ try:
241
+ _run_roles(ticket, slot_number, run)
242
+ if not run.args.only:
243
+ # A ticket that an earlier run left in `merge` has passed its review.
244
+ if run.state.read()["tickets"][number]["phase"] != "merge":
245
+ driver_fixloop.run(ticket, slot, run, _prompt_values(ticket, slot, run))
246
+ _merge(ticket, slot, run)
247
+ except Blocked as blocked:
248
+ _block(number, run, ledger(f"Ticket {number}: BLOCKED at {blocked}."))
249
+ print(f"{ticket.file}: BLOCKED at {blocked}")
250
+ return driver_slots.release(slot) # False: the slot keeps the uncommitted work of the blocked ticket
251
+
252
+
253
+ def _next_ticket(tickets: list[Ticket], running: dict, told: set, run: Run, kept: dict) -> Ticket | None:
254
+ """The first ticket that may start now. A ticket whose slot is kept for it goes first: it needs no free slot."""
255
+ phases = {number: entry["phase"] for number, entry in run.state.read()["tickets"].items()}
256
+ ready, holds = driver_graph.frontier(tickets, phases, {number for number, _ in running.values()})
257
+ for hold in sorted(set(holds.items()) - told):
258
+ told.add(hold)
259
+ ledger(f"Ticket {hold[0]}: {hold[1]}.")
260
+ if not ready:
261
+ return None
262
+ return next((ticket for ticket in ready if ticket.number in kept), ready[0])
263
+
264
+
265
+ def _take_slot(number: str, kept: dict, free: list[int]) -> int:
266
+ if number in kept:
267
+ return kept.pop(number)
268
+ slot = min(free)
269
+ free.remove(slot)
270
+ return slot
271
+
272
+
273
+ def _walk(tickets: list[Ticket], slots: int, run: Run, pool: ThreadPoolExecutor) -> None:
274
+ """Start each ready ticket in the lowest free slot, and look again each time a ticket ends."""
275
+ kept, told = dict(run.kept), set() # a kept slot waits for its ticket
276
+ free = [slot for slot in range(1, slots + 1) if slot not in kept.values()]
277
+ running: dict = {} # future -> (ticket number, slot number)
278
+
279
+ def start_ready() -> None:
280
+ while (ticket := _next_ticket(tickets, running, told, run, kept)) and (ticket.number in kept or free):
281
+ slot = _take_slot(ticket.number, kept, free)
282
+ running[pool.submit(_run_ticket, ticket, slot, run)] = (ticket.number, slot)
283
+
284
+ while True:
285
+ start_ready()
286
+ if not running:
287
+ return
288
+ for future in wait(running, return_when=FIRST_COMPLETED).done:
289
+ _, slot = running.pop(future)
290
+ if future.result():
291
+ free.append(slot)
292
+
293
+
294
+ def _run_tickets(tickets: list[Ticket], slots: int, run: Run) -> None:
295
+ """One thread per ticket in flight; this thread only schedules."""
296
+ with ThreadPoolExecutor(slots) as pool:
297
+ try:
298
+ _walk(tickets, slots, run, pool)
299
+ except KeyboardInterrupt:
300
+ # A hard exit: the ticket threads would run their agents to the end, and state.json writes are atomic.
301
+ driver_agents.stop()
302
+ ledger("The run was interrupted; the work of the stopped agents stays in their slots for the next run.")
303
+ print("implement-spec: interrupted; run it again to resume from state.json", flush=True)
304
+ os._exit(130)
305
+
306
+
307
+ def _work(tickets: list[Ticket], slots: int, run: Run) -> tuple[bool, list[str]]:
308
+ """The tickets, then the end of run: (the end of run is BLOCKED, the lines it adds to the summary)."""
309
+ only = run.args.only
310
+ # No slot has the PR branch checked out while tickets run: a merge moves its ref and no tree changes under it.
311
+ if 1 not in run.kept.values():
312
+ git(".", "checkout", "-q", "--detach")
313
+ try:
314
+ if only:
315
+ _run_ticket(tickets[0], 1, run)
316
+ else:
317
+ _run_tickets(tickets, slots, run)
318
+ finally:
319
+ if driver_slots.release("."): # slot 1 is clean, so it goes back to the PR branch
320
+ git(".", "checkout", "-q", run.pr)
321
+ # Every ticket thread has ended, so every slot is idle: only now may the end of run rewrite the PR branch.
322
+ return (False, []) if only else driver_final.run(run, tickets)
323
+
324
+
325
+ # ---------------------------------------------------------------- before the first agent: resume and answers
326
+
327
+ def _pr_branch(state: driver_state.State) -> tuple[str, str]:
328
+ """(the PR branch, "") or ("", why the run cannot start)."""
329
+ here = git(".", "symbolic-ref", "-q", "--short", "HEAD").stdout.strip()
330
+ # An interrupted run leaves slot 1 on a ticket branch or detached, so the record names the PR branch.
331
+ pr = state.read()["run"]["pr_branch"] if state.path.exists() else here
332
+ if not pr:
333
+ return "", "the PR worktree is on a detached HEAD: check out the PR branch"
334
+ if here not in ("", pr) and not here.startswith("ticket/"):
335
+ return "", f"{state.path} records a run on {pr}, not on {here}: check out {pr}, or remove {OUT}"
336
+ return pr, ""
337
+
338
+
339
+ def _resume_error(number: str, state: driver_state.State) -> str:
340
+ """Check the record of one ticket against git. A ticket that git contradicts goes back to its last verified
341
+ phase; "" or the error that stops the run."""
342
+ entry, branch = state.read()["tickets"][number], f"ticket/{number}"
343
+ if entry["phase"] not in driver_state.PHASES:
344
+ return f"{state.path}: ticket {number} has the unknown phase {entry['phase']!r}"
345
+ if entry["phase"] == "pending" and git(".", "rev-parse", "-q", "--verify", f"refs/heads/{branch}").returncode == 0:
346
+ return f"branch {branch} exists and {state.path} does not record it: delete or rename the branch"
347
+ fields, why = driver_state.verified(entry, branch, state.read()["run"]["pr_branch"])
348
+ if fields:
349
+ state.ticket(number, **fields)
350
+ start = fields["tip"][:7] if fields["tip"] else "the PR tip"
351
+ ledger(f"Ticket {number}: returned to {fields['phase']} from {start}: {why}.")
352
+ return ""
353
+
354
+
355
+ def _resume(tickets: list[Ticket], state: driver_state.State, slots: int, args, kept: dict) -> tuple[str, list[str]]:
356
+ """Start or resume state.json and make the slots: (the PR branch, the errors that stop the run)."""
357
+ pr, error = _pr_branch(state)
358
+ if error:
359
+ return "", [error]
360
+ state.start(pr, [ticket.number for ticket in tickets])
361
+ driver_final.finish_interrupted_move(state, pr)
362
+ errors = [error for error in (_resume_error(ticket.number, state) for ticket in tickets) if error]
363
+ return pr, errors or driver_slots.setup(slots, args.prepare, set(kept.values()))
364
+
365
+
366
+ def _publish_questions(run: dict) -> str:
367
+ """Write decisions.json and the review page for the questions of `run` of state.json; "" or the error."""
368
+ if not run["questions"]:
369
+ return ""
370
+ document = driver_decisions.document(run)
371
+ DECISIONS.write_text(json.dumps(document, indent=1) + "\n")
372
+ PAGE.unlink(missing_ok=True)
373
+ try:
374
+ if document["questions"]:
375
+ PAGE.write_text(driver_review_page.render(document))
376
+ except (OSError, ValueError) as exc:
377
+ return (f"implement-spec: cannot write {PAGE} for the open questions of {DECISIONS}: {exc}; install the "
378
+ "review-page skill next to implement-spec")
379
+ return ""
380
+
381
+
382
+ def _apply_answers(state: driver_state.State, args) -> list[str]:
383
+ """Turn each answer that counts into a ruling and let its ticket go on; [] or the errors that stop the run."""
384
+ run = state.read()["run"]
385
+ questions, document = run["questions"], driver_decisions.document(run)
386
+ if errors := driver_answers.given(args.answer, document):
387
+ return errors
388
+ answers, ignored = driver_answers.read(document)
389
+ rulings, moved = driver_decisions.apply(questions, answers)
390
+ # The ledger comes first: a run that stops between the two writes gives a ruling twice, and never an answer
391
+ # that no ledger line names.
392
+ for sentence in ignored + rulings:
393
+ ledger(sentence)
394
+
395
+ def change(new: dict) -> None:
396
+ new["run"]["questions"] = questions
397
+ for number, fields in moved.items():
398
+ new["tickets"][number].update(fields)
399
+
400
+ state.update(change)
401
+ error = _publish_questions(run)
402
+ return [error] if error else []
403
+
404
+
405
+ # ---------------------------------------------------------------- the end of the process
406
+
407
+ def _write_stage(path: str, code: int, open_questions: int, blocked: list[str]) -> None:
408
+ """stage.json tells the dispatcher of an unattended run how the run ended."""
409
+ if code == 2:
410
+ body = {"status": "awaiting-review", "artifact": str(PAGE), "open_questions": open_questions}
411
+ elif code == 0:
412
+ body = {"status": "done"}
413
+ else:
414
+ body = {"status": "blocked", "blocked": blocked}
415
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
416
+ Path(path).write_text(json.dumps({"stage": "implement", **body}, indent=1) + "\n")
417
+
418
+
419
+ def _tell(args, code: int, open_questions: list[dict], blocked: list[str], pushed: bool) -> None:
420
+ """Print the open questions for the operator, or write stage.json for the dispatcher of an unattended run."""
421
+ if not args.unattended:
422
+ print(driver_decisions.text(open_questions, str(driver_answers.FILE), str(PAGE)), end="")
423
+ elif args.stage_json and (code or pushed):
424
+ # Exit 0 with nothing pushed is not `done` for the dispatcher: it gets no file.
425
+ _write_stage(args.stage_json, code, len(open_questions), blocked)
426
+
427
+
428
+ def _exit_code(final: dict, args, end_blocked: bool) -> int:
429
+ """Publish the open questions, tell the operator or the dispatcher, and return the exit code."""
430
+ if error := _publish_questions(final["run"]):
431
+ print(error)
432
+ return 3
433
+ blocked = [number for number, entry in final["tickets"].items() if entry["phase"] == "blocked"]
434
+ if end_blocked:
435
+ blocked.append(FINAL)
436
+ open_questions = driver_decisions.document(final["run"])["questions"]
437
+ code = 2 if driver_decisions.parked(final["run"]["questions"]) else int(bool(blocked))
438
+ _tell(args, code, open_questions, blocked, bool(final["run"]["final"]["pushed"]))
439
+ return code
440
+
441
+
442
+ def _run(tickets: list[Ticket], models: dict[str, list[str]], args, kept: dict[str, int]) -> int:
443
+ state = driver_state.State(STATE)
444
+ slots = 1 if args.only else min(args.parallel, len(tickets))
445
+ pr, errors = _resume(tickets, state, slots, args, kept)
446
+ errors = errors or _apply_answers(state, args)
447
+ for error in errors:
448
+ print(error)
449
+ if errors:
450
+ return 3
451
+ run = Run(args, models, state, pr, kept)
452
+ if args.unattended and args.stage_json:
453
+ Path(args.stage_json).unlink(missing_ok=True) # the file tells how this run ends, never an earlier one
454
+ end_blocked, lines = _work(tickets, slots, run)
455
+ final = state.read()
456
+ summary = driver_summary.render(final)
457
+ (OUT / "summary.md").write_text(summary)
458
+ print(summary + "".join(line + "\n" for line in lines), end="")
459
+ return _exit_code(final, args, end_blocked)
460
+
461
+
462
+ # ---------------------------------------------------------------- the command line
463
+
464
+ class _Parser(argparse.ArgumentParser):
465
+ def error(self, message: str):
466
+ print(f"implement-spec: {message}")
467
+ raise SystemExit(3)
468
+
469
+
470
+ def _timeout(text: str) -> tuple[str, float]:
471
+ role, _, minutes = text.partition("=")
472
+ if role not in driver_agents.TIMEOUTS:
473
+ raise ValueError(role)
474
+ return role, float(minutes)
475
+
476
+
477
+ def _arguments(argv: list[str] | None) -> argparse.Namespace:
478
+ defaults = ", ".join(f"{role} {minutes}" for role, minutes in driver_agents.TIMEOUTS.items())
479
+ parser = _Parser(prog="implement-spec", description="Validate tickets and preconditions, then walk the graph.")
480
+ parser.add_argument("--tickets", default=".agent/tickets", help="directory of NN-name.md ticket files")
481
+ parser.add_argument("--gate", help="the repo's check command (required); the driver runs it on each commit it "
482
+ "is about to trust, with IMPLEMENT_SPEC_BASE set to the PR branch")
483
+ parser.add_argument("--spec", help="the spec file the prompts point at (required without --dry-run)")
484
+ parser.add_argument("--models", help="JSON file: one ordered list of provider/model@effort per role, "
485
+ "on top of the skill's models.json")
486
+ parser.add_argument("--timeout", action="append", type=_timeout, default=[], metavar="ROLE=MINUTES",
487
+ help=f"timeout of one role, repeatable; doubled on the one rerun (defaults: {defaults})")
488
+ parser.add_argument("--dry-run", action="store_true", help="validate, print the frontier width, change nothing")
489
+ parser.add_argument("--runner", help="test only: a script called as `<script> <role> <prompt> <report>` "
490
+ "in the slot, in place of the CLI")
491
+ parser.add_argument("--parallel", type=int, default=2, help="tickets in flight at once (default 2)")
492
+ parser.add_argument("--prepare", help="shell command run once in each new slot 2..N, in "
493
+ ".agent/worktrees/slot-K/ (for example: link the installed dependencies)")
494
+ parser.add_argument("--answer", action="append", type=driver_answers.pair, default=[],
495
+ metavar="QUESTION=OPTION", help="the answer to one open question of "
496
+ f"{DECISIONS}, repeatable; it goes to {driver_answers.FILE}")
497
+ parser.add_argument("--unattended", action="store_true", help="at a question, print no question: write "
498
+ "--stage-json for the dispatcher, which gives the answers file to the next run")
499
+ parser.add_argument("--stage-json", metavar="FILE", help="with --unattended: where the driver writes how a "
500
+ "run ended that is parked (exit 2) or has a BLOCKED ticket (exit 1)")
501
+ parser.add_argument("--push", action="store_true", help="local mode: when the final review has passed, push "
502
+ "the PR branch to origin and open the PR; without it nothing is pushed")
503
+ parser.add_argument("--only", choices=ONLY, help="test only: run this one role on the first ticket")
504
+ args = parser.parse_args(argv)
505
+ args.argv = list(sys.argv[1:] if argv is None else argv) # for the rerun command the driver prints
506
+ args.timeout = {**driver_agents.TIMEOUTS, **dict(args.timeout)}
507
+ return args
508
+
509
+
510
+ def _worktree_errors(slot_1_is_kept: bool) -> list[str]:
511
+ errors = []
512
+ # The trailing slash lets a `.agent/` pattern match before the directory exists.
513
+ if git(".", "check-ignore", "-q", ".agent/").returncode != 0:
514
+ errors.append(".agent is not gitignored")
515
+ status = git(".", "status", "--porcelain")
516
+ if status.returncode != 0:
517
+ errors.append(f"git status failed: {status.stderr.strip()}")
518
+ if not slot_1_is_kept: # a kept slot 1 holds the uncommitted work of a ticket that runs there again
519
+ errors += [f"uncommitted change: {line[3:]}" for line in status.stdout.splitlines()]
520
+ return errors
521
+
522
+
523
+ def _validated(args: argparse.Namespace) -> tuple[list[Ticket], dict[str, list[str]], dict[str, int]] | None:
524
+ """(the tickets, the models, the kept slots) of a valid call, or None with each error printed."""
525
+ errors = [] if args.gate else ["--gate is required: the command the driver runs as the gate"]
526
+ if not (args.spec or args.dry_run):
527
+ errors.append("--spec is required: the spec file the prompts point at")
528
+ if args.parallel < 1:
529
+ errors.append("--parallel must be 1 or more")
530
+ kept = driver_state.kept(json.loads(STATE.read_text())) if STATE.exists() else {}
531
+ errors += _worktree_errors(1 in kept.values())
532
+ models, model_errors = driver_agents.load_models(args.models)
533
+ tickets, ticket_errors = driver_tickets.parse(args.tickets)
534
+ errors += model_errors + ticket_errors + driver_review.precondition_errors(tickets)
535
+ errors += driver_final.precondition_errors(args.push or args.unattended)
536
+ for error in errors:
537
+ print(error)
538
+ return None if errors else (tickets, models, kept)
539
+
540
+
541
+ def _print_width(tickets: list[Ticket]) -> None:
542
+ print(f"max frontier width: {driver_tickets.max_frontier_width(tickets)}")
543
+
544
+
545
+ def _lock_file(args: argparse.Namespace) -> Path | None:
546
+ """The file one driver per repository locks: in the git common directory, which every worktree shares and
547
+ `git status` never reports. None, with each error printed, when the call is not valid."""
548
+ common = git(".", "rev-parse", "--git-common-dir")
549
+ # A first call is validated before the lock, so a wrong call makes no file. After an earlier run the lock comes
550
+ # first: the driver that holds it changes what the validation reads.
551
+ first_call = common.returncode or not OUT.exists()
552
+ if first_call and not _validated(args):
553
+ return None
554
+ return Path(common.stdout.strip()).resolve() / "implement-spec.lock"
555
+
556
+
557
+ def main(argv: list[str] | None = None) -> int:
558
+ args = _arguments(argv)
559
+ if args.dry_run: # it changes nothing, so it needs no lock
560
+ if valid := _validated(args):
561
+ _print_width(valid[0])
562
+ return 0 if valid else 3
563
+ if not (lock_file := _lock_file(args)):
564
+ return 3
565
+ held = driver_state.lock(lock_file)
566
+ if not held:
567
+ print(f"implement-spec: another driver runs in this repository: it holds the lock {lock_file}. Wait until it "
568
+ "ends, then run the driver again.")
569
+ return 3
570
+ with held:
571
+ # Only what is read under the lock is trusted: no other driver changes state.json, the slots or the refs.
572
+ if not (valid := _validated(args)):
573
+ return 3
574
+ tickets, models, kept = valid
575
+ _print_width(tickets)
576
+ return _run(tickets, models, args, kept)
577
+
578
+
579
+ if __name__ == "__main__":
580
+ sys.exit(main())