Skip to content

Stop dropping the reviewer's intent verdict when its formatting drifts - #41

Open
AdamT wants to merge 1 commit into
mainfrom
fix/review-verdict-parse
Open

Stop dropping the reviewer's intent verdict when its formatting drifts#41
AdamT wants to merge 1 commit into
mainfrom
fix/review-verdict-parse

Conversation

@AdamT

@AdamT AdamT commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

The bug

rcParseReviewOutput accepted the reviewer's closing verdict in exactly one shape — an unadorned key, and a value that is literally verified, partial, or diverges:

switch strings.ToLower(strings.TrimSpace(t[len("INTENT_MATCH:"):])) {
case "verified":  match = finding.MatchVerified
case "partial":   match = finding.MatchPartial
case "diverges":  match = finding.MatchDiverges
}   // no default, no log

Everything else fell through to MatchUnknown, which the console renders as "no contract resolved for this change" — asserting the reviewer never judged the change, when it had.

Evidence

kaicontext/kai-server#70, identical commit content, two runs:

Run intent_match note
1st unknown (none)
2nd verified "Clean security fix — constant-time compare, no open fallback, thorough test coverage…"

The job log for the failing run shows a complete review: 25 graph-grounded tool calls (kai_callers on verifyIngestSecret, kai_grep for IngestFinding), exit 0, Step completed: success. The review worked. The parse didn't.

It also isn't a size problem — in the same batch, a 951-line PR verified while this 237-line one did not.

Shapes that silently became unknown

Reviewer emits Before After
INTENT_MATCH: verified
**INTENT_MATCH:** verified
`INTENT_MATCH:` verified
INTENT_MATCH: verified.
INTENT_MATCH: verified — no material gaps
INTENT_MATCH: matches
INTENT MATCH: verified (key drift) ❌ — deliberately still unknown

That sixth row matters: the prompt itself glosses verified as "(matches)", so the model echoing that word back is expected behaviour. kai-engine's intent parser (reviewanalyze.go:334) already accepts matches/match. Two parsers with two vocabularies is the root cause.

