Skip to content

review: hand off out-of-lane observations instead of dropping them - #245

Merged
jwbron merged 63 commits into
mainfrom
jwbron/review-out-of-lane
Jul 13, 2026
Merged

review: hand off out-of-lane observations instead of dropping them#245
jwbron merged 63 commits into
mainfrom
jwbron/review-out-of-lane

Conversation

@jwbron

@jwbron jwbron commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Third of five PRs in the fold-in batch two stack (stacked on #244; the stack is based on #238, jwbron/review-trial-skill). The top of the batch-two stack will be jwbron/review-skill-rule-quote.

In run 2 of the v1.4.0 re-run lifecycle the skill-auditor found a real issue (dedup reads via an eventually-consistent Query immediately after PutMulti) and dropped it, verbatim: "that's a correctness concern, not a quotable skill-rule violation, so I'll leave it". That was the correct call under quote-the-rule, but the observation died with it.

Failure scenario fixed: a reviewer investigating a rule notices a genuine correctness defect, correctly declines to report it as a skill violation, and has no other channel; the defect reaches the PR author in no form and ships.

The handoff:

  • The skill-auditor and all 11 specialist lenses may now return out_of_lane_observations[] alongside findings[]: path, optional line, the concern stated concretely, a required concrete failure_scenario (the claim the validator attacks, same discipline as findings), and an optional suggested_lane.
  • A new orchestrator rule (Step 3, after lens normalization) converts each observation into a candidate comment with the code-assigned label question (non-blocking); the label is never model-chosen, because a handoff is not a vetted finding and must not block on its own (and the claim-validator never upgrades severity). Candidates then flow through the identical provenance gate, scope filter, claims.json, and validation path as every other candidate; validation into the correctness lane, rather than re-dispatching the correctness reviewer, keeps the handoff inside the run's existing budget.
  • The shape is code-validated: validateOutOfLaneObservation / isValidOutOfLaneObservation in lib/finding-schema.ts, with the same collect-every-error contract as validateFinding so a malformed handoff is diagnosable from the run artifact. It is a sibling type, not a Finding field, so FINDING_SCHEMA_VERSION stays 2 and no serialized finding is invalidated.

The per-lens handoff paragraph and JSON field are stamped into each of the 11 lens definitions (mechanically, identical text); the next PR in the stack (dispatch-tax trim) folds exactly this kind of repeated block into the shared staged disciplines context, so the duplication added here is transitional within the same stack.

cc @jeresig

Testing

  • npx vitest run workflows/review: 484 tests green (7 new observation-schema tests: minimal and full shapes, required fields, error collection, line bounds).
  • npx tsc --noEmit clean.

jwbron added 14 commits July 9, 2026 12:47
…live cases

Phase 1 of the live A/B eval plan (#232). The corpus gains
an opt-in live block carrying what a real model run needs and the
deterministic replay does not:

- prContext (PR title/description/author/base; the description is
  untrusted author text, so an adversarial case can carry its payload
  there or in the diff),
- a post-change file tree on disk next to the case, via a new
  <id>/case.json + <id>/tree/ layout that coexists with flat <id>.json
  (a directory containing case.json is one case; its tree is never
  parsed as corpus JSON), and
- labeled defect specs (mustCatchSpecs / mustNotFlagSpecs: path, line
  window, mechanism keyword alternates). Live runs choose their own
  finding ids, so ground truth matches on anchor window + mechanism
  rather than id.

The loader enforces the invariants: the live tag and the live block
imply each other, a live case needs a cleanly-parseable diff, spec
paths must appear in changedFiles and the diff, and every non-removed
changed file must exist in the tree. loadLiveCorpus() returns the
subset. The live half lives in corpus/live.ts; loader.ts re-exports it
as the single public surface.

Ten cases are converted with hand-authored real diffs and trees: five
smoke incidents, both clean cases, one adversarial injection whose
payload is a code comment in the diff, one golden holdout, and one
synthetic mutation. Their recorded line anchors are rewritten to the
authored defect lines (natural files beat content padded to synthetic
line numbers), which activates the provenance gate on these cases in
the deterministic suite; all expectations hold unchanged.
…action and case staging

Phases 2a/2b of the live A/B eval plan (#232), stacked on the Phase 1
corpus format (#233).

agent-extract.ts turns review.md's '## agent:' sections into data
(name, description, pinned model, prompt body). It takes the markdown
as a string with no fs access, because the baseline arm of an A/B
reads the merge-base review.md via git show. Parsing is strict; a
malformed or model-less section throws listing every problem, since a
silently dropped agent would skew an eval arm without failing it. An
integration test extracts the real review.md (21 agents) and pins the
staging-root reference so a future rename fails a test instead of
silently staging nothing.

live-stage.ts materializes the production staging layout for one
live-enabled corpus case: pr-context.json (review.md Step 1's shape,
synthetic identity fields), full.diff / pr.diff / full-stripped.diff
(all the case diff; corpus diffs carry no generated files and no
pattern-triage pass runs), files.json + review-files.json with the
hasPatch cross-check derived from the diff parse, provenance.json,
routing.json from the deterministic router, an out/ directory, and
the post-change checkout copied from the case tree.
rewriteAgentPrompt swaps the production staging root for the staged
context dir. Everything sits behind an injected-fs seam and is
memfs-tested.

Model dispatch (phase 2c) is deliberately absent; it needs the Agent
SDK dependency decision and arrives separately.
… runner (phase 2c)

live-producer.ts runs the live roster over one staged case behind an
injected LiveAgentRunner seam (the judge.ts pattern), so its logic is
stub-tested with zero model calls. Roster: the default finders
(correctness-reviewer, skill-auditor) plus the router's lensesToSpawn;
no pattern-triage or thread-reconciler in eval. It maps all three
sub-agent output contracts into the shapes the deterministic runner
consumes: label-shape findings (correctness lens, or conventions for
the skill-auditor so labelForFinding reproduces the best-practice
variants; confidence defaulted to 0.7 per the production claims rule),
structured-schema lens findings validated as-is, and the validator's
{claims: [...]} verdicts into CaseVerification[]. It stages
claims.json for the validator, resolves {{#runtime-import}} directives
against the case tree (a case opts into a skills index by carrying the
file), retries once on malformed output with the rejection fed back,
keeps partial results when an agent fails twice, prefixes colliding
finding ids, and reports per-agent cost/turns/wall-clock.

live-runner.ts is the one module that touches a real model runtime:
an Agent SDK query() per dispatch with Read/Grep/Glob only, cwd pinned
to the staged checkout, the agent's pinned model, and hard turn and
wall-clock caps; plus the CLI smoke entry
(tsx live-runner.ts --case <id>, requires ANTHROPIC_API_KEY). The
investigation-cap CLI the prompts reference is not staged; its own
denied-budget fallback applies. Adds @anthropic-ai/claude-agent-sdk
as a dev dependency.
live-match.ts scores a live run against a case's labeled defect
specs: a posted candidate satisfies a spec when its anchor agrees
with the spec's path (and line window when both carry one) and any
mechanism alternate matches the finding's failure_scenario or prose;
each candidate satisfies at most one spec and vice versa. An injected
fallback arbiter (hard-capped, same-file only, recorded as
via: fallback for audit) can rescue recall on vague prose; false
flags are decided by the deterministic rule alone. computeLiveMetrics
aggregates recall, verdict agreement, clean false-flag (including a
clean case that blocks), and noise.

live-ab.ts is the arm orchestrator and CLI: baseline review.md from
git show <merge-base>, candidate from the working tree, both arms
over the same live corpus with everything else (corpus, lib, runner,
metrics, judge) from the candidate, per the plan's settled decision
to isolate the model seam. Each arm runs under half the --max-usd
budget with sticky exhaustion: once spend plus the running per-case
average crosses the cap, remaining cases are recorded skipped and
the report still emits. Spec-level regressions are diffed only over
cases both arms scored. Judge scoring reuses the pinned judge
(quality aggregates only; judge-vs-ground-truth disagreement keys on
recorded ids a live arm does not use); the fetch model moves to
judge-live-model.ts and live-judge.ts now imports it. runner.ts
gains an optional RunOptions.validation override so a live
validator's output replaces the recorded block.

Report-only except the standing rule: adversarial-injection failures
on the candidate arm exit non-zero.
Review Eval A/B runs on every non-draft PR touching
workflows/review/** (and on workflow_dispatch): both review.md arms
over the live corpus via live-ab.ts, with the delta report posted as
a sticky PR comment (hidden-marker upsert), appended to the job
summary, and uploaded as an artifact even on partial or gate-failing
runs. Per-PR scope is the smoke-tagged live subset; the full-eval
label or dispatch input lifts it, skip-live-eval opts out, drafts
wait until ready, the changeset-release branch is excluded (it
matches the path filter via package.json but changes no behavior),
secretless runs skip green so fork PRs never fail, and per-PR
concurrency cancels superseded runs. The workflow name is distinct
from every gh-aw workflow per the operational-floor rule about shared
concurrency groups. live-ab.ts gains --smoke-only and writes
out/live-ab-report.md alongside the JSON for the comment step.
… and vitest

Tree directories are byte-exact case fixtures paired with each case's
diff: prettier auto-formatting one would silently desync it from the
diff the provenance gate parses, and a tree may carry a *.test.ts
whose tests fail by design (test-adequacy cases), so vitest must not
execute them either. CI's lint job caught the first drift (prettier
wanting to rewrap a fixture ternary).
…rpus' into jwbron/live-eval-producer-staging
…s with the case id

Live agents choose their own finding ids, so every case's first
correctness finding was live-correctness-reviewer-1; ids were unique
within a case but collided across cases, and judge.ts's score join
requires arm-global uniqueness. Caught by the first real A/B run
(both arms completed, then judge aggregation threw). Ids are now
<caseId>:<id> from the moment of parsing, so claims.json, the
validator round-trip, the matcher, and the judge all see the same
namespaced id.
…eport instead of killing it

The first real A/B run spent both arms' budgets and then died in
judge aggregation, writing no report: the exact
everything-spent-nothing-posted failure mode the plan forbids. Judge
scoring is additive, so a per-arm failure is now caught, recorded as
judgeError on the arm, rendered as a degradation note in the report,
and the run proceeds to write JSON + markdown and evaluate the
adversarial gate as usual.
…A/B phase 5)

Packages the seeded-defect live-trial pattern (Khan/webapp#40678) as
a Claude Code skill so a trial costs an operator an afternoon instead
of a week of hand choreography. The skill collects required inputs
(seeded branch, human-authored defect table, arms, budget approval)
and refuses to improvise ground truth; sets up one isolated PR per
arm with per-arm trigger recipes and the distinct-workflow-name rule
(same-named gh-aw workflows share a per-PR concurrency group and
cancel each other); collects reviews, artifacts, and costs per run
with the known gh-aw artifact-bug tolerance; drives optional
lifecycle pushes; scores defect by defect with the deterministic
rule mirroring eval/live-match.ts plus audited manual judgment;
exports live-enabled corpus case skeletons with a sanitization gate
for private-repo content landing in this public repo; and cleans up
trial PRs, branches, and temporary workflows. Trials remain the
architecture-bet instrument; per-change evals belong to the corpus
A/B.

.gitignore narrows from .claude to .claude/* with a !.claude/skills/
carve-out: local agent state (settings, worktrees) stays untracked,
project skills are shared tooling and are committed.
@changeset-bot

changeset-bot Bot commented Jul 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: cbb2999

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
review Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@jwbron

jwbron commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Branch rebuilt on the refreshed chain (7b5318c, content unchanged; see #243 for the full topology note). Reset local state to origin before continuing. The dispatch-tax trim PR should base on this branch.

@github-actions

This comment has been minimized.

* block on its own.
*/
export type OutOfLaneObservation = {
/** Repo-relative path the observation anchors on. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought (non-blocking): The production orchestrator now routes out-of-lane observations, but the live-eval producer (eval/live-producer.ts parseAgentFindings) reads only findings[]. So in a review-trial a handed-off observation is dropped and the arm scores as if it never happened — the same drop this PR closes on the production path. Worth teaching the eval harness to read out_of_lane_observations[] in a follow-up so the trial can measure this feature.

* returned, so a producer's malformed handoff is diagnosable from the run
* artifact rather than silently dropped.
*/
export const validateOutOfLaneObservation = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (non-blocking): validateOutOfLaneObservation is referenced only by the tests — the orchestrator prose in review.md never invokes it (unlike the provenance gate, which runs a real CLI). Is the intent that shape enforcement stays eval/test-only, as validateFinding already is? If so, the PR description's "shape is code-validated" slightly overstates the live behavior; a one-line note that the live gate is the orchestrator prompt would settle it.

lens, a concern outside its domain). Do not discard these. Convert each observation
into a candidate comment in the same label-bearing shape as every other candidate:
`path`/`line` from the observation, `subject` from its `observation` text verbatim,
`failure_scenario` verbatim, and the label **`question (non-blocking)`** — the label

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought (non-blocking): The handoff channel has a lower evidentiary bar than the lane it feeds. A finding must carry evidence_trace and an honest confidence; an out-of-lane observation carries neither, so once converted to a candidate it has no confidence and defaults to 0.7 (review.md:693) — above the 0.5 medium posting bar — leaving the claim-validator as the only gate before it posts. Consider code-assigning out-of-lane candidates a below-default confidence so only validator-confirmed ones clear the bar.

is code-assigned, never model-chosen: an out-of-lane observation is a handoff, not a
vetted finding, so it can never block on its own (and the `claim-validator` never
upgrades severity). Set the candidate's `source` to `"<agent> (out-of-lane)"`. From
here each one flows through the identical change-provenance gate → scope filter →

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought (non-blocking): Nothing merges same-line candidates from different sources within a run (the only dedup layers are the thread-reconciler and the scope filter), so the motivating case — the skill-auditor hands off a correctness bug the correctness-reviewer also finds — can post the same line twice. Also suggested_lane is collected on the observation but never consumed in the routing paragraph: either use it for an ownership/dedup check or drop the field.

# Conflicts:
#	workflows/review/eval/corpus/live.ts
@github-actions

Copy link
Copy Markdown
Contributor

Review Guidance

github-actions (2 files)
File Reason
finding-schema.ts Shared deterministic enforcement layer consumed by verdict/validation on every consumer PR; adds new exported types and validators.
review.md The shared reviewer orchestrator prompt that governs every review run; adds routing prose plus per-lens output-schema and instruction blocks.

Common patterns

review.md: The same "Hand off, never drop, an out-of-lane observation" instruction paragraph was added verbatim before the Output section of 11 agent sections (skill-auditor + 10 specialist lenses).

review.md: The identical out_of_lane_observations JSON field was inserted into the output-schema block of each of those same 11 agent sections.

Excluded from review (3 files)

Not individually reviewed — generated, formatting-only, or fully explained by a common pattern above:

  • .changeset/review-out-of-lane.md — pattern-only
  • workflows/review/README.md — pattern-only
  • workflows/review/eval/corpus/live.ts — formatting-only

jwbron added a commit that referenced this pull request Jul 10, 2026
Phase 3 of the live A/B eval plan (#232), stacked on the Phase 2 producer (#234): score live runs against labeled ground truth and diff two review.md versions arm to arm.

## 3a: `eval/live-match.ts`

Live runs choose their own finding ids, so the recorded metrics (which key on `expected.mustCatch` ids) cannot score them. The matcher maps a run's POSTED candidates onto the case's labeled defect specs: a candidate satisfies a spec when its anchor agrees with the spec's path (and line window, when both carry one) and any mechanism alternate matches the finding's `failure_scenario` or prose, case-insensitively. Each candidate satisfies at most one spec and each spec at most one candidate, so one comment cannot claim two defects. An injected fallback arbiter (hard-capped, same-file candidates only, recorded as `via: "fallback"` for human audit) can rescue recall on vague prose; false flags are decided by the deterministic rule alone. `computeLiveMetrics` aggregates the live analogues of the suite's numbers: must-catch recall, verdict agreement, clean false-flag (a clean case that blocks counts), and noise (posted candidates matching no spec).

## 3b: `eval/live-ab.ts`

The arm orchestrator and CLI. Baseline `review.md` comes from `git show <merge-base>` (or `--base-ref`), candidate from the working tree; both arms run over the same live corpus with everything else (corpus, `lib/`, runner, metrics, judge) from the candidate, per the plan's settled decision to isolate the model-behavior seam. Each arm runs under half the `--max-usd` budget (default 40 total) with sticky exhaustion: once spend plus the running per-case average crosses the cap, the remaining cases are recorded as SKIPPED and the report still emits (dying at a cap with nothing posted is the failure mode the plan forbids). Each live result replays through the deterministic pipeline; `runner.ts` gains an optional `RunOptions.validation` override so the live validator's output replaces the recorded block. Spec-level regressions ("baseline caught, candidate missed" and the reverse) are diffed only over cases both arms actually scored, so a budget skip is never reported as a regression.

Judge scoring reuses the pinned judge for quality aggregates only (judge-vs-ground-truth disagreement keys on recorded ids, which live arms do not use); the fetch model moves from `live-judge.ts` into shared `eval/judge-live-model.ts`.

Output: `out/live-ab-report.json` plus a markdown table (metrics deltas, judge quality delta, cost, wall clock, regressions, skips, agent failures) printed and appended to `GITHUB_STEP_SUMMARY`. Report-only, with one exception per the playbook's standing rule: the candidate arm failing any adversarial-injection case (wrong verdict or missed spec) exits non-zero.

## Test plan:

- `pnpm run test --run`: 696 tests green (16 new: matcher rules incl. window overlap, one-candidate-one-spec, malformed-regex fallback-to-literal, capped fallback audit trail; runArm budget semantics incl. the sticky-stop case that caught a real bug in review; regression diffing over shared cases; report rendering both gate outcomes).
- `pnpm run typecheck` and eslint clean.
- The end-to-end CLI (`pnpm dlx tsx workflows/review/eval/live-ab.ts --max-usd 10 --cases incident-money-rounding,clean-no-findings`) needs `ANTHROPIC_API_KEY`; this environment has none, so that is the first thing to exercise when testing. On an unchanged review.md it prints the identical-arms note and near-zero deltas.


## Next steps (human)

1. ~~End-to-end A/B validation~~ DONE, superseded by the phase 4 acceptance runs (linked on #237): the control arm held recall and verdict agreement flat at +0% and the weakened arm showed -17% recall with the lost spec named, which is also the first empirical read on matcher quality (7 cases matched; one systematic miss traced to lens routing, fixed on #233, not to the matcher). Two acceptance-driven additions landed here since: `perCase.failedAgents` entries now carry `<agent>: <reason>` (a claim-validator flake was undiagnosable from the report alone; the flake itself is still to be root-caused on a future run), and every report closes with a per-row stability footer (judge quality rose on the deliberately regressed arm because fewer, surer comments each score better, so the footer warns against reading it alone).
2. Review the two scoring caveats: judge output is quality-aggregates only (disagreement semantics key on recorded ids), and the fallback arbiter is wired but not yet given a live implementation (matches would be marked `via: "fallback"` for audit when one is added).
3. Confirm the budget semantics (half of `--max-usd` per arm, sticky skip-and-report) match how you want cost bounded.

---

## Update 2026-07-09: eval-tuning pass (three riders)

Three instrument fixes from the [eval-tuning memo](https://claude.ai/code/artifact/2995f0b5-8840-4d03-96e3-2518e2f0f75a), landed here so the whole stack inherits them:

1. **Pre-flight identity short-circuit** (memo item 1, hoisted down from #251 with identical text so that branch merges cleanly): byte-identical review.md in both arms posts a no-reviewable-delta report and runs nothing; `--force-arms` preserved for wobble controls. Corpus-only and plumbing-only PRs in this stack now pay $0 per push.
2. **Gate-flip retry** (memo item 3): an adversarial hard-gate flip re-runs only the flipped cases, best of three, before the gate may fail the arm. Retried runs never replace the original in the metrics; they only decide whether the flip was a run-to-run flake (the #244/#245 incident shape). Retry spend is recorded in the report.
3. **Drop-bucket taxonomy** (memo item 4): every missed must-catch spec is classified true-miss vs found-but-dropped (provenance/scope/validation) by re-matching against the dropped candidates with the line window relaxed. Lost regressions are annotated with their class and the table gains a found-but-dropped count row.

Plus **judge economics**: the judge pin moves from Opus 4.8 to `claude-haiku-4-5-20251001` (the judge grades prose only; every load-bearing metric is deterministic, and the acceptance runs showed judge quality is not single-run-stable on any model, so it is priced accordingly; supersedes operator direction 4), and the judge seam gains retry with backoff so a transient 500 no longer wastes a whole scoring pass.

Author: jwbron

Reviewers: jeresig, github-actions[bot], jwbron, jaredly

Required Reviewers:

Approved By: jeresig, github-actions[bot]

Checks: ✅ 9 checks were successful

Pull Request URL: #236

@jeresig jeresig left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good!

@jwbron
jwbron deleted the branch main July 13, 2026 18:41
@jwbron jwbron closed this Jul 13, 2026
…' into jwbron/review-out-of-lane

# Conflicts:
#	workflows/review/README.md
@jwbron jwbron reopened this Jul 13, 2026
@jwbron
jwbron changed the base branch from jwbron/review-rereview-accountability to main July 13, 2026 18:45
@jwbron
jwbron merged commit e1c7fb6 into main Jul 13, 2026
6 checks passed
@jwbron
jwbron deleted the jwbron/review-out-of-lane branch July 13, 2026 19:00
jwbron added a commit that referenced this pull request Jul 30, 2026
…ice 2) (#282)

* [jwies/review-scripted-dispatch] review: script-driven dispatch behind the ROUTING dispatch dial (orchestrator slice 2)

* [jwies/review-scripted-dispatch] review: sync the workspace lockfile with the agent-sdk dependency

* [jwies/review-scripted-dispatch] review: parse the validator's claims-array contract; dispute cap as a mechanical floor; feedback hardening

* [jwies/review-scripted-dispatch-local] review: lenient contract extraction, findings-key tolerance, and the malformed-output retry in the dispatcher (trial run 29893634730)

* [jwies/review-scripted-dispatch-local] review: honest dedupe-test naming; dispatch-directive arity/duplicate and runCli dispatchMode coverage (re-review feedback)

* [jwies/review-scripted-dispatch] review: enforce the label contract in fromLabelShape (salvage near-misses, reject unknown labels) and join subject/discussion with a sentence break

Trial run 29897276810's correctness-reviewer emitted a ReportFindings-style
shape ({file, line, anchor, summary, severity, verdict, category,
discussion}) instead of the label contract. The parse accepted it, defaulted
label to the empty string with no anchor, and produced four label-less
anchor-less claims that rendered as bare '**:**' paragraphs in the review
body and demoted a BLOCKING correctness finding to advisory.

Now fromLabelShape salvages the near-miss fields first (path from
anchor.path/file, subject from summary), then rejects any finding whose
label is not in the Conventional Comments vocabulary, so parseWithRetry
re-dispatches the reviewer once with its corrective note instead of
accepting a shape the contract never allowed.

The same run's prose join glued subject to discussion with a bare space
("...memory Both TestExpiration..."); joinProse now inserts a sentence
break when the subject lacks terminal punctuation, which also keeps
buildClaims' first-sentence split recovering the subject.

* [jwies/review-scripted-dispatch] review: merge cross-source duplicate claims before validation (#245)

Trial run 29897276810 posted the AddDate months-vs-days defect four times
(correctness, completeness, first-principles, and a skill-auditor
out-of-lane handoff), and each copy was separately validated; claim
validation is the largest sub-agent cost line, so duplicates now merge
before the validator dispatch, not after.

The merge is conservative and deterministic: different sources only, same
path within a two-line window, and a token-similarity floor (Jaccard,
overlap, and shared-bigram thresholds calibrated on that run's real
outputs, where same-defect pairs scored at least 0.20 Jaccard and
different-defect pairs on the same lines at most 0.05). The survivor is the
highest-severity copy; it gains an 'also flagged by' note, adopts a merged
duplicate's suggestion or author dispute when it lacks its own, and every
merge is recorded in dispatch-result.json so run reports can count them.

Replayed against the run's real out/ files this merges 13 claims to 9: the
AddDate group collapses into the blocking correctness finding, and the
missing-composite-index defect (flagged by correctness and holistic in
different framings) collapses into one.

* [jwies/review-scripted-dispatch] review: raise the per-sub-agent dispatch timeout to 15 minutes and record timeouts readably

Trial run 29901690493 shed both default finders: the correctness-reviewer
and skill-auditor were aborted at exactly the 5-minute DEFAULT_TIMEOUT_MS
while every lighter reviewer finished in 60-115 seconds, and the staged
error record read only 'Claude Code process aborted by user' (the SDK's
generic abort message). The heavy investigators routinely need 5-10
minutes; the prior pin's correctness pass ran about 8 minutes to
completion. The cap is a hang backstop, not a budget (credit spend is
metered by the sandbox api-proxy), so it now sits at 15 minutes, and the
CLI runner translates a timer abort into 'sub-agent timed out after Xms'
so the out-file record and the run report name the cause.

* [jwies/review-scripted-dispatch] review: salvage a label-valid finding whose failure_scenario is missing

Trial run 29906543140's correctness pass twice emitted findings with valid
labels, anchor objects, and rich discussions but no subject and no
failure_scenario; the schema requires a non-empty failure_scenario, so both
replies were rejected and the correctness dimension was voided (disclosed,
but a voided default finder is strictly worse than a weaker fallback). The
salvage now falls back subject -> discussion for the failure_scenario, so
the claim-validator attacks the discussion prose instead of nothing.

* [jwies/review-scripted-dispatch] review: salvage a title-keyed subject in the label-shape near-miss path

Trial run 29908199997's correctness pass drifted to {id, file, anchor,
label, category, title, discussion}: valid label, but the subject lived
under 'title', so the salvage produced an empty subject and (pre-c35675f)
an empty failure_scenario, voiding the dimension a third time. 'title'
now joins 'summary' in the subject fallback chain.

* [jwies/review-scripted-dispatch] review: salvage suggested_patch in the label-shape near-miss path

fromLabelShape read only the contract's `suggestion` key, so trial run
29943085279's correctness pass, which drifted into its
ReportFindings-style shape (defect-13 lineage), salvaged the AddDate
finding but dropped the one-line fix it carried under
`suggested_patch`; the posted comment (Khan/webapp#41029) had no
suggestion fence. Accept a non-empty `suggested_patch` when
`suggestion` is absent, exactly like the anchor and summary/title
salvages beside it; `suggestion` still wins when both are present.

* [jwies/review-scripted-dispatch] review: drop the same-path line window in the cross-source duplicate merge

dedupeClaims required anchors within two lines of each other, so trial
run 29943085279 posted the missing-deletion-test defect four times in
one review (expiration_test.go:15 and :58, expiration.go:62 and :38).
For same-path pairs the calibrated similarity floor
(describesSameDefect) now carries the precision alone, the stance
suppressOpenThreadDuplicates already takes and documents. Replayed
against that run's claims.json this merges exactly the :15/:58 pair and
nothing else; the AddDate issue and the untested-behavior thought on
the very same line stay unmerged (the false-merge guard), and the
test-adequacy/first-principles pair scores below the floor and stays
two comments. Cross-file merging stays out of scope: it needs its own
strictly higher calibration, and a missed merge only costs a duplicate
comment.

* [jwies/review-scripted-dispatch] review: star-guard the duplicate merge so a chain-only member never drops

Union-find chains A~B~C through a bridging claim that bundles two
defects (measured: a test-adequacy finding naming both the missing test
and the unbounded read clears the floor against each correctness
finding separately while the two never clear it against each other),
and the collapse silently dropped a distinct blocking finding; with the
same-path line window gone the bridge can span a whole file. A group
member now merges only when it clears describesSameDefect against the
survivor directly; chain-only members stay their own claims (a missed
merge only costs a duplicate comment). Both recorded trial merges are
unaffected: run 29897276810's four-way group is pairwise-complete,
run 29943085279's is a direct pair.

* [jwies/review-scripted-dispatch] review: stage dispatch-result.json under out/ so the artifact carries it

runDispatch wrote dispatch-result.json only to /tmp/gh-aw/review, which
the Step 9 artifact upload does not include, so run 29943085279's
post-hoc could not tell whether the correctness output arrived through
the submit_result tool or the text fallback: perAgent (structuredFinal,
retried, per-agent usd), merges, and threadSuppressions never left the
runner. The same content now also stages as out/dispatch-result.json.
The dispatch gate reads out/ files by fixed or routing-planned name
only (rereview-plan.json already stages there as a non-agent file), so
the extra file changes no gate rule.

* [jwies/review-scripted-dispatch] review: malformed reviewFiles entries fail toward triage-unavailable

The dispatcher filtered triage's reviewFiles to strings and treated the
filtered-empty result as an empty review, dispatching no finders and
rendering no disclosure, while the gate's empty-reviewFiles waiver
reads the RAW staged array (dispatch-gate.ts triageEmptiedReview): a
triage output like reviewFiles: [1, 2] therefore produced a guaranteed
false block (rule 1 correctness-missing has no disclosure escape, plus
shed-undisclosed for every planned extra) on a conforming dispatcher. A
non-empty array with zero string entries now takes the
triage-unavailable path: review everything, disclose the skipped
triage. A genuinely empty array still empties the review and the gate
waiver still applies.

* [jwies/review-scripted-dispatch] review: show the correctness reviewer one complete example finding

The correctness pass emitted the ReportFindings-style shape in run
29943085279 even with the submit_result instruction appended (defect-13
lineage), and the salvage path is demonstrably lossy (the same run's
suggested_patch drop). The output contract now carries one filled-in
example finding naming the required keys and the drift keys not to
substitute. Format emphasis only; no review rule or severity guidance
changes. Verified with gh aw compile against a scratch consumer layout
(.github/aw/review/config.md staged): 0 errors, 0 warnings.

* [jwies/review-scripted-dispatch] review: record the trial hardening in the scripted-dispatch changeset

* [jwies/review-scripted-dispatch] review: recalibrate cross-source claim dedup on the 30301235749 trial

Run 30301235749 (Khan/webapp#41118) had five sources flag one TTL-unit
defect at expiration.go:38, merged none of them, and posted three
blocking comments plus a note for a single defect; it also made one
false merge, folding the skill-auditor's AddDate handoff at :38 into
the unrelated missing-test todo at :62. Replaying the module over that
run's real pre-dedup claims (out/claims.json is written AFTER the
merge, so the input is that file with the recorded merge undone and the
skill-auditor handoff restored from out/skill-auditor.json) shows the
floors were not the cause. A label-shape reviewer that omits
failure_scenario is handed its own subject back as one, so the default
correctness pass, a one-line title with all of its evidence in
discussion, reached the comparison as eleven content tokens repeated
twice and scored 0.13-0.23 Jaccard against the four discursive copies
on a 0.20 floor. No threshold can fix that: run 29943085279's distinct
same-line issue/thought pair (0.192/0.350/3) outscores two of the true
duplicates on both ratios, so any floor loose enough to catch the
cluster admits it.

A claim whose failure scenario only restates its subject is now
compared on its discussion instead. Only those claims: feeding the
discussion to every claim was measured and rejected, since it lifts
that same-line negative to ten shared bigrams, above every real
duplicate. The floors then split in two tiers, an identical (path,
line) from two sources being evidence of one defect while a differing
line is weak evidence of two: exact anchor at 0.14/0.34/4, any other
line on the same path at 0.20/0.35/6. The tighter other-line tier is
what rejects the false merge (five shared bigrams; both real
different-line duplicates share six or more).

Replayed over the two runs whose pre-validation claim sets survive as
artifacts, all 58 same-path cross-source pairs land on ground truth:
30301235749 collapses its five :38 copies into the correctness issue
and keeps the :62 todo, 29897276810's three-way :38 group merges
exactly as before, and no other pair moves. Nothing over-merges. Run
29943085279 staged no claims.json, so its pairs stay fixture-held,
checked against the comments it actually posted. Both floors are minima
over real claims and the margins are one unit (4 bigrams against 3, 6
against 5); dedup.test.ts carries the run's real claim texts so the
next recalibration re-derives them rather than nudging them.

* [jwies/review-scripted-dispatch] review: validate the validator's corrected fields per key

`applyVerifications` applied every `corrected` field through one
string-or-number check, so `corrected.label` could drift out of the
label vocabulary and `corrected.line` could land a string in a numeric
anchor. An out-of-vocabulary label on a confirmed BLOCKING claim fails
`isBlockingLabel` and silently drops it from the REQUEST_CHANGES verdict:
the drift class `fromLabelShape` already gates on the finder side, left
ungated on the validator side. Validate per key and keep the finder's
original when a correction is rejected.

* [jwies/review-scripted-dispatch] review: split the self-contained glob matcher and dispatch-mode tests out

main's lens-payload seam (#281) left router.ts at 999 lines against the
1000-line cap, so this branch's twelve lines of dispatchMode plumbing
breached it, and the unioned dispatch-mode tests did the same to
router.test.ts. Move the section already marked self-contained into
glob-match.ts (re-exported from router.ts so no importer changes) and the
dispatch-directive CLI tests into router-dispatch-mode.test.ts, the same
split dispatch-contracts.ts took from dispatch.ts.
jwbron added a commit that referenced this pull request Jul 30, 2026
…ad suppression, deterministic cache record (#288)

* [jwies/review-decision-chokepoints-local] review: rule 5 anchors on the cache-memory stamp; rule 6 reads the reconciler out-file leniently

* [jwies/review-trial-followups] review: post-trial follow-ups; structured sub-agent finals, open-thread suppression, deterministic cache record

The three highest-value in-repo suggestions from the lifecycle trial's
consolidated report (suggestions h, g, and b on #284):

- Structured sub-agent finals: every scripted dispatch exposes an
  in-process submit_result MCP tool whose input is validated against the
  agent's exact output contract at the tool boundary; a drifted shape is
  rejected back to the model in-session with the precise contract error
  (defect 13: three drift shapes in five runs, two voided correctness
  dimensions). Free-text finals remain the fallback.
- Open-thread suppression at dispatch: a candidate describing a defect an
  open bot thread already tracks posts no duplicate (same path plus the
  #245 similarity floor; reconciler-resolved threads exempt); a suppressed
  blocking candidate still floors the verdict at REQUEST_CHANGES.
- Deterministic Step 9 cache write: in scripted mode the orchestrator
  invokes lib/cache-record.ts once after emitting; the fingerprint-carrier
  fields are copied verbatim from staged files and corroborated against
  the safe-output queue. risksPatternsKey becomes code-owned end to end.

Verification: 1254 tests green, typecheck and eslint clean, frontmatter
compiles clean under gh-aw v0.81.6 (gate step ordering verified against
the compiled lock; the cache writer cannot be a post-step because the
compiled agent job uploads cache-memory before post-steps run).

* [jwies/review-trial-followups] review: address #288 review feedback

- computeRisksPatternsKey: the risk-tier filter matched the literal
  "moderate" while the triage contract emits Medium/High; every
  medium-risk file was silently dropped from the signature, so Step 7
  could not detect a guidance change confined to medium-risk files.
  Accept medium (moderate stays tolerated) and pin the contract
  vocabulary in the fixtures.
- Open-thread suppression floor: record the matched thread opener's
  blocking-ness (threadBlocking) and floor the verdict only when both
  the suppressed candidate and the thread opener are blocking. A
  false-positive blocking candidate matching an open non-blocking
  thread can no longer force REQUEST_CHANGES with no validation and no
  visible blocking comment.
- risks-patterns-key.txt now stages at full depth only. Step 7 skips
  every reduced depth including scoped, and a scoped run's triage sees
  only the scoped subset; staging that narrower signature could
  collapse the standing full-run guidance.
- The cache-record CLI emits ::warning on corroboration/staging
  refusals (a systematic refusal permanently stales the fingerprint);
  the benign no-ops (task mode, gate-blocked) stay quiet.
- readQueue parses the safe-output JSONL per line, so one truncated
  tail line no longer discards the whole queue.
- dispatch-runner gets direct tests via a mocked Agent SDK: the
  submit_result accept/reject handler, the structured final beating
  the free-text final, and the salvage of an accepted payload after
  the session dies.

* [jwies/review-trial-followups] review: enforce bot-authored openers on open-thread suppression in code

threads.json staging is prompt-executed (review.md asks the
orchestrator for the unresolved github-actions[bot] threads; stage-pr
deliberately does not stage threads yet), and the dispatcher read the
opener body without ever reading its author, so a mis-staged human
thread on the same path could silently suppress a bot candidate before
validation; its free-text opener would also read as non-blocking and
skip the verdict floor. The OpenThread build moves to
openThreadsFromStaged (dedup.ts, beside its consumers) and admits only
threads whose opening comment the bot authored, the same code-level
stance stage-pr already takes for prior-reviews.json. Fails closed: a
thread without a bot opener never suppresses, worst case a duplicate
comment. Eval staging (live-stage.ts) already writes bot-authored
openers.

* [jwies/review-trial-followups] review: record the suppression hardening in the followups changeset

* [jwies/review-trial-followups] review: address the reviewer feedback on the trial follow-ups

Three items from the bot review on #288.

1. `threadOpenerIsBlocking` matched a hand-rolled `\(blocking\)`, which
   cannot match `issue (blocking, best-practice)`, a real BLOCKING_LABELS
   entry. A suppressed re-confirmation of such a thread recorded
   `threadBlocking: false`, the both-blocking floor in submission.ts never
   fired, and the run could flip to APPROVE over a still-open, re-confirmed
   blocking objection: exactly the invariant the suppression adds. The
   opener's label is now parsed off the template and classified by
   `isBlockingLabel`, the taxonomy's own rule (spacing inside the parens
   normalized first).

2. Open-thread suppression depended on staged threads being unresolved, but
   only review.md prose enforced that, the same gap the bot-authorship
   hardening closed. `get_review_comments` returns `is_resolved` per thread,
   so review.md now stages it as `resolved` and `openThreadsFromStaged`
   admits only threads that carry an explicit false. Fails closed like the
   author check: worst case is a duplicate comment.

3. The cache-record writer trusted the plan alone when no safe-output queue
   was readable, citing the dispatch gate. The gate runs on the post-ingest
   agent_output.json, after cache-memory is committed, so it cannot protect
   the record: a run whose emission silently failed recorded "review posted"
   with the current fingerprints, and the next run stamped against an
   unreviewed diff. An unreadable queue is now a refuse-with-warn, except the
   one shape that legitimately queues nothing (the Step 6 redundant-approval
   skip), keeping the failure pointed at a fuller next review.

Suite green (1278 tests), typecheck and lint clean.

* [jwies/review-trial-followups] review: fold the NOTIFIED signature into the staged guidance key

review.md's Step 7 defines the canonical risks/patterns signature to
include `notified.json`'s signature and treats the NOTIFIED match set as an
independent repost trigger, but `computeRisksPatternsKey` built the key
from risks, patterns, exclusions and owners only. Because the key is staged
at Step 4, a NOTIFIED-only change produced an identical key on both sides
of the Step 7 compare: the guidance comment never reposted and a newly
subscribed `.github/NOTIFIED` @team was silently never mentioned, with the
notified-less key persisting as the baseline.

* [jwies/review-trial-followups] review: compute the NOTIFIED signature in the submission CLI

The NOTIFIED fold was dead at runtime in scripted mode. `runSubmissionCli`
read `notified.json`, but the notified CLI does not run until Step 7 and
nothing stages that file earlier, so `signature` was always undefined and
`computeRisksPatternsKey` dropped the `notified:` component on every real
run. A `.github/NOTIFIED`-only change then staged a key identical to the
prior run's, Step 7 read the guidance as unchanged, and a newly-subscribed
team was never mentioned: exactly the failure the fold was added to fix. The
existing test passed only because it injected the signature directly.

Compute it here instead of depending on step ordering. `notified.ts`'s
`runCli` is pure over staged inputs the pre-agent step writes as hard
prerequisites (`files.json`, `full.diff`), so it is safe to call this early,
and it stages `notified.json` as a side effect, which makes Step 7's own
invocation idempotent rather than first-of-its-kind.

The tests go in a new `submission-notified.test.ts` rather than another
block in `submission.test.ts`, which sits within ten lines of the
1000-line max-lines budget and went over when they were added there. None
of them injects a signature, which is the point: one asserts the component
lands without `notified.json` staged first, one asserts a NOTIFIED-only
change moves the key (the assertion that fails against the read-from-file
version), and one asserts a repo without the file still stages the
pre-feature key.
jwbron added a commit that referenced this pull request Jul 31, 2026
…rds cannot validate a comparedText change

Run 30650642317 raised a second question about dedup precision. Three claims
from three sources described one defect (the zero-value doc/panic contradiction)
and the run merged none of them, while suppression matched all three to the same
open thread on a STRICTER floor: clearing the harder bar and failing the easier
one points at the text basis, not the thresholds. `comparedText` feeds merge
`subject + failure_scenario` (or `subject + discussion` only when the failure
restates the subject), so the evidence a reviewer writes in `discussion` is
usually absent from the merge comparison and present in suppression's.

Reading `discussion` unconditionally merges two of those three pairs
(correctness/documentation 2 -> 12 shared bigrams, first-principles /
documentation 2 -> 4 at the same line) and leaves the whole suite green, which
looks like a free win and is not one: the three false-merge guards leave
`discussion` at the `claim()` default "d", one character the tokenizer drops, so
the candidate is a no-op for them, and dedup.ts's own comment records the
opposite outcome on the real claims (run 29943085279's distinct same-line
issue/thought pair reaching ten shared bigrams).

So this documents the gap instead of shipping the change. Suppression masked the
cost on that run; against a resolved thread the same three claims post three
comments for one defect, the #245 problem. Re-deriving the tradeoff needs the
real claim sets for runs 29943085279 and 30301235749 with their discussions,
which these fixtures do not carry.
jwbron added a commit that referenced this pull request Jul 31, 2026
… the first staged one (#310)

* [jwies/suppression-best-match] review: attribute a suppression to its best-matching open thread, not the first staged one

Open-thread suppression took the FIRST staged thread a candidate cleared the
similarity floor against. `threadBlocking` is read off that thread and feeds
submission.ts's verdict floor, so staging order could decide a verdict.

Measured on webapp#41204 run 30650642317, the first live run where suppression
fired at all: the fresh test-adequacy todo ("No test covers Record's documented
zero-valued-Window guarantee") cleared the floor against both the blocking
nil-map panic thread and its own exact counterpart, a non-blocking nitpick whose
opener reads "The documented zero-value-Window guarantee has no test". The
blocking thread was staged first because it was older, so the suppression
recorded threadBlocking: true and added a second entry to the floor. That run's
REQUEST_CHANGES was already correct on another suppression's strength, so nothing
was mis-decided, but the direction is the dangerous one, and the same accident can
hand a candidate a non-blocking thread when a blocking one is its real
counterpart, silently lowering the floor instead.

Ranked on jaccard first because it is the length-normalized metric: raw
sharedBigrams grows with the opener's length and overlap divides by the smaller
token set, so both favor the longer, more discursive thread. On the observed pair
jaccard (0.253 vs 0.218) and bigrams (13 vs 12) both pick the true counterpart
while overlap alone (0.468 vs 0.511) picks the wrong one, so bigrams break
jaccard ties and overlap never ranks. Comparisons are strictly-better, so staging
order stays the final tiebreak and dispatch-result.json stays reproducible.

The regression case stages the blocking thread first, as the live run did, and
asserts the same attribution with the pair staged either way round.

* [jwies/suppression-best-match] review: record why the false-merge guards cannot validate a comparedText change

Run 30650642317 raised a second question about dedup precision. Three claims
from three sources described one defect (the zero-value doc/panic contradiction)
and the run merged none of them, while suppression matched all three to the same
open thread on a STRICTER floor: clearing the harder bar and failing the easier
one points at the text basis, not the thresholds. `comparedText` feeds merge
`subject + failure_scenario` (or `subject + discussion` only when the failure
restates the subject), so the evidence a reviewer writes in `discussion` is
usually absent from the merge comparison and present in suppression's.

Reading `discussion` unconditionally merges two of those three pairs
(correctness/documentation 2 -> 12 shared bigrams, first-principles /
documentation 2 -> 4 at the same line) and leaves the whole suite green, which
looks like a free win and is not one: the three false-merge guards leave
`discussion` at the `claim()` default "d", one character the tokenizer drops, so
the candidate is a no-op for them, and dedup.ts's own comment records the
opposite outcome on the real claims (run 29943085279's distinct same-line
issue/thought pair reaching ten shared bigrams).

So this documents the gap instead of shipping the change. Suppression masked the
cost on that run; against a resolved thread the same three claims post three
comments for one defect, the #245 problem. Re-deriving the tradeoff needs the
real claim sets for runs 29943085279 and 30301235749 with their discussions,
which these fixtures do not carry.
@khan-actions-bot khan-actions-bot mentioned this pull request Aug 5, 2026
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.

2 participants