Files

220 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Review Gauntlet
A graph-based post-implementation review gate — **"the done gate"**. An
orchestrator that must not claim *done* without green reviews spawns this agent
once instead of judging trigger rules and parsing verdict sentinels itself. The
graph structure makes skipping a lane or misreading a verdict impossible:
trigger signals are computed by a script, lanes run as isolated parallel
sub-agents, and the final verdict is a deterministic regex gate — an LLM never
gets the chance to rationalize past it.
```mermaid
flowchart TD
parse["parse<br/>(llm: extract request facts)"] --> signals["signals<br/>(script: deterministic diff facts)"]
parse -. "fallback" .-> pfault["parse_fault<br/>(script: PIPELINE-FAULT marker)"]
pfault --> gate
signals --> select["select_lanes<br/>(llm: ADDITIVE-only judgment)"]
select --> build["build_items<br/>(script: rules floor ∪ additions;<br/>forced lanes add)"]
select -. "fallback" .-> dlanes["default_lanes<br/>(script: deterministic degraded<br/>selection — wider, never narrower)"]
dlanes --> build
build --> mcr["map: code-review"] & madv["map: adversary"] & msec["map: security"] & mpb["map: probe"]
mcr -->|"over: 0-or-1 items"| rcr[["code-reviewer<br/>(max_attempts: 2)"]]
madv --> radv[["adversary<br/>(max_attempts: 2)"]]
msec --> rsec[["security-reviewer<br/>(max_attempts: 2)"]]
mpb --> rpb[["probe<br/>(max_attempts: 2)"]]
rcr -. "fallback" .-> lfault["lane_fault<br/>(script: PIPELINE-FAULT lane result,<br/>collected by the map)"]
radv -. "fallback" .-> lfault
rsec -. "fallback" .-> lfault
rpb -. "fallback" .-> lfault
mcr & madv & msec & mpb --> rgate["retry_gate<br/>(script: fault-only re-run gate;<br/>stash + restore, ≤3 attempts,<br/>wall-clock budget)"]
rgate -. "_next: retry faulted lanes" .-> build
rgate --> gate["verdict_gate<br/>(script: sentinel regex — deterministic)"]
gate --> done(["GAUNTLET: PASS | BLOCKED<br/>(+ GAUNTLET_REVIEW_INCOMPLETE: … when any lane or the pipeline was incomplete)"])
```
## Why a graph
The four independent review lanes existed before this agent — as **prompt
discipline** inside the calling orchestrator: "spawn code-reviewer when the
change is broad", "🔴 always blocks", "INCONCLUSIVE is never PASS". Prompt
discipline degrades under context pressure: a lane gets skipped, a verdict
line gets paraphrased, a missing report reads as "nothing to report". Here
those rules are *structure*:
- **Lane selection floor is deterministic** (`build_items.py`): code-review
always runs; adversary runs when a spec/plan is present; security runs on
attack-surface signals (auth/deps/exec paths or diff content) or hardened
posture; probe runs when consumer-facing surface changed *and* a local-run
recipe exists. The `select_lanes` LLM can only **widen** the set — a false
flag never removes a rule-selected lane. `forced_lanes` from the caller
override everything exactly.
- **Conditional lanes without dynamic routing**: each lane is a `map` node
over a 0-or-1-item list — an empty list means the map runs zero branches
(map-over-nothing), which *is* the skip mechanism. All four maps join at
the retry gate on equal-length paths; it falls through to the verdict
gate, or routes back to `build_items` for faulted lanes only.
- **The verdict is a regex, not an opinion** (`verdict_gate.py`): a missing
sentinel is a lane FAILURE, never a pass; the critical count from the
code-review report's summary line (raw 🔴 count if absent) blocks
regardless of its verdict line; a code-review lane whose own verdict line
records a pipeline fault blocks (a degraded review is never a pass); the
adversary's own degraded-run header (the `Criteria: …` line directly under
its sentinel recording a pipeline fault, died criterion checks, or its
verdict script's crash stub) blocks as a pipeline fault,
not as a DIVERGES finding;
`NEEDS-HUMAN` without 🔴 passes but
is surfaced under *Human attention required*; probe `INCONCLUSIVE` blocks
with an environment note — it is never treated as PASS or FAIL.
- **Verdict independence is structural**: lanes run as isolated sub-agents
with no `teammates:` flag — no cross-lane messaging, by construction.
- **Failure fails closed, structurally** (`fallback:` routes + `PIPELINE-FAULT`
markers): every LLM node and lane retries once (`max_attempts: 2`), then a
fallback fires instead of killing the graph — and every fallback degrades
toward BLOCKED, never toward a pass. A dead lane becomes a `PIPELINE-FAULT:`
lane result (`lane_fault.py`) that the gate reports as BLOCKED *naming the
lane* — distinct from SKIPPED (not selected). A dead `parse` records a fault
and jumps straight to the gate (`parse_fault.py`). A dead `select_lanes`
degrades to a deterministic selection (`default_lanes.py`): code-review +
adversary, plus probe on consumer surface *with a local-run recipe*, plus
security on auth/deps/exec
signals or hardened posture, unioned with caller-forced lanes — wider, never
narrower. A crashed `build_items` records a fault so an all-SKIPPED gate
still blocks. Fault detection is prefix-anchored — a real review that merely
*quotes* "PIPELINE-FAULT:" flows to the normal sentinel rules. And the
`GAUNTLET:` sentinel is always emitted: even a crashed gate prints
`GAUNTLET: BLOCKED`, never silence.
## Fault-only re-runs
`retry_gate.py` sits between the lane maps and the verdict gate and re-runs
only lanes that **faulted** — a missing report (stood in for by a synthetic
`PIPELINE-FAULT: <lane> lane produced no report` marker so it never renders as
SKIPPED), a non-string report, the `lane_fault` marker, a sentinel-less
report, or a nested graph's own degraded-run wording (the code-review verdict
line / the adversary's header line directly under its sentinel). A real verdict
(DIVERGES, FAIL, INCONCLUSIVE, NEEDS-HUMAN, …) is never re-run: the review's
judgment stands. Each lane gets up to 3 attempts, all inside a wall-clock
budget (`MAX_RETRY_ELAPSED_SECS`) sized to fit the graph timeout.
- **Stash + restore**: every lane report that ran this pass is stashed in
`kept_results`; a re-run pass overwrites the untouched lanes' collected
results with `[]`, so their stashed reports are restored before the gate.
The verdict gate always sees each lane's *last* report.
- **Retry never narrows selection**: lane selection is not re-run. A re-run
pass rebuilds items only for the retried lanes, and the stashed results
stand in for the rest.
- **Exhaustion is recorded, not hidden**: a lane still faulted after its
attempts or the budget lands in `review_incomplete`, and its fault flows to
the verdict gate as-is.
- **Crash guards append**: the `build_items.py` and `retry_gate.py` crash
guards append to an existing `signals_error` rather than overwrite it, and
`build_items.py` preserves the prior `lanes_summary`.
A non-empty `GAUNTLET_REVIEW_INCOMPLETE:` line structurally implies
`GAUNTLET: BLOCKED`: the verdict gate forces every lane named in
`review_incomplete` to a BLOCKED row (`review incomplete after N attempt(s)`)
and a matching blocker, so a restored earlier report can never turn an
incomplete review into a pass — and malformed `review_incomplete` bookkeeping
is itself recorded as a `pipeline` fault, never dropped.
Whenever at least one lane (or the pipeline) could not complete, the first
line reads `GAUNTLET: BLOCKED` followed immediately by
`GAUNTLET_REVIEW_INCOMPLETE: <lane>[, <lane>]`. The names are the exhausted
lanes from `review_incomplete`, plus the `pipeline` pseudo-lane whenever a
pipeline-level `PIPELINE-FAULT:` fired — parse death, or a crash in the
builder, the retry gate, the default-lanes fallback, or the verdict gate
itself (the default-lanes fallback's crash still runs the widened lanes, so
`pipeline` appears alongside real lane rows). The report gains a
`## Review incomplete` section naming each exhausted lane with its
attempt count; `PIPELINE-FAULT` lane rows carry ` ×N` when the lane was
re-run (N > 1). The *named* entries are faults to re-run, never code
findings; the line can accompany real findings from the other lanes, which
still appear under `## Blockers` and must be fixed as usual.
## Sentinels parsed
| Lane | Sentinel | Blocks on |
|------|----------|-----------|
| code-reviewer | `**Verdict: MERGE-READY \| NEEDS-HUMAN**` | critical count from the report's summary line (raw 🔴 count if absent); missing sentinel; own verdict line records a pipeline fault |
| adversary | `ADVERSARIAL_REVIEW: CONFORMS \| DIVERGES` | DIVERGES; own degraded-run header (pipeline fault, died criterion checks, or its verdict script's crash stub); missing sentinel |
| security-reviewer | `SECURITY_REVIEW: PASS \| FAIL` | FAIL; missing sentinel |
| probe | `USAGE_PROBE: PASS \| FAIL \| INCONCLUSIVE` | FAIL; INCONCLUSIVE (environment note); missing sentinel |
## Spawning it
```
agent__spawn --agent review-gauntlet --prompt "Post-implementation review gate.
Project: /abs/path/to/repo
Diff: main...HEAD (a git range, a ref, or 'worktree' for uncommitted changes)
Rigor: production Security posture: standard
Plan / acceptance criteria:
<paste the criteria the implementation must satisfy — enables the adversary lane>
Local-run recipe / usage suites:
<how to boot the service locally + where existing suites live — enables the probe lane>
Forced lanes: (optional, ADDITIVE — e.g. 'adversary, probe' run on top of the computed selection; forcing never drops the always-on floor)" \
--variables {"verification_commands": "[\"cargo test --all\", \"cargo clippy -- -D warnings\"]"}
```
Verification commands are NOT part of the prompt. They are the declared
`verification_commands` graph variable (default `'[]'`; a JSON array of shell
commands as a string, since agent variables arrive as strings), also settable
from the CLI:
```sh
coyote -a review-gauntlet --agent-variable project_dir /abs/path/to/repo \
--agent-variable verification_commands '["cargo test --all"]' "<prompt>"
```
The reply opens with `GAUNTLET: PASS` or `GAUNTLET: BLOCKED` (plus the
`GAUNTLET_REVIEW_INCOMPLETE:` line when applicable), followed by a lane status
table, the lane-selection reasons, blockers, attention items, and each lane's
full report in a collapsible section. On BLOCKED with a
`GAUNTLET_REVIEW_INCOMPLETE:` line: the named lanes never completed — this is an
infrastructure fault, never a code finding. Fix any non-fault blockers listed
under `## Blockers` first (the re-run retries the lane); if the line persists,
escalate to the user with three options — retry the review again (a **fresh**
gauntlet run), accept NEEDS-HUMAN and proceed without that lane (recorded in the
report / PR body), or abort — never route it through your findings-handling
rules. On BLOCKED without it: fix the findings per your own findings-handling
rules, then spawn a **fresh** gauntlet run. On PASS with *Human attention
required*: complete the work but carry those items into your final report
verbatim.
## Notes
- The caller decides *whether* review is warranted at all (the gauntlet is for
non-trivial work — that's why code-review is unconditionally on); the
gauntlet decides *which* of the other lanes apply and what the verdicts mean.
- Lane sub-agents get generous finite per-attempt timeouts (see the
`timeout:` on each `run_*` node) and one retry (`max_attempts: 2`) inside
the lane, then up to 3 attempts of the whole lane from `retry_gate.py` while
the wall-clock budget (`MAX_RETRY_ELAPSED_SECS`) allows; `settings.timeout`
is derived from that envelope in graph.yaml's comment. A lane that still
dies or times out falls back to the `lane_fault` marker — BLOCKED naming the
lane and `GAUNTLET_REVIEW_INCOMPLETE`, never a silent pass.
- The `verification_commands` variable is forwarded to the adversary lane as a
structured `inputs:` passthrough on `run_adversary` (a lone `{{…}}` template
lands the raw value in the child's state) — never through the lane prompt
and never extracted by `parse`. The adversary's `run_checks` stage executes
those commands with a shell, which makes the declaration a trust boundary:
the prompt also carries plan text pasted from the repo under review, so
commands must never be inferred from it, and the engine merges only the keys
`parse`'s `output_schema.properties` declares, so an extra
`verification_commands` key emitted by the LLM is dropped before it reaches
state.
Undeclared stays undeclared
(`'[]'` ⇒ "none declared"); a declaration that is not a JSON array of
strings executes nothing and records a `PIPELINE-FAULT`, which the
adversary lane reports as DIVERGES and the gate as BLOCKED.
- Signals failures degrade WIDER: if git can't compute the diff, the surface
is unknown — the security lane is added (and probe, when a local-run recipe
exists) on top of caller context + forced lanes, and the report notes it. The auth-path regex deliberately over-fires (`auth(?!or)` matches
auth/authn/authz/oauth but not "author") — the safe direction, since signals
only ever ADD the security lane.
- Requires the `code-reviewer`, `adversary`, `security-reviewer`, and `probe`
agents to be installed (they ship with Coyote).