What changed

  1. Decoration-tolerant — strips * and backticks before matching. Deliberately not _: that would break the INTENT_MATCH key itself and mangle snake_case identifiers in the note (there's a test).
  2. Synonyms resolve — aligned with what kai-engine already accepts.
  3. First-word matching — a trailing justification no longer hides the verdict.
  4. Conflicting verdicts don't resolve — two INTENT_MATCH: lines that disagree record unknown rather than last-one-wins, so a model quoting the block back as an example can't decide the verdict by emission order.
  5. Unreadable verdicts are loggedreview-commit prints the reviewer's raw closing text to stderr whenever it records unknown, so the failure stops being silent.

Also changed: which findings get recorded

Three rules with a wider blast radius than the verdict fix, found while fixing it:

  • A second FINDINGS: header restarts the list. A reviewer that quotes an example block while explaining itself would otherwise have those bullets stored as defects it never reported. Observed in a real run: 3 findings recorded, 2 of them quoted from this command's own test fixtures.
  • The list ends at the first non-bullet line. A closing block the model spelled differently used to leave the parser inside FINDINGS, storing its trailing prose as a defect.
  • Bullets are matched after decoration is stripped, so **- [security] …** is a finding. It was dropped silently before.

On not inventing verdicts

An earlier draft had a salvage pass that recovered a verdict from a drifted key (INTENT MATCH: with a space). It carried a real false-positive bug, caught by the tests here: the key INTENT_MATCH contains the word "match", so scanning the whole line resolved any unreadable INTENT_MATCH: line to verified.

Salvage was dropped rather than narrowed. A drifted key now stays unknown and gets logged, which is the honest outcome — the project's own rule is that "a confidently wrong verified poisons the whole proposition". Nothing outside a well-formed INTENT_MATCH: line can produce a verdict, and TestRCParseReviewOutput_ProseNeverBecomesAVerdict pins that, drifted key included.

Why the logging is the important half

The parser fix reduces how often this happens. The logging tells you when it still does — previously the job exited 0, the log looked clean, and an amber badge was the only symptom. There was no way to measure the failure rate, which means no way to know whether the cheap-model thesis is holding.

Testing

40 cases across 13 tests: every shape above, unreadable/absent/conflicting verdicts staying unknown, prose never inventing a verdict, the findings-block rules, and inline code and underscores preserved in notes.

go build ./... clean; go test ./cmd/kai/ passes in full (no regressions).

Deploying

The production review pods run this code via the kai-ci image (kai-cli/deploy/kai-ci/Dockerfilego build -o /out/kai ./cmd/kai), which kai-server's review workflow pins as kai-ci:latest. This needs a kai-ci image rebuild + push to take effect — it does not ride kai-server's deploy. pullPolicyFor already forces PullAlways on :latest, so pods pick it up once pushed.

Follow-up worth considering

This whole class of bug exists because the verdict travels as free text. Having the agent emit it as a structured tool call would remove the parser entirely — larger change, filed as a thought rather than done here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FjRNnVT1rpLU8ewxjTzxXQ

@kaicontext

kaicontext Bot commented Aug 3, 2026

Copy link
Copy Markdown

Kai review

Warning

Couldn't finish reviewing this change. Push a new commit or re-run the review to try again.

@AdamT
AdamT force-pushed the fix/review-verdict-parse branch 5 times, most recently from bf0b253 to 2d495a3 Compare August 5, 2026 12:03
@AdamT
AdamT force-pushed the fix/review-verdict-parse branch 2 times, most recently from 72d1b0b to d54d7c7 Compare August 18, 2026 06:53
review-commit asks the reviewer to finish with a fixed block:

    FINDINGS:
    - [category] path:line — defect
    INTENT_MATCH: verified|partial|diverges
    NOTE: <assessment>

The parser accepted that block in one exact shape only: an unadorned key and a
value equal to one of three words. Anything else fell through to MatchUnknown,
which the console renders as "no contract resolved for this change" — a claim
that the reviewer never judged the change, when it had. Observed on
kai-server#70: two runs of the identical commit, one recording intent=unknown
and the next intent=verified with a full note.

Four things now parse that did not:

- Markdown around a key. The model wraps the block it was told to emit verbatim
  ("**INTENT_MATCH:** verified"), so keys are matched with * and ` removed.
- "matches". The prompt glosses the verdict as "'verified' (matches)", showing
  the model two words for one meaning. This is the spelling that was observed
  being dropped.
- A justification after the verdict ("verified — no material gaps"): the first
  word of the value is the verdict, the rest is prose.
- A finding the model wrapped in bold. Matching the raw line dropped it, which
  is worse than the verdict bug: a clean-looking review with a real defect
  missing from it and nothing anywhere saying so.

Every relaxation is bounded so it cannot invent a verdict, because a confidently
wrong "verified" is worse than an honest unknown:

- Only the FIRST word of the value is read. Reading further makes "not verified"
  resolve to verified, and makes an ordinary justification ("diverges — the code
  no longer matches the stated intent") name two verdicts and resolve to neither.
- Only a real INTENT_MATCH line is read — no scanning prose for verdict words.
  "matches" is a common English word and "Intent:" is a natural recap heading.
- Two verdict lines that disagree resolve to unknown. Otherwise the result
  depends on emission order, and a model quoting the block back as an example is
  enough to flip it.
- Decoration is stripped from the verdict, which is matched word by word, but
  only the exact wrapper token is removed from the note and the findings, which
  are prose. Blanket-stripping turned `a*b` into "ab"; trimming any trailing run
  ate the closing backtick of a note ending in `code`.
- A second FINDINGS: header restarts the list, and the list ends at the first
  non-bullet line. A real run recorded 3 findings, 2 of them quoted from this
  command's own test fixtures.

The vocabulary is closed at four spellings, and each appears as a WORD in the
prompt this command sends or in the finding.Match enum. A test asserts that in
both directions, so the list and the prompt cannot drift apart. Notably absent:
"match" and "diverge" — kai-engine's judgeIntent accepts them, but no prompt here
emits them, and the two parsers never read the same text.

When no verdict can be read, review-commit logs the reviewer's raw closing text.
Previously the job exited 0, the log showed a clean run, and an amber badge was
the only symptom. That log is also how the vocabulary grows: add a spelling when
a real run produces it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013fuAGomxZJea7MSwY19SQg
@AdamT
AdamT force-pushed the fix/review-verdict-parse branch from d54d7c7 to dd92767 Compare August 18, 2026 09:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant