@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jesdi/skills",
3
- "version": "0.0.33",
3
+ "version": "0.0.35",
4
4
  "description": "jesdi's agent skills (content package consumed by @jesdi/skills-cli)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- return (False, []) if only else driver_final.run(run, tickets)
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: list[str] = field(default_factory=list) # `file:line` of each proof the last fix made due 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
- job.proofs = set(followed)
137
- job.again = [f"{file}:{line}" for (file, line), changed in sorted(followed.items()) if changed]
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
- with during("the gate"):
166
- run.gate(ticket.number, slot, job.tip)
167
- with during("the review"):
168
- found = _review(job)
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
- raise Blocked(f"the gate is red on {commit[:7]}, record {record}")
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\nprintf '%s\\0' \"$@\" >> {log}\nprintf '\\036' >> {log}\nexit 1\n")
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 7\n", "--only", "reviewer")
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 7\n'
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
- code = 1 if conf.get("all") or branch in conf.get("red", []) else 0
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)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-15T12:44:26.687Z",
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.9",
50
- "hash": "sha256-a2864cb6013ba8ee10af5b597b4376b89507efce8c6acecfbeca5db7389623ea",
49
+ "version": "0.1.11",
50
+ "hash": "sha256-c58d05ad78d3d1ae1381c02a96f4e5ca64f6387cea2b16ac5e1997bf52767467",
51
51
  "description": "Implement a specification in code."
52
52
  },
53
53
  {