Coverage for src/keel/fixloop.py: 100%

253 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-02 20:26 +0000

1"""s9 fixloop — who fixes a review finding, and the brief they are handed (#1016). 

2 

3``ship.md`` s9 said *"aggregate findings → hand to the implementer → fix → push"*. When 

4the implementer was a delegate there was nothing behind the arrow: no command, no prompt 

5shape, no ownership rule. In the live run two blocking majors sat with nobody assigned 

6and the host — the orchestrator whose quota the delegation existed to protect — wrote the 

7fix itself. 

8 

9This module is the pure half of the answer: 

10 

11* :func:`render_brief` — the deterministic fix brief. Findings grouped by severity with 

12 ``file:line`` anchors and the reviewer's reproduction, the round and its budget, and the 

13 narrowed-re-review sentence the next reviewer will be held to. Byte-stable for identical 

14 input, which is what lets ``keel fixloop brief`` be snapshot-tested and lets two hosts 

15 hand the same delegate the same words. 

16* :func:`resolve_fixer` — the escalation ladder ``implementer → gate → host``, a pure 

17 function of *(round, provider availability, budget)* and nothing else. Round 1 goes to 

18 the seat ``knobs.team.fix`` resolved (the implementer, by default); a failed round 

19 escalates one rung; an unavailable provider is skipped rather than dispatched to; the 

20 budget (**≤3 rounds**, unchanged) still ends the loop. 

21* :func:`brief_document` — the JSON ``keel fixloop brief`` prints and the adapter reads, 

22 including the ``keel delegate run --role fix`` invocation for the resolved seat. 

23 

24The ladder is deliberately short and deliberately terminal. Each rung is a *different* 

25opinion — the seat that wrote the change, the seat the project already trusts to gate it, 

26and the host that is always there — so a duplicate rung is dropped rather than dispatched 

27twice, and running off the end stays with the last usable fixer instead of inventing one. 

28 

29Pure and deterministic: no wall-clock, no randomness, no I/O. Severity vocabulary and 

30ordering come from :mod:`keel.findings`, so the loop-exit rule (``critical``/``major`` 

31block, ``minor`` is a gated suggestion, ``nit`` is advisory) has exactly one definition. 

32""" 

33 

34from __future__ import annotations 

35 

36from collections.abc import Iterable, Mapping, Sequence 

37from dataclasses import dataclass 

38from typing import Any 

39 

40from . import findings as findings_mod 

41 

42SCHEMA_VERSION = "keel.fixloop.v1" 

43 

44#: Marker on the rendered brief, so a brief that reaches a PR is recognisable as one. 

45BRIEF_MARKER = "<!-- keel.fixloop-brief.v1 -->" 

46 

47#: Review-fix rounds a run may spend. **Unchanged by #1016** — the ladder decides *who* 

48#: fixes, never *how often*. Mirrors :data:`keel.runcontrols.DEFAULT_FIXLOOP_CAP`, which 

49#: caps the same loop from the run-controls side. 

50DEFAULT_ROUND_BUDGET = 3 

51 

52#: The ladder, in order. ``implementer`` is the resolved ``assignment.fix`` seat (the 

53#: provider that implemented, unless ``knobs.team.fix`` names another); ``gate`` is the 

54#: mandatory second opinion; ``host`` is the agent driving the run. 

55STAGES = ("implementer", "gate", "host") 

56 

57#: Why a hop happened. 

58HOP_REASONS = ("start", "round-failed", "provider-unavailable", "ladder-exhausted") 

59 

60#: Outcome of :func:`resolve_fixer`, plus the ``no-config`` refusal 

61#: :func:`no_config_document` renders — a fix round resolved against a team policy that 

62#: could not be read is a fix round handed to the host by accident. 

63STATUSES = ("assigned", "budget-exhausted", "no-fixer", "no-config") 

64 

65#: Default host agent, mirroring :data:`keel.team.HOST_DEFAULT`. 

66HOST_DEFAULT = "claude" 

67 

68#: The sentence a narrowed re-reviewer is held to. Quoted verbatim into the brief so the 

69#: fixer knows exactly how small the next review is, and so the adapter does not improvise 

70#: a looser one. 

71NARROWED_INSTRUCTION = ( 

72 "verify only the applied fix in commit {sha}; do not re-review what you already approved" 

73) 

74 

75#: Placeholder when the fix commit does not exist yet — it cannot, the fix is unwritten. 

