@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.
- package/README.md +12 -6
- package/package.json +2 -2
- package/skills/implement-spec/ARCHITECTURE.md +113 -0
- package/skills/implement-spec/README.md +214 -0
- package/skills/implement-spec/SKILL.md +18 -70
- package/skills/implement-spec/driver.py +580 -0
- package/skills/implement-spec/driver_agents.py +145 -0
- package/skills/implement-spec/driver_answers.py +79 -0
- package/skills/implement-spec/driver_decisions.py +152 -0
- package/skills/implement-spec/driver_final.py +505 -0
- package/skills/implement-spec/driver_fixloop.py +170 -0
- package/skills/implement-spec/driver_gate.py +47 -0
- package/skills/implement-spec/driver_graph.py +42 -0
- package/skills/implement-spec/driver_merge.py +105 -0
- package/skills/implement-spec/driver_report.py +115 -0
- package/skills/implement-spec/driver_review.py +126 -0
- package/skills/implement-spec/driver_review_page.py +51 -0
- package/skills/implement-spec/driver_run.py +262 -0
- package/skills/implement-spec/driver_slots.py +64 -0
- package/skills/implement-spec/driver_state.py +105 -0
- package/skills/implement-spec/driver_summary.py +40 -0
- package/skills/implement-spec/driver_tickets.py +132 -0
- package/skills/implement-spec/driver_tidy.py +57 -0
- package/skills/implement-spec/models.json +10 -0
- package/skills/implement-spec/prompts/conflict.md +32 -0
- package/skills/implement-spec/prompts/correction.md +4 -0
- package/skills/implement-spec/prompts/correctness.md +34 -0
- package/skills/implement-spec/prompts/final-fixer.md +36 -0
- package/skills/implement-spec/prompts/final-reviewer.md +36 -0
- package/skills/implement-spec/prompts/final-scoped.md +1 -0
- package/skills/implement-spec/prompts/fixer.md +35 -0
- package/skills/implement-spec/prompts/implementer.md +33 -0
- package/skills/implement-spec/prompts/interrupted.md +1 -0
- package/skills/implement-spec/prompts/moved.md +2 -0
- package/skills/implement-spec/prompts/overruled.md +5 -0
- package/skills/implement-spec/prompts/pr-body.md +19 -0
- package/skills/implement-spec/prompts/rebase-conflict.md +33 -0
- package/skills/implement-spec/prompts/rereview.md +3 -0
- package/skills/implement-spec/prompts/rerun.md +2 -0
- package/skills/implement-spec/prompts/reviewer.md +34 -0
- package/skills/implement-spec/prompts/stands.md +2 -0
- package/skills/implement-spec/prompts/test-writer.md +34 -0
- package/skills/implement-spec/prompts/timeout-restored.md +2 -0
- package/skills/implement-spec/prompts/timeout-shared.md +2 -0
- package/skills/implement-spec/prompts/timeout.md +2 -0
- package/skills/implement-spec/run.sh +101 -0
- package/skills/implement-spec/tests/conftest.py +38 -0
- package/skills/implement-spec/tests/test_agents.py +358 -0
- package/skills/implement-spec/tests/test_amendments.py +117 -0
- package/skills/implement-spec/tests/test_cli.py +220 -0
- package/skills/implement-spec/tests/test_decisions.py +434 -0
- package/skills/implement-spec/tests/test_driver.py +767 -0
- package/skills/implement-spec/tests/test_final.py +442 -0
- package/skills/implement-spec/tests/test_fixloop.py +523 -0
- package/skills/implement-spec/tests/test_graph.py +32 -0
- package/skills/implement-spec/tests/test_landing.py +355 -0
- package/skills/implement-spec/tests/test_merge.py +144 -0
- package/skills/implement-spec/tests/test_models.py +47 -0
- package/skills/implement-spec/tests/test_report.py +162 -0
- package/skills/implement-spec/tests/test_review.py +338 -0
- package/skills/implement-spec/tests/test_review_page.py +67 -0
- package/skills/implement-spec/tests/test_slots.py +32 -0
- package/skills/implement-spec/tests/test_state.py +68 -0
- package/skills/implement-spec/tests/test_stops.py +272 -0
- package/skills/implement-spec/tests/test_summary.py +28 -0
- package/skills/implement-spec/tests/test_tickets.py +72 -0
- package/skills/implement-spec/tests/test_tidy.py +582 -0
- package/skills/implement-spec/tests/test_walk.py +457 -0
- package/skills/review-page/template.html +1 -1
- package/skills/to-tickets/SKILL.md +19 -2
- package/skills-manifest.json +7 -7
- 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())
|