@jesdi/skills 0.0.33 → 0.0.35
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/package.json +1 -1
- package/skills/implement-spec/ARCHITECTURE.md +6 -0
- package/skills/implement-spec/README.md +6 -2
- package/skills/implement-spec/SKILL.md +1 -1
- package/skills/implement-spec/driver.py +37 -4
- package/skills/implement-spec/driver_agents.py +44 -0
- package/skills/implement-spec/driver_final.py +6 -1
- package/skills/implement-spec/driver_fixloop.py +34 -10
- package/skills/implement-spec/driver_run.py +24 -3
- package/skills/implement-spec/models.json +5 -5
- package/skills/implement-spec/prompts/fixer.md +1 -0
- package/skills/implement-spec/tests/test_agents.py +35 -2
- package/skills/implement-spec/tests/test_driver.py +49 -3
- package/skills/implement-spec/tests/test_fixloop.py +28 -0
- package/skills/implement-spec/tests/test_models.py +4 -4
- package/skills/implement-spec/tests/test_walk.py +2 -1
- package/skills-manifest.json +3 -3
package/package.json
CHANGED
|
@@ -33,6 +33,10 @@ and do not know about the run, except `driver_run.py`, which the three deciding
|
|
|
33
33
|
- **A step that cannot go on raises `Blocked`.** The message says why. `with during("the merge"):` puts the step
|
|
34
34
|
in front. Only two places catch it for good: `driver._run_ticket` (the ticket becomes BLOCKED) and
|
|
35
35
|
`driver_final.run` (the end of run becomes BLOCKED). Another `except Blocked` only cleans up and raises again.
|
|
36
|
+
- **A CLI that fails with no report raises `Halted`**, not `Blocked`: the setup of the run is wrong, not the
|
|
37
|
+
ticket. `driver._run_ticket` and `driver._work` catch it: the run starts no other agent and ends with exit 4,
|
|
38
|
+
and the ticket keeps its phase for the next run. A cleanup of an `except Blocked` that leaves git as it was (an
|
|
39
|
+
open merge, an open rebase, a rejected fix batch) also runs for `Halted`.
|
|
36
40
|
- **A check before the first agent returns a list of errors**, so the operator sees all of them at one time
|
|
37
41
|
(exit 3). Examples: `precondition_errors`, `driver_tickets.parse`, `driver_report.validate`.
|
|
38
42
|
- **The modules that do not know about the run return a result, not `Blocked`.** Some return `""` or the reason
|
|
@@ -103,6 +107,8 @@ and do not know about the run, except `driver_run.py`, which the three deciding
|
|
|
103
107
|
A timeout doubles the time of the rerun.
|
|
104
108
|
- Fix rounds stop at 4, and round 3 goes to the next implementer entry. The correctness pass never runs on the
|
|
105
109
|
provider of the implementer.
|
|
110
|
+
- A red gate on a fixer's commit is a finding of the next round (`driver_fixloop._gate`), so it blocks only after
|
|
111
|
+
round 4.
|
|
106
112
|
|
|
107
113
|
### Questions
|
|
108
114
|
|
|
@@ -19,6 +19,7 @@ Before any agent starts, the driver checks these. Each failed check prints one l
|
|
|
19
19
|
- Each model entry is valid, and the CLI of the first entry of each role is on PATH. For the correctness role, the entry is the first of its list on another provider than the first implementer entry: that list must have such an entry, and its CLI must be on PATH. This applies to every run, also when no ticket has a **Risk** line.
|
|
20
20
|
- The `review-diff` skill is installed next to this one: the final review of every run reads it, and so does the correctness pass.
|
|
21
21
|
- With `--push` or `--unattended`, the repository has an `origin` remote.
|
|
22
|
+
- Each model that the run can use answers one short test prompt (the preflight). A model that fails gives one line with its error. `--dry-run` skips the preflight.
|
|
22
23
|
|
|
23
24
|
A run that stopped is continued with the same command. `state.json` is the only input of a resume.
|
|
24
25
|
|
|
@@ -56,6 +57,7 @@ Default timeouts in minutes: explorer 30, test-writer 30, implementer 90, review
|
|
|
56
57
|
| 1 | A ticket is BLOCKED, or the end of run is BLOCKED. The other tickets ran as far as they could. |
|
|
57
58
|
| 2 | Parked: a question of kind `locked-test` or `final-finding` is open. Exit 2 wins over exit 1. |
|
|
58
59
|
| 3 | Invalid input or a failed precondition: a usage error, a ticket error, a model error, an uncommitted change, a slot that cannot be made, another driver that runs in the same repository. No agent starts. The one exception: a review page that cannot be written gives exit 3 at the end of a run, after agents ran. |
|
|
60
|
+
| 4 | Stopped: the CLI of an agent failed two times and wrote no report (for example, the account cannot use the model). The driver starts no other agent and lets the running agents end. No ticket is BLOCKED by it: fix the CLI or the model entry, then run the same command again to resume. |
|
|
59
61
|
| 130 | The operator interrupted the run. The driver stops each agent. The work stays in the slots for the next run. |
|
|
60
62
|
|
|
61
63
|
With `--unattended`, the `--stage-json` file holds `{"stage": "implement", "status": …}`:
|
|
@@ -80,7 +82,7 @@ Everything is under `.agent/`, which is never committed.
|
|
|
80
82
|
| `.agent/implement/<NN>/` | One folder for each ticket. |
|
|
81
83
|
| `.agent/implement/<NN>/<role>-<K>.json`, `.log`, `.prompt.md` | The report, the log and the prompt of one agent. K is the fix round. |
|
|
82
84
|
| `.agent/implement/<NN>/gate-<sha7>.log` | One gate record: start, commit, status, command, output, exit, the state after the command, end. |
|
|
83
|
-
| `.agent/implement/<NN>/findings-<K>.json` | The merged findings of the review after fix round K. |
|
|
85
|
+
| `.agent/implement/<NN>/findings-<K>.json` | The merged findings of the review after fix round K, or the red gate of round K. |
|
|
84
86
|
| `.agent/implement/final/` | The same files for the end of run, and `pr-body.md`: the pr-writer's body, then the facts of the run. |
|
|
85
87
|
| `.agent/implement/pycache/` | Compiled Python of the gate, kept out of the slots. |
|
|
86
88
|
| `.agent/implement-review.html` | The page with the open questions. |
|
|
@@ -117,6 +119,8 @@ Rules for each agent:
|
|
|
117
119
|
|
|
118
120
|
A fix round is one **fixer** (`prompts/fixer.md`), then the gate on the new tip, then a re-review of the range of the fix. A mutation proof is repeated only when the fix changed its production line.
|
|
119
121
|
|
|
122
|
+
A red gate on the commit of a fixer is a critical finding with source `gate`. Its file is the gate record, and the record holds the output. No review runs on that tip, and the next round starts. The re-review after that round covers each fix since the last review and repeats each proof that one of them made due. A red gate after round 4 blocks the ticket, as does a red gate on the commit of the implementer or of a fixture correction.
|
|
123
|
+
|
|
120
124
|
- Rounds 1 and 2 run on the first entry of the implementer list.
|
|
121
125
|
- Rounds 3 and 4 run on the second entry of the implementer list. When the list has one entry, they stay on it and the ledger says so.
|
|
122
126
|
- There is no ruling at round 2.
|
|
@@ -158,7 +162,7 @@ A repository with no `origin` remote skips only step 2 and step 6. The tidied co
|
|
|
158
162
|
|
|
159
163
|
The driver uses the first entry of a role. The fixer of a ticket runs on the `implementer` list; the `fixer` list is only for the fix batch of the final review. The driver uses a later entry in two cases only: fix rounds 3 and 4 (the implementer list), and the correctness pass, which takes the first correctness entry of another provider than the first implementer entry. The reviewer never does the correctness pass: when the list has no such entry, or the CLI of that entry is not on PATH, the run ends with exit 3 before any agent. The `explorer` list is read and checked, but the driver starts no explorer.
|
|
160
164
|
|
|
161
|
-
The defaults need both `claude` and `codex` on PATH. Read "Known limits" before you use them as they are.
|
|
165
|
+
The defaults need both `claude` and `codex` on PATH. The `openai` defaults name the full Codex model names (`gpt-6-astra`, `gpt-6.1-sol`), because a Codex CLI that is signed in with a ChatGPT account does not accept the short aliases. Read "Known limits" before you use them as they are.
|
|
162
166
|
|
|
163
167
|
## Report schema per role
|
|
164
168
|
|
|
@@ -24,7 +24,7 @@ has passed. The review rounds adapt superpowers' subagent-driven-development
|
|
|
24
24
|
Run the driver. It makes every choice of the run; you start it and relay its result.
|
|
25
25
|
|
|
26
26
|
1. In the PR worktree, run `sh <skill-dir>/run.sh` with the flags the operator gave, unchanged. `<skill-dir>` is the directory of this file. The driver needs `--gate` and `--spec`: ask the operator for a flag that is missing. A run can take hours: run the command in the background and wait for it to exit.
|
|
27
|
-
2. When it exits, relay `.agent/implement/summary.md` and the exit code: 0 is done, 1 is a BLOCKED ticket, 2 is parked on questions, 3 is invalid input. Relay the lines the driver printed after the summary too.
|
|
27
|
+
2. When it exits, relay `.agent/implement/summary.md` and the exit code: 0 is done, 1 is a BLOCKED ticket, 2 is parked on questions, 3 is invalid input, 4 is stopped by a CLI failure (after the fix, the same command resumes). Relay the lines the driver printed after the summary too.
|
|
28
28
|
3. With exit 2, and with exit 1 when the driver printed questions, show the operator those questions. When the operator answers, run the same command again with one `--answer <question id>=<option id>` for each answer.
|
|
29
29
|
|
|
30
30
|
Do not answer a question for the operator. Do not edit `.agent/implement/`, the code or the branches during a run.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[--dry-run]
|
|
7
7
|
|
|
8
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
|
|
9
|
+
run is BLOCKED, 2 = parked on a question, 3 = usage or validation error, 4 = stopped by a CLI failure. README.md has the method and the files of
|
|
10
10
|
a run; ARCHITECTURE.md has the map of the modules and the rules the code keeps.
|
|
11
11
|
"""
|
|
12
12
|
from __future__ import annotations
|
|
@@ -32,7 +32,7 @@ import driver_slots
|
|
|
32
32
|
import driver_state
|
|
33
33
|
import driver_summary
|
|
34
34
|
import driver_tickets
|
|
35
|
-
from driver_run import FINAL, OUT, Blocked, Run, during, ledger
|
|
35
|
+
from driver_run import FINAL, OUT, Blocked, Halted, Run, during, ledger
|
|
36
36
|
from driver_slots import git
|
|
37
37
|
from driver_tickets import Ticket
|
|
38
38
|
|
|
@@ -133,7 +133,7 @@ def _resolve_conflict(ticket: Ticket, slot: str, run: Run, tip: str, files: list
|
|
|
133
133
|
changed = driver_review.locked_changes(slot, tip, run.state.read()["tickets"][number]["locked"])
|
|
134
134
|
if changed:
|
|
135
135
|
raise Blocked("; ".join(finding["title"] for finding in changed))
|
|
136
|
-
except Blocked:
|
|
136
|
+
except (Blocked, Halted):
|
|
137
137
|
driver_merge.abort(slot) # closes the merge when the agent did not commit it
|
|
138
138
|
raise
|
|
139
139
|
resolved = driver_slots.head(slot)
|
|
@@ -234,6 +234,12 @@ def _block(number: str, run: Run, ledger_line: int) -> None:
|
|
|
234
234
|
ledger(f"Ticket {number}: {driver_decisions.decides(ask.kind, len(ask.subjects))} in {DECISIONS}.")
|
|
235
235
|
|
|
236
236
|
|
|
237
|
+
def _halt(run: Run, owner: str, why: str) -> None:
|
|
238
|
+
"""Start no other agent: the run ends with exit 4, and `owner` keeps its phase for the next run."""
|
|
239
|
+
run.halted.append(why)
|
|
240
|
+
ledger(f"{owner}: the run stops and the phase stays for the next run: {why}.")
|
|
241
|
+
|
|
242
|
+
|
|
237
243
|
def _run_ticket(ticket: Ticket, slot_number: int, run: Run) -> bool:
|
|
238
244
|
"""Take one ticket from its recorded phase to merged or blocked. True: the slot is free again."""
|
|
239
245
|
number, slot = ticket.number, driver_slots.path(slot_number)
|
|
@@ -247,11 +253,15 @@ def _run_ticket(ticket: Ticket, slot_number: int, run: Run) -> bool:
|
|
|
247
253
|
except Blocked as blocked:
|
|
248
254
|
_block(number, run, ledger(f"Ticket {number}: BLOCKED at {blocked}."))
|
|
249
255
|
print(f"{ticket.file}: BLOCKED at {blocked}")
|
|
256
|
+
except Halted as halted:
|
|
257
|
+
_halt(run, f"Ticket {number}", str(halted))
|
|
250
258
|
return driver_slots.release(slot) # False: the slot keeps the uncommitted work of the blocked ticket
|
|
251
259
|
|
|
252
260
|
|
|
253
261
|
def _next_ticket(tickets: list[Ticket], running: dict, told: set, run: Run, kept: dict) -> Ticket | None:
|
|
254
262
|
"""The first ticket that may start now. A ticket whose slot is kept for it goes first: it needs no free slot."""
|
|
263
|
+
if run.halted:
|
|
264
|
+
return None
|
|
255
265
|
phases = {number: entry["phase"] for number, entry in run.state.read()["tickets"].items()}
|
|
256
266
|
ready, holds = driver_graph.frontier(tickets, phases, {number for number, _ in running.values()})
|
|
257
267
|
for hold in sorted(set(holds.items()) - told):
|
|
@@ -319,7 +329,13 @@ def _work(tickets: list[Ticket], slots: int, run: Run) -> tuple[bool, list[str]]
|
|
|
319
329
|
if driver_slots.release("."): # slot 1 is clean, so it goes back to the PR branch
|
|
320
330
|
git(".", "checkout", "-q", run.pr)
|
|
321
331
|
# Every ticket thread has ended, so every slot is idle: only now may the end of run rewrite the PR branch.
|
|
322
|
-
|
|
332
|
+
if only:
|
|
333
|
+
return False, []
|
|
334
|
+
try:
|
|
335
|
+
return driver_final.run(run, tickets)
|
|
336
|
+
except Halted as halted:
|
|
337
|
+
_halt(run, "End of run", str(halted))
|
|
338
|
+
return False, []
|
|
323
339
|
|
|
324
340
|
|
|
325
341
|
# ---------------------------------------------------------------- before the first agent: resume and answers
|
|
@@ -425,6 +441,17 @@ def _tell(args, code: int, open_questions: list[dict], blocked: list[str], pushe
|
|
|
425
441
|
_write_stage(args.stage_json, code, len(open_questions), blocked)
|
|
426
442
|
|
|
427
443
|
|
|
444
|
+
def _halted_exit(halted: list[str], final: dict) -> int:
|
|
445
|
+
"""Exit 4 after a CLI failure: no ticket is BLOCKED by it, and the same command resumes the run."""
|
|
446
|
+
for why in halted:
|
|
447
|
+
print(f"implement-spec: the run stopped: {why}")
|
|
448
|
+
print("implement-spec: fix the CLI or the model entry (--models FILE), then run the same command again to "
|
|
449
|
+
"resume; no ticket is BLOCKED by this.")
|
|
450
|
+
if error := _publish_questions(final["run"]):
|
|
451
|
+
print(error)
|
|
452
|
+
return 4
|
|
453
|
+
|
|
454
|
+
|
|
428
455
|
def _exit_code(final: dict, args, end_blocked: bool) -> int:
|
|
429
456
|
"""Publish the open questions, tell the operator or the dispatcher, and return the exit code."""
|
|
430
457
|
if error := _publish_questions(final["run"]):
|
|
@@ -456,6 +483,8 @@ def _run(tickets: list[Ticket], models: dict[str, list[str]], args, kept: dict[s
|
|
|
456
483
|
summary = driver_summary.render(final)
|
|
457
484
|
(OUT / "summary.md").write_text(summary)
|
|
458
485
|
print(summary + "".join(line + "\n" for line in lines), end="")
|
|
486
|
+
if run.halted:
|
|
487
|
+
return _halted_exit(run.halted, final)
|
|
459
488
|
return _exit_code(final, args, end_blocked)
|
|
460
489
|
|
|
461
490
|
|
|
@@ -572,6 +601,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
572
601
|
if not (valid := _validated(args)):
|
|
573
602
|
return 3
|
|
574
603
|
tickets, models, kept = valid
|
|
604
|
+
# The CLIs answer before the first agent, so a model that the account cannot use stops no ticket.
|
|
605
|
+
if not args.runner and (errors := driver_agents.preflight(models)):
|
|
606
|
+
print("".join(error + "\n" for error in errors), end="")
|
|
607
|
+
return 3
|
|
575
608
|
_print_width(tickets)
|
|
576
609
|
return _run(tickets, models, args, kept)
|
|
577
610
|
|
|
@@ -11,8 +11,10 @@ import re
|
|
|
11
11
|
import shutil
|
|
12
12
|
import signal
|
|
13
13
|
import subprocess
|
|
14
|
+
import tempfile
|
|
14
15
|
import threading
|
|
15
16
|
import time
|
|
17
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
16
18
|
from pathlib import Path
|
|
17
19
|
|
|
18
20
|
SKILL = Path(__file__).resolve().parent
|
|
@@ -22,6 +24,9 @@ TIMEOUTS = {"explorer": 30, "test-writer": 30, "implementer": 90, "reviewer": 45
|
|
|
22
24
|
PROVIDERS = {"anthropic": ("claude", ("low", "medium", "high", "xhigh", "max")),
|
|
23
25
|
"openai": ("codex", ("minimal", "low", "medium", "high", "xhigh"))}
|
|
24
26
|
READ_ONLY = ("reviewer", "correctness", "final-reviewer", "pr-writer")
|
|
27
|
+
PREFLIGHT = "implement-spec preflight: reply with the word OK. Read no file and run no command.\n"
|
|
28
|
+
# The time limit of one preflight call: a CLI that gives no answer in this time fails the preflight.
|
|
29
|
+
PREFLIGHT_MINUTES = 5
|
|
25
30
|
# A process group is here only while its leader is not reaped: then no other process can have its id.
|
|
26
31
|
LIVE: set[int] = set()
|
|
27
32
|
STOPPED = False
|
|
@@ -69,6 +74,45 @@ def load_models(path: str | None) -> tuple[dict[str, list[str]], list[str]]:
|
|
|
69
74
|
return models, [error for error in found if error]
|
|
70
75
|
|
|
71
76
|
|
|
77
|
+
def used_entries(models: dict[str, list[str]]) -> list[str]:
|
|
78
|
+
"""Each entry the driver can start an agent on: the first of each role but the explorer, the correctness entry
|
|
79
|
+
and the second implementer entry (fix rounds 3 and 4)."""
|
|
80
|
+
entries = [entries[0] for role, entries in models.items() if role not in ("explorer", "correctness")]
|
|
81
|
+
entries += [correctness_entry(models), *models["implementer"][1:2]]
|
|
82
|
+
return sorted(set(entries))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def preflight(models: dict[str, list[str]]) -> list[str]:
|
|
86
|
+
"""Give one short prompt to each used entry, all at the same time. Returns one error per entry whose CLI fails:
|
|
87
|
+
a model that the account cannot use, a CLI that is not signed in."""
|
|
88
|
+
with tempfile.TemporaryDirectory(prefix="implement-spec-preflight-") as folder:
|
|
89
|
+
def check(index: int, entry: str) -> str:
|
|
90
|
+
prompt, log = (Path(folder) / f"{index}{suffix}" for suffix in (".prompt.md", ".log"))
|
|
91
|
+
prompt.write_text(PREFLIGHT)
|
|
92
|
+
# A read-only command: the agent can change nothing, and codex writes its answer to the -o file.
|
|
93
|
+
code = launch(command("reviewer", entry, str(Path(folder) / f"{index}.json")), prompt, log,
|
|
94
|
+
PREFLIGHT_MINUTES * 60)
|
|
95
|
+
if code == 0:
|
|
96
|
+
return ""
|
|
97
|
+
why = f"no answer in {PREFLIGHT_MINUTES} minutes" if code is None else f"exit {code}"
|
|
98
|
+
cli = PROVIDERS[split(entry)[0]][0]
|
|
99
|
+
return f"model entry {entry}: the {cli} CLI failed the preflight ({why}): {last_words(log)}"
|
|
100
|
+
|
|
101
|
+
entries = used_entries(models)
|
|
102
|
+
with ThreadPoolExecutor(len(entries)) as pool:
|
|
103
|
+
return [error for error in pool.map(check, range(len(entries)), entries) if error]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def last_words(log: Path) -> str:
|
|
107
|
+
"""The last line of an agent log, or the `result` of the last stream-json line: the error a failed CLI gave."""
|
|
108
|
+
lines = [line for line in log.read_text(errors="replace").splitlines() if line.strip()] if log.exists() else []
|
|
109
|
+
if not lines:
|
|
110
|
+
return "no output"
|
|
111
|
+
with contextlib.suppress(ValueError, AttributeError):
|
|
112
|
+
return str(json.loads(lines[-1]).get("result") or lines[-1])[-500:]
|
|
113
|
+
return lines[-1][-500:]
|
|
114
|
+
|
|
115
|
+
|
|
72
116
|
def command(role: str, entry: str, report: str) -> list[str]:
|
|
73
117
|
"""The CLI call for one agent; the prompt goes to its stdin."""
|
|
74
118
|
provider, model, effort = split(entry)
|
|
@@ -29,7 +29,7 @@ import driver_decisions
|
|
|
29
29
|
import driver_merge
|
|
30
30
|
import driver_review
|
|
31
31
|
import driver_tidy
|
|
32
|
-
from driver_run import FINAL, OUT, Blocked, Run, during, ledger
|
|
32
|
+
from driver_run import FINAL, OUT, Blocked, Halted, Run, during, ledger
|
|
33
33
|
from driver_slots import git, head
|
|
34
34
|
from driver_state import State
|
|
35
35
|
from driver_tickets import Ticket
|
|
@@ -158,6 +158,9 @@ def _resolve_rebase_conflict(run: Run, env: dict, base: str, onto: str) -> None:
|
|
|
158
158
|
run.agent("conflict", FINAL, ".", values, template="rebase-conflict", detached=True)
|
|
159
159
|
except Blocked as blocked:
|
|
160
160
|
error = str(blocked)
|
|
161
|
+
except Halted:
|
|
162
|
+
git(".", "rebase", "--abort")
|
|
163
|
+
raise
|
|
161
164
|
if not error and _rebase_is_open(): # the agent resolved the files and did not go on
|
|
162
165
|
subprocess.run(["git", "-c", "core.editor=true", "rebase", "--continue"], capture_output=True, env=env)
|
|
163
166
|
if _rebase_is_open():
|
|
@@ -368,6 +371,8 @@ def _run_fixer(run: Run, tickets: list[Ticket], final: dict, tip: str) -> dict:
|
|
|
368
371
|
check={"owners": [ticket.number for ticket in tickets]})
|
|
369
372
|
except Blocked as blocked:
|
|
370
373
|
raise Blocked(f"{blocked}{_undo_rejected_batch(run, tip)}") from None
|
|
374
|
+
except Halted as halted:
|
|
375
|
+
raise Halted(f"{halted}{_undo_rejected_batch(run, tip)}") from None
|
|
371
376
|
|
|
372
377
|
|
|
373
378
|
def _fix_batch(run: Run, tickets: list[Ticket], final: dict, tip: str) -> None:
|
|
@@ -30,8 +30,9 @@ class Job:
|
|
|
30
30
|
tip: str
|
|
31
31
|
previous: str = "" # the tip before the last fix; "" makes the next review a full one
|
|
32
32
|
proofs: set[tuple[str, int]] = field(default_factory=set) # mutation proofs so far, at `tip`
|
|
33
|
-
again:
|
|
33
|
+
again: set[tuple[str, int]] = field(default_factory=set) # each proof at `tip` that a fix made due again
|
|
34
34
|
corrected: bool = False # the last step was a fixture correction
|
|
35
|
+
red: bool = False # the gate of `tip` is red, so no review saw it
|
|
35
36
|
|
|
36
37
|
def pointers(self, review: int) -> dict[str, str]:
|
|
37
38
|
"""The values of a prompt; `review` is the round whose findings the agent reads."""
|
|
@@ -39,7 +40,7 @@ class Job:
|
|
|
39
40
|
findings = os.path.relpath(OUT / self.ticket.number / f"findings-{review}.json", self.slot)
|
|
40
41
|
return {**self.values, "start_sha": self.tip, "from_sha": self.previous, "round": str(self.round),
|
|
41
42
|
"locked": " ".join(entry["locked"]), "test_writer_sha": str(entry["test_writer_sha"] or ""),
|
|
42
|
-
"findings": findings, "proofs": " ".join(self.again)}
|
|
43
|
+
"findings": findings, "proofs": " ".join(f"{file}:{line}" for file, line in sorted(self.again))}
|
|
43
44
|
|
|
44
45
|
|
|
45
46
|
def entry(models: dict[str, list[str]], round: int) -> str:
|
|
@@ -129,12 +130,16 @@ def _fix(job: Job) -> None:
|
|
|
129
130
|
with during(f"fix round {job.round}"):
|
|
130
131
|
run.agent("fixer", number, job.slot, job.pointers(job.round - 1), entry=on, fix_round=job.round)
|
|
131
132
|
before, job.tip = job.tip, driver_slots.head(job.slot)
|
|
132
|
-
# No review has seen a fixture correction, so the review after a fix on a corrected tip is a full one.
|
|
133
|
-
job.previous, job.corrected = "" if job.corrected else before, False
|
|
134
133
|
run.state.ticket(number, tip=job.tip)
|
|
135
134
|
followed = driver_review.follow(job.slot, before, job.tip, job.proofs)
|
|
136
|
-
|
|
137
|
-
job.
|
|
135
|
+
due = {proof for proof, changed in followed.items() if changed}
|
|
136
|
+
if job.red:
|
|
137
|
+
# No review saw `before`: the next review covers the fix before it too, and its due proofs stay due.
|
|
138
|
+
due |= set(driver_review.follow(job.slot, before, job.tip, job.again))
|
|
139
|
+
else:
|
|
140
|
+
# No review has seen a fixture correction, so the review after a fix on a corrected tip is a full one.
|
|
141
|
+
job.previous = "" if job.corrected else before
|
|
142
|
+
job.corrected, job.proofs, job.again = False, set(followed), due
|
|
138
143
|
|
|
139
144
|
|
|
140
145
|
def _act_on(job: Job, found: list[dict]) -> bool:
|
|
@@ -157,14 +162,33 @@ def _act_on(job: Job, found: list[dict]) -> bool:
|
|
|
157
162
|
return False
|
|
158
163
|
|
|
159
164
|
|
|
165
|
+
def _gate(job: Job) -> list[dict]:
|
|
166
|
+
"""Gate the tip. A red gate on a fixer's commit before round 4 is a finding for the next round; any other red
|
|
167
|
+
gate blocks. Returns the findings: the red gate, or none."""
|
|
168
|
+
run, number = job.run, job.ticket.number
|
|
169
|
+
job.red = False
|
|
170
|
+
with during("the gate"):
|
|
171
|
+
if not 0 < job.round < ROUNDS or job.corrected:
|
|
172
|
+
run.gate(number, job.slot, job.tip)
|
|
173
|
+
return []
|
|
174
|
+
record = run.red_gate(number, job.slot, job.tip)
|
|
175
|
+
if not record:
|
|
176
|
+
return []
|
|
177
|
+
job.red, path = True, os.path.relpath(record, job.slot)
|
|
178
|
+
found = [{"severity": "critical", "file": path, "line": 0, "title": f"the gate is red on {job.tip[:7]}",
|
|
179
|
+
"evidence": f"the output of the gate in {path}", "kind": "code", "source": "gate"}]
|
|
180
|
+
(OUT / number / f"findings-{job.round}.json").write_text(json.dumps(found, indent=1) + "\n")
|
|
181
|
+
return _log_findings(job, found)
|
|
182
|
+
|
|
183
|
+
|
|
160
184
|
def run(ticket: Ticket, slot: str, run: Run, values: dict[str, str]) -> None:
|
|
161
185
|
"""Gate, review and fix the ticket tip until a review leaves no finding open."""
|
|
162
186
|
state = run.state.read()["tickets"][ticket.number]
|
|
163
187
|
job = Job(ticket, slot, run, values, state["round"], state["tip"])
|
|
164
188
|
while True:
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
189
|
+
found = _gate(job)
|
|
190
|
+
if not found:
|
|
191
|
+
with during("the review"):
|
|
192
|
+
found = _review(job)
|
|
169
193
|
if _act_on(job, found):
|
|
170
194
|
return
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""What the ticket threads and the end of run share: the Run, one agent with its one rerun, the gate, the ledger.
|
|
2
2
|
|
|
3
3
|
A step that cannot go on raises Blocked. The message says why, and `during` adds the step it happened in.
|
|
4
|
+
A CLI that fails with no report raises Halted: that is the setup of the run, not the ticket.
|
|
4
5
|
"""
|
|
5
6
|
from __future__ import annotations
|
|
6
7
|
|
|
@@ -34,6 +35,11 @@ class Blocked(Exception):
|
|
|
34
35
|
"""A ticket, or the end of run, cannot go on. The message says why."""
|
|
35
36
|
|
|
36
37
|
|
|
38
|
+
class Halted(Exception):
|
|
39
|
+
"""The CLI of an agent failed with no report, two times: a model the account cannot use, a CLI that is not
|
|
40
|
+
signed in. The run starts no other agent and ends with exit 4; the ticket keeps its phase for the next run."""
|
|
41
|
+
|
|
42
|
+
|
|
37
43
|
@contextmanager
|
|
38
44
|
def during(step: str) -> Iterator[None]:
|
|
39
45
|
"""Put the step in front of a Blocked that leaves the block: `<step>: <why>`."""
|
|
@@ -79,6 +85,7 @@ class Run:
|
|
|
79
85
|
kept: dict[str, int] # ticket number -> the slot that holds its uncommitted work from an earlier run
|
|
80
86
|
merging: threading.Lock = field(default_factory=threading.Lock) # held for one landing: merges are serial
|
|
81
87
|
asks: dict[str, Ask] = field(default_factory=dict) # ticket number -> its pending stop
|
|
88
|
+
halted: list[str] = field(default_factory=list) # why the run starts no other agent; [] while it goes on
|
|
82
89
|
|
|
83
90
|
def tip(self) -> str:
|
|
84
91
|
return git(".", "rev-parse", f"refs/heads/{self.pr}").stdout.strip()
|
|
@@ -110,6 +117,9 @@ class Run:
|
|
|
110
117
|
result = review()
|
|
111
118
|
except Blocked as blocked:
|
|
112
119
|
error = str(blocked)
|
|
120
|
+
except Halted:
|
|
121
|
+
_restore_if_changed(number, slot, branch, tip, runs_again=False)
|
|
122
|
+
raise
|
|
113
123
|
left = _restore_if_changed(number, slot, branch, tip, runs_again=not (error or last))
|
|
114
124
|
if error or not left:
|
|
115
125
|
break
|
|
@@ -119,8 +129,13 @@ class Run:
|
|
|
119
129
|
|
|
120
130
|
def gate(self, number: str, slot: str, commit: str) -> None:
|
|
121
131
|
"""Gate `commit` in the slot, unless a green record names it already."""
|
|
132
|
+
if record := self.red_gate(number, slot, commit):
|
|
133
|
+
raise Blocked(f"the gate is red on {commit[:7]}, record {record}")
|
|
134
|
+
|
|
135
|
+
def red_gate(self, number: str, slot: str, commit: str) -> str:
|
|
136
|
+
"""`gate`, for a caller that acts on a red gate: returns its record, or "" when the gate is green."""
|
|
122
137
|
if commit in _gate_record(self.state.read(), number)["green_gates"]:
|
|
123
|
-
return
|
|
138
|
+
return ""
|
|
124
139
|
at = driver_slots.head(slot)
|
|
125
140
|
if at != commit:
|
|
126
141
|
raise Blocked(f"the slot is at {at[:7]}, not at {commit[:7]}: the gate did not run")
|
|
@@ -133,8 +148,9 @@ class Run:
|
|
|
133
148
|
|
|
134
149
|
self.state.update(count)
|
|
135
150
|
if not green:
|
|
136
|
-
|
|
151
|
+
return str(record)
|
|
137
152
|
ledger(f"{_owner(number)}: the gate is green on {commit[:7]}, record {record}.")
|
|
153
|
+
return ""
|
|
138
154
|
|
|
139
155
|
def restore_locked_files(self, number: str, slot: str) -> list[str]:
|
|
140
156
|
"""Before a test-writer correction: put each locked file the implementer changed back to its locked
|
|
@@ -244,7 +260,7 @@ def _attempts(run: Run, role: str, number: str, slot: str, values: dict[str, str
|
|
|
244
260
|
"template": template or role, **(check or {})}
|
|
245
261
|
argv = _command(run, role, entry, os.path.relpath(prompt, slot), report_in_slot)
|
|
246
262
|
text = _first_prompt(context["template"], values, notes, report_in_slot)
|
|
247
|
-
minutes, error = run.args.timeout[role], ""
|
|
263
|
+
minutes, error, failed = run.args.timeout[role], "", ""
|
|
248
264
|
for _ in range(2):
|
|
249
265
|
prompt.write_text(text)
|
|
250
266
|
report.unlink(missing_ok=True)
|
|
@@ -255,8 +271,13 @@ def _attempts(run: Run, role: str, number: str, slot: str, values: dict[str, str
|
|
|
255
271
|
_record_attempt(run, role, number, entry, seconds, log, error)
|
|
256
272
|
if not error:
|
|
257
273
|
return json.loads(report.read_text())
|
|
274
|
+
# A CLI that exits with an error and no report did not do the role: the run setup is wrong, not the ticket.
|
|
275
|
+
failed = "" if code in (None, 0) or report.exists() else (
|
|
276
|
+
f"the {role} on {entry} exited {code} with no report: {driver_agents.last_words(log)}")
|
|
258
277
|
# The rerun prompt is the first prompt, the rejection, then the same ending again.
|
|
259
278
|
text += rerun_lines + driver_agents.tail(text, report_in_slot)
|
|
260
279
|
if code is None:
|
|
261
280
|
minutes *= 2
|
|
281
|
+
if failed:
|
|
282
|
+
raise Halted(failed)
|
|
262
283
|
raise Blocked(error)
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"explorer": ["claude-sonnet-5@medium"],
|
|
3
|
-
"test-writer": ["claude-sonnet-5@medium", "openai/gpt-sol@medium"],
|
|
4
|
-
"implementer": ["claude-sonnet-5@medium", "openai/gpt-sol@medium"],
|
|
5
|
-
"reviewer": ["openai/gpt-astra@medium", "claude-opus-5@medium"],
|
|
6
|
-
"correctness": ["openai/gpt-astra@medium", "claude-opus-5@medium"],
|
|
3
|
+
"test-writer": ["claude-sonnet-5@medium", "openai/gpt-6.1-sol@medium"],
|
|
4
|
+
"implementer": ["claude-sonnet-5@medium", "openai/gpt-6.1-sol@medium"],
|
|
5
|
+
"reviewer": ["openai/gpt-6-astra@medium", "claude-opus-5@medium"],
|
|
6
|
+
"correctness": ["openai/gpt-6-astra@medium", "claude-opus-5@medium"],
|
|
7
7
|
"fixer": ["claude-opus-5@medium"],
|
|
8
|
-
"final-reviewer": ["openai/gpt-astra@medium", "claude-opus-5@medium"],
|
|
8
|
+
"final-reviewer": ["openai/gpt-6-astra@medium", "claude-opus-5@medium"],
|
|
9
9
|
"conflict": ["claude-opus-5@medium"],
|
|
10
10
|
"pr-writer": ["claude-opus-5@medium"]
|
|
11
11
|
}
|
|
@@ -10,6 +10,7 @@ Findings of the review: {findings}
|
|
|
10
10
|
Fix each finding of severity critical or important in the findings file. Leave the minor findings: the final review gets them. Leave each finding of kind `fixture`: the test-writer corrects those.
|
|
11
11
|
Never edit a locked file. The one exception is a finding `locked file changed`: make that file equal to its content at {test_writer_sha}.
|
|
12
12
|
Use targeted tests while you work. The driver runs the gate on your final commit, and a re-review reads the lines you changed.
|
|
13
|
+
A finding from the `gate` is a red gate: its file is the gate record. Read the output there and make the gate green.
|
|
13
14
|
Commit your work on the checked-out ticket branch. Do not dispatch subagents.
|
|
14
15
|
In `ticket`, give the number of the ticket that owns your commits. In `green`, give the summary lines of the passing test output.
|
|
15
16
|
|
|
@@ -22,6 +22,7 @@ import pytest
|
|
|
22
22
|
from test_cli import GIT, ROLES, RUN, SKILL, TOOLS, drive, git, make_repo, models_file, ticket
|
|
23
23
|
|
|
24
24
|
RED = "FAILED tests/test_export.py::test_rows - AttributeError"
|
|
25
|
+
REFUSED = "ERROR: The 'gpt-astra' model is not supported when using Codex with a ChatGPT account."
|
|
25
26
|
RUNNER = f"""#!{sys.executable}
|
|
26
27
|
import json, os, subprocess, sys, time
|
|
27
28
|
from pathlib import Path
|
|
@@ -69,7 +70,8 @@ sys.exit(step.get("exit", 0))
|
|
|
69
70
|
|
|
70
71
|
@pytest.fixture
|
|
71
72
|
def env(tmp_path):
|
|
72
|
-
"""A restricted PATH whose fake claude and codex append their argv (NUL-separated) and exit 1.
|
|
73
|
+
"""A restricted PATH whose fake claude and codex append their argv (NUL-separated) and exit 1. They answer the
|
|
74
|
+
preflight prompt with exit 0 and log nothing, except for the model gpt-astra, which the account cannot use."""
|
|
73
75
|
bin_dir = tmp_path / "bin"
|
|
74
76
|
bin_dir.mkdir()
|
|
75
77
|
for name in TOOLS:
|
|
@@ -77,7 +79,15 @@ def env(tmp_path):
|
|
|
77
79
|
(bin_dir / name).symlink_to(shutil.which(name))
|
|
78
80
|
for cli in ("claude", "codex"):
|
|
79
81
|
log = f'"{tmp_path}/argv-{cli}"'
|
|
80
|
-
(bin_dir / cli).write_text(f"#!/bin/sh
|
|
82
|
+
(bin_dir / cli).write_text(f"""#!/bin/sh
|
|
83
|
+
case "$(cat)" in *'implement-spec preflight'*)
|
|
84
|
+
case " $* " in *' gpt-astra '*) echo "{REFUSED}"; exit 1;; esac
|
|
85
|
+
exit 0;;
|
|
86
|
+
esac
|
|
87
|
+
printf '%s\\0' "$@" >> {log}
|
|
88
|
+
printf '\\036' >> {log}
|
|
89
|
+
exit 1
|
|
90
|
+
""")
|
|
81
91
|
(bin_dir / cli).chmod(0o755)
|
|
82
92
|
return dict(os.environ, PATH=str(bin_dir)), tmp_path
|
|
83
93
|
|
|
@@ -267,6 +277,29 @@ def test_no_report_is_a_violation_with_one_rerun_then_blocked(env):
|
|
|
267
277
|
assert any("01" in l and "BLOCKED" in l for l in w.ledger().splitlines()), w.ledger()
|
|
268
278
|
|
|
269
279
|
|
|
280
|
+
def test_a_model_the_account_cannot_use_fails_the_preflight_with_exit_3_before_any_agent(env):
|
|
281
|
+
w = World(env, reviewer=["openai/gpt-astra@medium"])
|
|
282
|
+
proc = w.run({"test-writer": [tw()]}, runner=False)
|
|
283
|
+
assert proc.returncode == 3, proc.stdout + proc.stderr
|
|
284
|
+
assert f"model entry openai/gpt-astra@medium: the codex CLI failed the preflight (exit 1): {REFUSED}" \
|
|
285
|
+
in proc.stdout.splitlines(), proc.stdout
|
|
286
|
+
assert not launches(w.tmp, "claude") and not launches(w.tmp, "codex")
|
|
287
|
+
assert "ticket/01" not in git(w.repo, "branch", "--list") and not w.ledger()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_a_cli_that_fails_with_no_report_stops_the_run_with_exit_4_and_the_same_command_resumes(env):
|
|
291
|
+
w = World(env, refactor=True)
|
|
292
|
+
plan = {"implementer": [{"stderr": REFUSED + "\n", "exit": 1}] * 2 + [impl()]}
|
|
293
|
+
proc = w.run(plan)
|
|
294
|
+
assert proc.returncode == 4, proc.stdout + proc.stderr
|
|
295
|
+
assert f"implement-spec: the run stopped: the implementer on anthropic/claude-opus-5@medium exited 1 with no " \
|
|
296
|
+
f"report: {REFUSED}" in proc.stdout.splitlines(), proc.stdout
|
|
297
|
+
assert "BLOCKED" not in w.ledger() and "Ticket 01: the run stops and the phase stays for the next run: the implementer" in w.ledger()
|
|
298
|
+
assert json.loads((w.repo / ".agent/implement/state.json").read_text())["tickets"]["01"]["phase"] == "implement"
|
|
299
|
+
ok(w.run(plan))
|
|
300
|
+
assert len(w.calls("implementer")) == 3
|
|
301
|
+
|
|
302
|
+
|
|
270
303
|
def test_finished_status_reruns_the_implementer_once_with_the_error_appended(env):
|
|
271
304
|
w = World(env)
|
|
272
305
|
ok(w.run({"test-writer": [tw()], "implementer": [with_report(impl(0), status="FINISHED"), impl(1)]}))
|
|
@@ -175,7 +175,7 @@ def test_accepted_reports_end_the_run_with_exit_0(repo, tmp_path, capsys):
|
|
|
175
175
|
assert [line.partition(" for ")[0] for line in ledger] == [
|
|
176
176
|
"Ticket 01: test-writer ran on claude-sonnet-5@medium", "Ticket 01: implementer ran on claude-sonnet-5@medium",
|
|
177
177
|
f"Ticket 01: the gate is green on {tip}, record .agent/implement/01/gate-{tip}.log.",
|
|
178
|
-
"Ticket 01: reviewer ran on openai/gpt-astra@medium",
|
|
178
|
+
"Ticket 01: reviewer ran on openai/gpt-6-astra@medium",
|
|
179
179
|
f"Ticket 01: merged into feat/x as a fast-forward at {tip}."]
|
|
180
180
|
assert ledger[1].endswith(", cost 0.25 USD: report accepted.")
|
|
181
181
|
assert branches() == ["feat/x"] and "nothing to commit" in subprocess.run(
|
|
@@ -198,7 +198,7 @@ def prompt_of(tmp_path, role="implementer"):
|
|
|
198
198
|
|
|
199
199
|
def test_an_agent_that_writes_no_report_is_rerun_once_then_blocks(repo, tmp_path, capsys):
|
|
200
200
|
repo()
|
|
201
|
-
code, ledger = agents(tmp_path, capsys, "exit
|
|
201
|
+
code, ledger = agents(tmp_path, capsys, "exit 0\n", "--only", "reviewer")
|
|
202
202
|
assert code == 1 and len(ledger) == 3
|
|
203
203
|
assert ledger[2].startswith("Ticket 01: BLOCKED at the reviewer: report .agent/implement/01/reviewer-0.json is not")
|
|
204
204
|
|
|
@@ -422,7 +422,7 @@ def test_a_merge_conflict_goes_to_the_conflict_agent_once_and_its_commit_is_gate
|
|
|
422
422
|
|
|
423
423
|
def test_a_conflict_agent_that_does_not_resolve_blocks_the_ticket_and_keeps_its_branch(repo, tmp_path, capsys):
|
|
424
424
|
two(repo)
|
|
425
|
-
stuck = '[ $role != conflict ] || exit
|
|
425
|
+
stuck = '[ $role != conflict ] || exit 0\n'
|
|
426
426
|
code, ledger, out = walk(tmp_path, capsys, stuck + "FILE=work.txt\n" + WORK + TOGETHER + REPORT)
|
|
427
427
|
assert code == 1, out
|
|
428
428
|
(line,) = [line for line in ledger if "BLOCKED" in line]
|
|
@@ -436,6 +436,19 @@ def test_a_conflict_agent_that_does_not_resolve_blocks_the_ticket_and_keeps_its_
|
|
|
436
436
|
assert out_of("git", "-C", slot, "rev-parse", "-q", "--verify", "MERGE_HEAD") == ""
|
|
437
437
|
|
|
438
438
|
|
|
439
|
+
def test_a_conflict_cli_that_fails_stops_the_run_and_leaves_no_open_merge(repo, tmp_path, capsys):
|
|
440
|
+
two(repo)
|
|
441
|
+
failing = '[ $role != conflict ] || { echo "ERROR: not signed in"; exit 7; }\n'
|
|
442
|
+
code, ledger, out = walk(tmp_path, capsys, failing + "FILE=work.txt\n" + WORK + TOGETHER + REPORT)
|
|
443
|
+
assert code == 4 and "with no report: ERROR: not signed in" in out, out
|
|
444
|
+
assert not [line for line in ledger if "BLOCKED" in line]
|
|
445
|
+
(number,) = [n for n, entry in state_of(tmp_path)["tickets"].items() if entry["phase"] == "merge"]
|
|
446
|
+
assert f"ticket/{number}" in branches()
|
|
447
|
+
for slot in (".", ".agent/worktrees/slot-2"):
|
|
448
|
+
assert out_of("git", "-C", slot, "status", "--porcelain") == ""
|
|
449
|
+
assert out_of("git", "-C", slot, "rev-parse", "-q", "--verify", "MERGE_HEAD") == ""
|
|
450
|
+
|
|
451
|
+
|
|
439
452
|
def test_a_red_gate_blocks_the_ticket_and_the_ticket_it_blocks_never_starts(repo, tmp_path, capsys):
|
|
440
453
|
two(repo, second="01")
|
|
441
454
|
code, ledger, out = walk(tmp_path, capsys, WORK + REPORT, gate="echo CHECK-OUTPUT; exit 1")
|
|
@@ -765,3 +778,36 @@ def test_a_rerun_after_an_interrupt_keeps_the_work_in_each_slot_and_reruns_the_r
|
|
|
765
778
|
assert out_of("git", "show", "feat/x:wip-01.txt") == "half" and out_of("git", "show", "feat/x:wip-02.txt") == "half"
|
|
766
779
|
assert [line for line in ledger if "Ticket 02: resumed in slot 2 with the work the interrupted implementer left" in line]
|
|
767
780
|
assert out_of("git", "status", "--porcelain") == "" and branches() == ["feat/x"]
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def test_the_preflight_asks_each_used_entry_once_and_names_each_cli_that_fails(tmp_path, monkeypatch):
|
|
784
|
+
"""claude answers; codex refuses gpt-astra, and the gpt-6.1-sol call never ends."""
|
|
785
|
+
bin_dir = tmp_path / "bin"
|
|
786
|
+
bin_dir.mkdir()
|
|
787
|
+
(bin_dir / "claude").write_text('#!/bin/sh\ncat > /dev/null\necho "$*" >> "$(dirname "$0")/asked"\n')
|
|
788
|
+
(bin_dir / "codex").write_text('#!/bin/sh\ncat > /dev/null\necho "$*" >> "$(dirname "$0")/asked"\n'
|
|
789
|
+
'case "$*" in *gpt-astra*) echo "ERROR: not supported"; exit 1;; '
|
|
790
|
+
'*gpt-6.1-sol*) sleep 30;; esac\n')
|
|
791
|
+
for cli in ("claude", "codex"):
|
|
792
|
+
(bin_dir / cli).chmod(0o755)
|
|
793
|
+
monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ['PATH']}")
|
|
794
|
+
monkeypatch.setattr(driver_agents, "PREFLIGHT_MINUTES", 0.02)
|
|
795
|
+
models, _ = driver_agents.load_models(None)
|
|
796
|
+
models["reviewer"] = ["openai/gpt-astra@medium"]
|
|
797
|
+
assert driver_agents.used_entries(models) == ["claude-opus-5@medium", "claude-sonnet-5@medium",
|
|
798
|
+
"openai/gpt-6-astra@medium", "openai/gpt-6.1-sol@medium",
|
|
799
|
+
"openai/gpt-astra@medium"]
|
|
800
|
+
assert driver_agents.preflight(models) == [
|
|
801
|
+
"model entry openai/gpt-6.1-sol@medium: the codex CLI failed the preflight (no answer in 0.02 minutes): "
|
|
802
|
+
"no output",
|
|
803
|
+
"model entry openai/gpt-astra@medium: the codex CLI failed the preflight (exit 1): ERROR: not supported"]
|
|
804
|
+
assert len((bin_dir / "asked").read_text().splitlines()) == 5
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def test_last_words_takes_the_result_of_a_stream_json_line(tmp_path):
|
|
808
|
+
log = tmp_path / "agent.log"
|
|
809
|
+
log.write_text('{"type": "system"}\n{"type": "result", "is_error": true, "result": "There is an issue"}\n\n')
|
|
810
|
+
assert driver_agents.last_words(log) == "There is an issue"
|
|
811
|
+
log.write_text("ERROR: plain\n")
|
|
812
|
+
assert driver_agents.last_words(log) == "ERROR: plain"
|
|
813
|
+
assert driver_agents.last_words(tmp_path / "none.log") == "no output"
|
|
@@ -311,6 +311,34 @@ def test_four_rounds_then_blocked_with_the_escalation_at_round_3(tmp_path):
|
|
|
311
311
|
assert rig.state()["tickets"]["03"]["phase"] == "merged"
|
|
312
312
|
|
|
313
313
|
|
|
314
|
+
def test_a_red_gate_on_a_fixer_commit_starts_the_next_round_with_the_record_as_evidence(tmp_path):
|
|
315
|
+
"""Issue 64: the gate of a fix round is red. That is a finding for the next round, not a BLOCKED ticket."""
|
|
316
|
+
rig = Inside(tmp_path, gate={"subjects": ["fix: 01 round 1"]})
|
|
317
|
+
rig.run(plan(**{"01/reviewer": [review("01", [STUCK], lines=(2,)), review("01", lines=(2,))],
|
|
318
|
+
"01/fixer": [fix("01", 1), fix("01", 2, files={"demo/other.py": "x = 1\n"})]}))
|
|
319
|
+
assert rig.code == 0, rig.ledger()
|
|
320
|
+
assert [call["role"] for call in rig.calls("01")] == ["test-writer", "implementer", "reviewer", "fixer", "fixer",
|
|
321
|
+
"reviewer"]
|
|
322
|
+
first, second = (call["sha"] for call in rig.calls("01", "fixer"))
|
|
323
|
+
record = f".agent/implement/01/gate-{first[:7]}.log"
|
|
324
|
+
(gate,) = json.loads((rig.folder() / "findings-1.json").read_text())
|
|
325
|
+
assert (gate["source"], gate["severity"], gate["kind"]) == ("gate", "critical", "code")
|
|
326
|
+
assert gate["title"] == f"the gate is red on {first[:7]}" and record in gate["evidence"]
|
|
327
|
+
assert rig.led(f"Ticket 01: critical finding of kind code from the gate at {record}:0: the gate is red on {first[:7]}.")
|
|
328
|
+
assert "findings-1.json" in rig.prompt("fixer-2") and rig.state()["tickets"]["01"]["round"] == 2
|
|
329
|
+
# No review saw the red tip: the re-review covers both fixes, and the proof that round 1 made due stays due.
|
|
330
|
+
again = rig.prompt("reviewer-2")
|
|
331
|
+
assert f"The fix is {rig.sha('01', 'implementer')}..{second}" in again and "demo/export.py:2" in again
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_a_red_gate_on_the_fixer_commit_of_round_4_blocks_the_ticket(tmp_path):
|
|
335
|
+
rig = Inside(tmp_path, gate={"subjects": ["fix: 01 round 4"]})
|
|
336
|
+
rig.run(plan(**{"01/reviewer": [review("01", [STUCK])] * 4, "01/fixer": [fix("01", n) for n in (1, 2, 3, 4)]}))
|
|
337
|
+
last = rig.calls("01", "fixer")[-1]["sha"]
|
|
338
|
+
assert f"BLOCKED at the gate: the gate is red on {last[:7]}" in blocked(rig)
|
|
339
|
+
assert len(rig.calls("01", "reviewer")) == 4 and not (rig.folder() / "findings-4.json").exists()
|
|
340
|
+
|
|
341
|
+
|
|
314
342
|
def test_round_3_stays_on_its_entry_when_the_implementer_has_no_next_entry(tmp_path):
|
|
315
343
|
rig = Inside(tmp_path, ONE, {"implementer": [OPUS]})
|
|
316
344
|
rig.run(plan(**{"01/reviewer": [review("01", [STUCK])] * 3 + [review("01")], "01/fixer": [fix("01", n) for n in (1, 2, 3)]}))
|
|
@@ -8,10 +8,10 @@ import pytest
|
|
|
8
8
|
from test_cli import RUN, SKILL, drive, env, make_repo, three # noqa: F401 (env is a fixture)
|
|
9
9
|
|
|
10
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"],
|
|
11
|
+
"implementer": ["claude-sonnet-5@medium", "openai/gpt-6.1-sol@medium"],
|
|
12
|
+
"test-writer": ["claude-sonnet-5@medium", "openai/gpt-6.1-sol@medium"],
|
|
13
|
+
"reviewer": ["openai/gpt-6-astra@medium", "claude-opus-5@medium"],
|
|
14
|
+
"final-reviewer": ["openai/gpt-6-astra@medium", "claude-opus-5@medium"],
|
|
15
15
|
}
|
|
16
16
|
ENTRY = re.compile(r"^(?:(?:anthropic|openai)/)?[\w.-]+@(?:minimal|low|medium|high|xhigh|max)$")
|
|
17
17
|
|
|
@@ -102,7 +102,8 @@ def git(*a):
|
|
|
102
102
|
conf = json.loads((rig / "gate.json").read_text())
|
|
103
103
|
(rig / "gates").mkdir(exist_ok=True)
|
|
104
104
|
branch = git("rev-parse", "--abbrev-ref", "HEAD")
|
|
105
|
-
|
|
105
|
+
subject = git("log", "-1", "--format=%s")
|
|
106
|
+
code = 1 if conf.get("all") or branch in conf.get("red", []) or subject in conf.get("subjects", []) else 0
|
|
106
107
|
rec = dict(t0=time.time(), cwd=os.path.realpath(os.getcwd()), commit=git("rev-parse", "HEAD"), branch=branch,
|
|
107
108
|
status=git("status", "--short"), base=os.environ.get("IMPLEMENT_SPEC_BASE"),
|
|
108
109
|
pycache=os.environ.get("PYTHONPYCACHEPREFIX"), exit=code)
|
package/skills-manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
3
|
+
"generatedAt": "2026-10-15T17:09:44.505Z",
|
|
4
4
|
"skills": [
|
|
5
5
|
{
|
|
6
6
|
"name": "backlog",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
48
|
"name": "implement-spec",
|
|
49
|
-
"version": "0.1.
|
|
50
|
-
"hash": "sha256-
|
|
49
|
+
"version": "0.1.11",
|
|
50
|
+
"hash": "sha256-c58d05ad78d3d1ae1381c02a96f4e5ca64f6387cea2b16ac5e1997bf52767467",
|
|
51
51
|
"description": "Implement a specification in code."
|
|
52
52
|
},
|
|
53
53
|
{
|