76UNKNOWN_SHA = "<fix-commit-sha>" 

77 

78#: Most reviewer-supplied text the brief embeds per field, and the most lines of it. The 

79#: brief *becomes* a delegate's prompt, so an unbounded finding is an unbounded prompt. 

80MAX_QUOTED_CHARS = 2000 

81MAX_QUOTED_LINES = 40 

82 

83#: Most of a reviewer-supplied value the brief renders inline — a headline, a reviewer id. 

84MAX_INLINE_CHARS = 200 

85 

86#: The brief's own trailer keys. A reviewer line that reads as one is rendered as inline 

87#: code inside the quote, so it cannot be mistaken for the brief's trailer. 

88_TRAILER_KEYS = ("blocking:", "head:") 

89 

90_TRUNCATED = "… (truncated)" 

91 

92#: Indent for a quoted block, lining it up under its ` - label:` bullet. 

93_QUOTE_INDENT = " " 

94 

95_BLOCKING = ("critical", "major") 

96 

97 

98class FixloopError(ValueError): 

99 """A fix-loop input that cannot be read: a bad round, or a malformed finding.""" 

100 

101 

102@dataclass(frozen=True) 

103class Rung: 

104 """One rung of the escalation ladder: a stage, a seat, and where the seat came from.""" 

105 

106 stage: str 

107 provider: str 

108 name: str 

109 kind: str 

110 source: str 

111 model: str | None = None 

112 effort: str | None = None 

113 #: ``"implementer"`` when this seat is the fix alias resolved — *you are fixing this 

114 #: because you implemented it*, which is the whole point of the default. 

115 alias: str | None = None 

116 available: bool = True 

117 

118 def as_dict(self) -> dict[str, Any]: 

119 return { 

120 "stage": self.stage, 

121 "provider": self.provider, 

122 "name": self.name, 

123 "kind": self.kind, 

124 "model": self.model, 

125 "effort": self.effort, 

126 "source": self.source, 

127 "alias": self.alias, 

128 "available": self.available, 

129 } 

130 

131 

132def contract_as_dict() -> dict[str, Any]: 

133 """The pure-core fix-loop contract an agentic command publishes.""" 

134 return { 

135 "schema_version": SCHEMA_VERSION, 

136 "deterministic": True, 

137 "stdlib_only": True, 

138 "round_budget": DEFAULT_ROUND_BUDGET, 

139 "ladder": list(STAGES), 

140 "hop_reasons": list(HOP_REASONS), 

141 "statuses": list(STATUSES), 

142 "severities": list(findings_mod.SEVERITIES), 

143 "blocking_severities": list(_BLOCKING), 

144 "renderer": {"brief": "keel.fixloop.render_brief", "marker": BRIEF_MARKER}, 

145 "dispatch": "keel delegate run --role fix", 

146 "narrowed_instruction": NARROWED_INSTRUCTION.format(sha=UNKNOWN_SHA), 

147 } 

148 

149 

150def _text(value: Any) -> str | None: 

151 return value.strip() if isinstance(value, str) and value.strip() else None 

152 

153 

154def _seat_of(raw: Any, *, stage: str, default_source: str) -> Rung | None: 

155 """A seat record from ``assignment`` -> a :class:`Rung`, or ``None`` when unusable.""" 

156 if not isinstance(raw, Mapping): 

157 return None 

158 provider = _text(raw.get("provider")) 

159 if provider is None: 

160 return None 

161 name = _text(raw.get("name")) or provider 

162 return Rung( 

163 stage=stage, 

164 provider=provider, 

165 name=name, 

166 kind=_text(raw.get("kind")) or "provider", 

167 source=_text(raw.get("source")) or default_source, 

168 model=_text(raw.get("model")), 

169 effort=_text(raw.get("effort")), 

170 alias=_text(raw.get("alias")), 

171 ) 

172 

173 

174def ladder( 

175 assignment: Mapping[str, Any] | None, 

176 *, 

177 host_agent: str = HOST_DEFAULT, 

178 unavailable: Iterable[str] = (), 

179) -> tuple[tuple[Rung, ...], list[str]]: 

180 """The escalation ladder for one assignment, plus the warnings building it produced. 

181 

182 ``assignment`` is what ``keel plan``/``keel ship --json`` render (see 

183 :func:`keel.team.resolve_assignment`). Rung 1 is ``assignment.fix``, already resolved 

184 — the ``implementer`` alias has been substituted for the seat that actually ran, so a 

185 delegated implementation is handed its own findings back rather than the host's. 

186 

187 A rung that repeats an earlier rung's ``provider`` is **dropped**, not dispatched: the 

188 ladder exists to reach a different pair of eyes, and ``gate.provider == fix.provider`` 

189 escalates to the same seat that just failed the round. The comparison is on the 

190 provider and not the bare name, because ``subagent:opus-reviewer`` and 

191 ``opus-reviewer`` share a name and are two genuinely different seats. 

192 """ 

193 blocked = {name for name in unavailable if isinstance(name, str) and name.strip()} 

194 record = assignment if isinstance(assignment, Mapping) else {} 

195 first = _seat_of(record.get("fix"), stage="implementer", default_source="team.fix") 

196 if first is None: 

197 first = _seat_of(record.get("implementer"), stage="implementer", default_source="host") 

198 rungs: list[Rung] = [] 

199 warnings: list[str] = [] 

200 if first is not None: 

201 rungs.append(first) 

202 gate = _seat_of(record.get("gate"), stage="gate", default_source="team.gate") 

203 if gate is not None: 

204 # Compared on `provider`, which is the seat's identity — `subagent:opus-reviewer` 

205 # and `opus-reviewer` share a `name` and are two different seats (a host subagent 

206 # and a vendor), so a name comparison would merge two rungs that are not one. 

207 if any(rung.provider == gate.provider for rung in rungs): 

208 warnings.append( 

209 f"team.gate provider {gate.provider!r} is already the fixer; the ladder " 

210 "skips it — escalating to the seat that just failed the round is not an " 

211 "escalation" 

212 ) 

213 else: 

214 rungs.append(gate) 

215 host = Rung(stage="host", provider=host_agent, name=host_agent, kind="provider", source="host") 

216 # No warning for a host that is already seated — on a project with no `knobs.team` the 

217 # fixer *is* the host, and a warning on every round of the default path is noise. The 

218 # short ladder is reported where it costs something: the round that wanted to escalate 

219 # and had nowhere to go says so, in `resolve_fixer`. 

220 if not any(rung.provider == host.provider for rung in rungs): 

221 rungs.append(host) 

222 resolved = tuple( 

223 Rung( 

224 stage=rung.stage, 

225 provider=rung.provider, 

226 name=rung.name, 

227 kind=rung.kind, 

228 source=rung.source, 

229 model=rung.model, 

230 effort=rung.effort, 

231 alias=rung.alias, 

232 # Either spelling: an operator writes down what the assignment showed them, 

233 # and a subagent seat shows `provider: subagent:x` beside `name: x`. 

234 available=rung.name not in blocked and rung.provider not in blocked, 

235 ) 

236 for rung in rungs 

237 ) 

238 return resolved, warnings 

239 

240 

241def _hop( 

242 *, 

243 round_number: int, 

244 previous: Rung | None, 

245 rung: Rung, 

246 reason: str, 

247 used: bool, 

248) -> dict[str, Any]: 

249 return { 

250 "round": round_number, 

251 "from": previous.stage if previous is not None else None, 

252 "to": rung.stage, 

253 "provider": rung.provider, 

254 "reason": reason, 

255 "used": used, 

256 } 

257 

258 

259def _walk( 

260 rungs: Sequence[Rung], 

261 *, 

262 round_number: int, 

263) -> tuple[Rung | None, list[dict[str, Any]]]: 

264 """Walk the ladder to ``round_number``; the trail is what the ledger records.""" 

265 hops: list[dict[str, Any]] = [] 

266 index = 0 

267 current: Rung | None = None 

268 for number in range(1, round_number + 1): 

269 if current is not None: 

270 if index + 1 >= len(rungs): 

271 hops.append( 

272 _hop( 

273 round_number=number, 

274 previous=current, 

275 rung=current, 

276 reason="ladder-exhausted", 

277 used=True, 

278 ) 

279 ) 

280 continue 

281 index += 1 

282 while index < len(rungs) and not rungs[index].available: 

283 hops.append( 

284 _hop( 

285 round_number=number, 

286 previous=current, 

287 rung=rungs[index], 

288 reason="provider-unavailable", 

289 used=False, 

290 ) 

291 ) 

292 index += 1 

293 if index >= len(rungs): 

294 if current is None: 

295 return None, hops 

296 hops.append( 

297 _hop( 

298 round_number=number, 

299 previous=current, 

300 rung=current, 

301 reason="ladder-exhausted", 

302 used=True, 

303 ) 

304 ) 

305 index = len(rungs) - 1 

306 continue 

307 previous, current = current, rungs[index] 

308 hops.append( 

309 _hop( 

310 round_number=number, 

311 previous=previous, 

312 rung=current, 

313 reason="start" if previous is None else "round-failed", 

314 used=True, 

315 ) 

316 ) 

317 return current, hops 

318 

319 

320def resolve_fixer( 

321 assignment: Mapping[str, Any] | None, 

322 *, 

323 round_number: int = 1, 

324 unavailable: Iterable[str] = (), 

325 budget: int = DEFAULT_ROUND_BUDGET, 

326 host_agent: str = HOST_DEFAULT, 

327) -> dict[str, Any]: 

328 """Who fixes round ``round_number`` — a pure function of round, availability, budget. 

329 

330 Round 1 is the resolved ``fix`` seat. Every failed round escalates exactly one rung, 

331 an unavailable provider is skipped on the way past, and a round past the last rung 

332 stays with the last usable fixer rather than fabricating one. Past ``budget`` there is 

333 no fixer at all: the loop is over and the issue is marked blocked with the outstanding 

334 findings quoted, which is the s9 rule #1016 did not change. 

335 """ 

336 if not isinstance(round_number, int) or isinstance(round_number, bool) or round_number < 1: 

337 raise FixloopError(f"round must be a positive integer, got {round_number!r}") 

338 limit = budget if isinstance(budget, int) and not isinstance(budget, bool) and budget > 0 else 0 

339 if limit <= 0: 

340 raise FixloopError(f"budget must be a positive integer, got {budget!r}") 

341 rungs, warnings = ladder(assignment, host_agent=host_agent, unavailable=unavailable) 

342 ladder_records = [rung.as_dict() for rung in rungs] 

343 if round_number > limit: 

344 return { 

345 "schema_version": SCHEMA_VERSION, 

346 "round": round_number, 

347 "budget": limit, 

348 "within_budget": False, 

349 "status": "budget-exhausted", 

350 "blocked": True, 

351 "fixer": None, 

352 "ladder": ladder_records, 

353 "hops": [], 

354 "next_action": ( 

355 f"the {limit}-round review-fix budget is spent: mark the issue blocked, " 

356 "quote the outstanding findings on the PR, and stop — a further round " 

357 "needs an explicit operator `keel fixloop brief --budget` override" 

358 ), 

359 "warnings": warnings, 

360 } 

361 # Bounded: one round advances one rung, so no round past `len(rungs) + 1` can reach a 

362 # rung — or a hop — the round before it did not. Walking the literal round number made 

363 # `--round 1000000` a million identical `ladder-exhausted` entries in the document an 

364 # adapter has to read, for no more information than the first one carries. 

365 walk_to = min(round_number, len(rungs) + 1) 

366 fixer, hops = _walk(rungs, round_number=walk_to) 

367 if hops and walk_to < round_number: 

368 # The trail is clamped; the round it ends on is not. Re-stamp the terminal hop so 

369 # the record still says which round this resolution is for. 

370 hops[-1] = {**hops[-1], "round": round_number} 

371 if fixer is None: 

372 return { 

373 "schema_version": SCHEMA_VERSION, 

374 "round": round_number, 

375 "budget": limit, 

376 "within_budget": True, 

377 "status": "no-fixer", 

378 "blocked": True, 

379 "fixer": None, 

380 "ladder": ladder_records, 

381 "hops": hops, 

382 "next_action": ( 

383 "every rung of the ladder is unavailable: mark the issue blocked and say " 

384 "which providers were refused — a fix nobody can run is not a fix" 

385 ), 

386 "warnings": warnings, 

387 } 

388 final = hops[-1] 

389 if final["reason"] == "ladder-exhausted": 

390 warnings.append( 

391 f"round {round_number} stays with {fixer.provider!r}: the ladder has no rung " 

392 "left to escalate to" 

393 ) 

394 record = fixer.as_dict() 

395 record["reason"] = final["reason"] 

396 return { 

397 "schema_version": SCHEMA_VERSION, 

398 "round": round_number, 

399 "budget": limit, 

400 "within_budget": True, 

401 "status": "assigned", 

402 "blocked": False, 

403 "fixer": record, 

404 "ladder": ladder_records, 

405 "hops": hops, 

406 "next_action": ( 

407 f"dispatch round {round_number} to {fixer.provider!r} with " 

408 "`keel delegate run --role fix`" 

409 ), 

410 "warnings": warnings, 

411 } 

412 

413 

414def no_config_document(*, path: str, reason: str, round_number: int = 1) -> dict[str, Any]: 

415 """The fail-closed answer when the project's team policy cannot be read. 

416 

417 ``knobs.team.fix`` is *the* input to this command: it says whether a delegate's 

418 findings go back to that delegate or to the host. Resolving it against an 

419 unconfigured policy answers "the host", silently — which is the exact failure #1016 

420 exists to prevent, arrived at by a missing file rather than by a decision. So an 

421 unreadable config is a refusal, and an operator who really means "no policy, the host 

422 fixes" says so with ``--no-project``. 

423 """ 

424 return { 

425 "schema_version": SCHEMA_VERSION, 

426 "round": round_number, 

427 "status": "no-config", 

428 "blocked": True, 

429 "fixer": None, 

430 "ladder": [], 

431 "hops": [], 

432 "config_path": path, 

433 "reason": reason, 

434 "next_action": ( 

435 f"cannot read the project config at {path}: {reason}. `knobs.team.fix` decides " 

436 "whether this round goes back to the delegate that implemented or to the host, " 

437 "so it is not guessed — pass --project/--root, or --no-project to say " 

438 "deliberately that there is no policy and the host fixes" 

439 ), 

440 "warnings": [], 

441 } 

442 

443 

444def parse_findings(raw: Any) -> list[findings_mod.Finding]: 

445 """Read the ``--findings`` document into :class:`keel.findings.Finding` objects. 

446 

447 Accepts the bare list reviewers return and the ``{"findings": [...]}`` envelope 

448 ``keel review`` bundles use, so an operator does not have to reshape the file between 

449 the review that produced it and the fix loop that consumes it. 

450 """ 

451 items = raw.get("findings") if isinstance(raw, Mapping) else raw 

452 if not isinstance(items, Sequence) or isinstance(items, (str, bytes, bytearray)): 

453 raise FixloopError( 

454 "findings must be a JSON array of findings, or an object with a 'findings' array" 

455 ) 

456 parsed: list[findings_mod.Finding] = [] 

457 for index, item in enumerate(items): 

458 if not isinstance(item, Mapping): 

459 raise FixloopError(f"findings[{index}] is not an object") 

460 severity = _text(item.get("severity")) 

461 if severity is None: 

462 raise FixloopError(f"findings[{index}] has no severity") 

463 line = item.get("line") 

464 try: 

465 parsed.append( 

466 findings_mod.Finding( 

467 severity=severity, 

468 message=_text(item.get("message")) or "(no message)", 

469 source=_text(item.get("source")) or "reviewer", 

470 path=_text(item.get("path")), 

471 line=line if isinstance(line, int) and not isinstance(line, bool) else None, 

472 anchorable=bool(item.get("anchorable")), 

473 reproduction=_text(item.get("reproduction")), 

474 ) 

475 ) 

476 except findings_mod.FindingError as exc: 

477 raise FixloopError(f"findings[{index}]: {exc}") from None 

478 return parsed 

479 

480 

481def neutralise(text: str) -> str: 

482 """Defang the one token reviewer text must never be able to forge. 

483 

484 The brief opens with an HTML-comment marker and is handed to a delegate as its 

485 prompt. A reviewer who can emit ``<!--`` can emit a second 

486 ``keel.fixloop-brief.v1`` marker — or any other keel artifact marker — so the opener 

487 and closer are broken here, once, for every reviewer-supplied string. 

488 """ 

489 return text.replace("<!--", "< !--").replace("-->", "-- >") 

490 

491 

492def _inline(value: Any, *, fallback: str = "") -> str: 

493 """One reviewer-supplied value, safe to interpolate into the middle of a line. 

494 

495 First line only, defanged and capped: a value that reaches the middle of a rendered 

496 line cannot be allowed to carry a newline, because the text after that newline would 

497 start a line of the brief that keel did not write. 

498 """ 

499 if not isinstance(value, str) or not value.strip(): 

500 return fallback 

501 first = neutralise(value.strip().splitlines()[0]).strip() 

502 if len(first) > MAX_INLINE_CHARS: 

503 first = first[:MAX_INLINE_CHARS].rstrip() + _TRUNCATED 

504 return first or fallback 

505 

506 

507def _quoted_line(line: str) -> str: 

508 """One line of reviewer text, with anything that reads as structure defanged.""" 

509 stripped = line.strip() 

510 if stripped.startswith("#"): 

511 # Escaped rather than dropped: the reviewer wrote it and the fixer should see it. 

512 # It just must not render as a heading of its own inside the quote. 

513 return line.rstrip().replace("#", "\\#", 1) 

514 # ⚡ Bolt Optimization: Use tuple directly in startswith instead of generator overhead 

515 if stripped.lower().startswith(_TRAILER_KEYS): 

516 return "`" + stripped.replace("`", "'") + "`" 

517 return line.rstrip() 

518 

519 

520def quote(text: Any, *, indent: str = _QUOTE_INDENT) -> list[str]: 

521 """Reviewer text as a blockquote: quoted **data**, never instructions. 

522 

523 Findings are the one part of the brief keel did not write, and the brief becomes the 

524 fixer's ``--prompt-file``. A finding whose message carried its own 

525 ``## Rules for this round`` section, a second brief marker and a forged 

526 ``blocking: no`` trailer would otherwise render as brief structure — reviewer text 

527 giving the fixer orders keel never issued. 

528 

529 So every line is prefixed with ``> ``: no line of reviewer text can sit at the start 

530 of a line of the brief, which is where every structural token of this format lives. 

531 On top of that the comment opener is defanged (:func:`neutralise`), a leading ``#`` 

532 is escaped, a line that reads as one of the brief's trailer keys becomes inline code, 

533 and the whole field is capped — a prompt has a budget. 

534 """ 

535 if not isinstance(text, str): 

536 text = "" 

537 body = neutralise(text) 

538 truncated = False 

539 if len(body) > MAX_QUOTED_CHARS: 

540 body, truncated = body[:MAX_QUOTED_CHARS], True 

541 lines = body.replace("\r\n", "\n").replace("\r", "\n").split("\n") 

542 if len(lines) > MAX_QUOTED_LINES: 

543 lines, truncated = lines[:MAX_QUOTED_LINES], True 

544 rendered = [] 

545 for line in lines: 

546 content = _quoted_line(line) 

547 rendered.append(f"{indent}> {content}" if content else f"{indent}>") 

548 if truncated: 

549 rendered.append(f"{indent}> {_TRUNCATED}") 

550 return rendered 

551 

552 

553def _anchor(finding: findings_mod.Finding) -> str: 

554 if not isinstance(finding.path, str) or not finding.path.strip(): 

555 return "whole PR" 

556 # A backtick would close the code span the anchor is rendered in. 

557 path = _inline(finding.path.replace("`", "'"), fallback="whole PR") 

558 if finding.line is None: 

559 return path 

560 return f"{path}:{finding.line}" 

561 

562 

563def _finding_block(index: int, finding: findings_mod.Finding) -> list[str]: 

564 """One finding: a scannable headline, then everything reviewer-written as a quote.""" 

565 message = finding.message if isinstance(finding.message, str) else "" 

566 # The headline stays one scannable line and the rest of the message is quoted beneath 

567 # it rather than dropped — the same split `cli._cmd_run_gates` makes for a multi-line 

568 # gate message, for the same reason: line two onwards is often where the detail is. 

569 headline, *detail = message.splitlines() or [""] 

570 lines = [ 

571 ( 

572 f"{index}. **{finding.severity}** · `{_anchor(finding)}` · " 

573 f"{_inline(headline, fallback='(no message)')}" 

574 ), 

575 f" - reported by: {_inline(finding.source, fallback='an unnamed reviewer')}", 

576 f" - decision: {findings_mod.decision_for(finding.severity)}", 

577 ] 

578 if detail: 

579 lines.append(" - the rest of the reviewer's message:") 

580 lines.extend(quote("\n".join(detail))) 

581 if finding.reproduction is not None: 

582 lines.append(" - reproduction:") 

583 lines.extend(quote(finding.reproduction)) 

584 else: 

585 lines.append(" - reproduction: not supplied by the reviewer — reproduce it yourself") 

586 return lines 

587 

588 

589def re_review(blocked: bool, *, fix_sha: str | None = None) -> dict[str, str]: 

590 """How the next review is scoped: a blocker re-reviews everything, a suggestion does not.""" 

591 sha = _text(fix_sha) or UNKNOWN_SHA 

592 if blocked: 

593 return { 

594 "mode": "full", 

595 "instruction": ( 

596 "a blocking finding triggers a full re-review of the change; the reviewer " 

597 "keeps their original codename" 

598 ), 

599 } 

600 return {"mode": "narrowed", "instruction": NARROWED_INSTRUCTION.format(sha=sha)} 

601 

602 

603def render_brief( 

604 *, 

605 pr_number: int | None, 

606 round_number: int, 

607 findings: Sequence[findings_mod.Finding] = (), 

608 fixer: Mapping[str, Any] | None = None, 

609 budget: int = DEFAULT_ROUND_BUDGET, 

610 head_sha: str | None = None, 

611 issue_number: int | None = None, 

612 fix_sha: str | None = None, 

613) -> str: 

614 """Render the fix brief handed to the round's fixer. Byte-stable for identical input.""" 

615 ordered = findings_mod.sort_findings(list(findings)) 

616 verdict = findings_mod.summarize(list(findings)) 

617 seat = fixer if isinstance(fixer, Mapping) else {} 

618 provider = _text(seat.get("provider")) or "unassigned" 

619 stage = _text(seat.get("stage")) or "implementer" 

620 source = _text(seat.get("source")) or "unresolved" 

621 alias_note = ( 

622 " — you are fixing this because you implemented it" 

623 if _text(seat.get("alias")) == "implementer" 

624 else "" 

625 ) 

626 pr_label = f"#{pr_number}" if isinstance(pr_number, int) else "the open PR" 

627 lines = [ 

628 BRIEF_MARKER, 

629 f"head: {_text(head_sha) or '<head-sha>'}", 

630 "", 

631 f"# Fix round {round_number} of {budget} — PR {pr_label}", 

632 "", 

633 ( 

634 f"You are the fixer for this round: `{provider}` (ladder stage `{stage}`, " 

635 f"from `{source}`){alias_note}." 

636 ), 

637 ] 

638 if isinstance(issue_number, int): 

639 lines.append(f"The change closes issue #{issue_number}.") 

640 lines.extend( 

641 [ 

642 "", 

643 ( 

644 "Fix the findings below in the run's worktree, then commit and push to " 

645 "the PR branch. Do not open a new PR, do not re-scope the change, and do " 

646 "not fix anything the reviewers did not raise." 

647 ), 

648 "", 

649 "## Findings", 

650 "", 

651 ] 

652 ) 

653 counter = 0 

654 for severity in findings_mod.SEVERITIES: 

655 group = [finding for finding in ordered if finding.severity == severity] 

656 if not group: 

657 continue 

658 decision = findings_mod.decision_for(severity) 

659 lines.append(f"### {severity} — {len(group)} ({decision})") 

660 lines.append("") 

661 for finding in group: 

662 counter += 1 

663 lines.extend(_finding_block(counter, finding)) 

664 lines.append("") 

665 if counter == 0: 

666 lines.extend(["No findings were supplied — there is nothing to fix this round.", ""]) 

667 lines.extend( 

668 [ 

669 "## Rules for this round", 

670 "", 

671 ( 

672 "- `critical`/`major` block the merge; `minor` is a gated suggestion — " 

673 "apply it or obtain a recorded `keel.deferral.v1` deferral; `nit` is " 

674 "advisory." 

675 ), 

676 ( 

677 f"- This is round {round_number} of a {budget}-round budget. Exceeding it " 

678 "marks the issue blocked with the outstanding findings quoted." 

679 ), 

680 ( 

681 "- Report what you changed and what you ran. A verification you did not " 

682 "run is not evidence." 

683 ), 

684 "", 

685 "## Re-review after your push", 

686 "", 

687 ] 

688 ) 

689 scope = re_review(verdict.blocked, fix_sha=fix_sha) 

690 if scope["mode"] == "full": 

691 lines.append(f"- Scope: **full** — {scope['instruction']}.") 

692 else: 

693 lines.append(f'- Scope: **narrowed** — the reviewer is told: "{scope["instruction"]}".') 

694 lines.append( 

695 "- Keep the diff that small. A narrowed reviewer that finds a NEW blocker " 

696 "escalates the loop back to a full re-review." 

697 ) 

698 lines.extend( 

699 [ 

700 "", 

701 "## Counts", 

702 "", 

703 "| severity | count |", 

704 "| --- | --- |", 

705 ] 

706 ) 

707 lines.extend( 

708 f"| {severity} | {verdict.counts[severity]} |" for severity in findings_mod.SEVERITIES 

709 ) 

710 lines.append("") 

711 lines.append(f"blocking: {'yes' if verdict.blocked else 'no'}") 

712 return "\n".join(lines) + "\n" 

713 

714 

715def dispatch_argv( 

716 fixer: Mapping[str, Any] | None, 

717 *, 

718 prompt_file: str, 

719 cwd: str | None = None, 

720 timeout: int | None = None, 

721 project: str | None = None, 

722) -> list[str] | None: 

723 """The ``keel delegate run --role fix`` argv for a resolved seat, or ``None``. 

724 

725 ``None`` for a host subagent seat (``kind: subagent``) and for no seat at all: a 

726 Claude-class subagent is dispatched by the host agent and never reaches 

727 ``keel delegate run``, exactly as s4 dispatches an implementer. 

728 """ 

729 if not isinstance(fixer, Mapping): 

730 return None 

731 provider = _text(fixer.get("provider")) 

732 if provider is None or _text(fixer.get("kind")) == "subagent": 

733 return None 

734 argv = ["keel", "delegate", "run", "--provider", provider, "--role", "fix"] 

735 argv.extend(["--prompt-file", prompt_file]) 

736 if _text(cwd) is not None: 

737 argv.extend(["--cwd", cwd.strip()]) 

738 if isinstance(timeout, int) and not isinstance(timeout, bool) and timeout > 0: 

739 argv.extend(["--timeout", str(timeout)]) 

740 model = _text(fixer.get("model")) 

741 if model is not None: 

742 argv.extend(["--model", model]) 

743 effort = _text(fixer.get("effort")) 

744 if effort is not None: 

745 argv.extend(["--effort", effort]) 

746 if _text(project) is not None: 

747 argv.extend(["--project", project.strip()]) 

748 return argv 

749 

750 

751def brief_document( 

752 *, 

753 assignment: Mapping[str, Any] | None, 

754 findings: Sequence[findings_mod.Finding] = (), 

755 pr_number: int | None = None, 

756 round_number: int = 1, 

757 budget: int = DEFAULT_ROUND_BUDGET, 

758 unavailable: Iterable[str] = (), 

759 host_agent: str = HOST_DEFAULT, 

760 head_sha: str | None = None, 

761 issue_number: int | None = None, 

762 fix_sha: str | None = None, 

763 prompt_file: str = "-", 

764 cwd: str | None = None, 

765 timeout: int | None = None, 

766 project: str | None = None, 

767) -> dict[str, Any]: 

768 """The whole s9 answer for one round: who fixes, the brief, and how to dispatch it.""" 

769 resolution = resolve_fixer( 

770 assignment, 

771 round_number=round_number, 

772 unavailable=unavailable, 

773 budget=budget, 

774 host_agent=host_agent, 

775 ) 

776 verdict = findings_mod.summarize(list(findings)) 

777 brief = render_brief( 

778 pr_number=pr_number, 

779 round_number=round_number, 

780 findings=findings, 

781 fixer=resolution["fixer"], 

782 budget=resolution["budget"], 

783 head_sha=head_sha, 

784 issue_number=issue_number, 

785 fix_sha=fix_sha, 

786 ) 

787 return { 

788 "schema_version": SCHEMA_VERSION, 

789 "pr": pr_number, 

790 "issue": issue_number, 

791 "head": _text(head_sha), 

792 "round": resolution["round"], 

793 "budget": resolution["budget"], 

794 "status": resolution["status"], 

795 # The *loop* is blocked — no seat can take this round. Whether the *findings* 

796 # block the merge is `findings.blocking`; conflating the two would have the 

797 # command exit non-zero on the ordinary case of a blocker with a fixer waiting. 

798 "blocked": resolution["blocked"], 

799 "fixer": resolution["fixer"], 

800 "ladder": resolution["ladder"], 

801 "hops": resolution["hops"], 

802 "next_action": resolution["next_action"], 

803 "warnings": resolution["warnings"], 

804 "findings": { 

805 "count": len(verdict.findings), 

806 "counts": verdict.counts, 

807 "blocking": verdict.blocked, 

808 }, 

809 "re_review": re_review(verdict.blocked, fix_sha=fix_sha), 

810 "dispatch": dispatch_argv( 

811 resolution["fixer"], 

812 prompt_file=prompt_file, 

813 cwd=cwd, 

814 timeout=timeout, 

815 project=project, 

816 ), 

817 "brief": brief, 

818 }