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
« 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).
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.
9This module is the pure half of the answer:
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.
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.
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"""
34from __future__ import annotations
36from collections.abc import Iterable, Mapping, Sequence
37from dataclasses import dataclass
38from typing import Any
40from . import findings as findings_mod
42SCHEMA_VERSION = "keel.fixloop.v1"
44#: Marker on the rendered brief, so a brief that reaches a PR is recognisable as one.
45BRIEF_MARKER = "<!-- keel.fixloop-brief.v1 -->"
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
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")
57#: Why a hop happened.
58HOP_REASONS = ("start", "round-failed", "provider-unavailable", "ladder-exhausted")
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")
65#: Default host agent, mirroring :data:`keel.team.HOST_DEFAULT`.
66HOST_DEFAULT = "claude"
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)
75#: Placeholder when the fix commit does not exist yet — it cannot, the fix is unwritten.
76UNKNOWN_SHA = "<fix-commit-sha>"
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
83#: Most of a reviewer-supplied value the brief renders inline — a headline, a reviewer id.
84MAX_INLINE_CHARS = 200
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:")
90_TRUNCATED = "… (truncated)"
92#: Indent for a quoted block, lining it up under its ` - label:` bullet.
93_QUOTE_INDENT = " "
95_BLOCKING = ("critical", "major")
98class FixloopError(ValueError):
99 """A fix-loop input that cannot be read: a bad round, or a malformed finding."""
102@dataclass(frozen=True)
103class Rung:
104 """One rung of the escalation ladder: a stage, a seat, and where the seat came from."""
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
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 }
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 }
150def _text(value: Any) -> str | None:
151 return value.strip() if isinstance(value, str) and value.strip() else None
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 )
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.
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.
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
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 }
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
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.
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 }
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.
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 }
444def parse_findings(raw: Any) -> list[findings_mod.Finding]:
445 """Read the ``--findings`` document into :class:`keel.findings.Finding` objects.
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
481def neutralise(text: str) -> str:
482 """Defang the one token reviewer text must never be able to forge.
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("-->", "-- >")
492def _inline(value: Any, *, fallback: str = "") -> str:
493 """One reviewer-supplied value, safe to interpolate into the middle of a line.
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
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()
520def quote(text: Any, *, indent: str = _QUOTE_INDENT) -> list[str]:
521 """Reviewer text as a blockquote: quoted **data**, never instructions.
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.
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
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}"
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
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)}
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"
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``.
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
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 }