feat(ship): judge may dismiss a tagged closing summary on record (GST-435 Part 2)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-31 17:50:15 +00:00
1 parent 7feb36ca76
commit 1b23f8188c
5 files changed
+142 -18

No files matched your search

+5 -3
View File
@@ -48,8 +48,9 @@ const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free
until the brief is re-sent and answered with a review, which voids it. --unavailable '<reason>' (the outside voice only; refused when the
output holds findings) records its failure text as missing coverage: COLLECT_UNAVAILABLE, and verify prints UNAVAILABLE and unavailable=N, never a review
judge --ledger <f> --merged <merged.jsonl> --judged <judged.jsonl>
judged lines {"id","severity","reason"?,"duplicate_of"?}: every merged id kept at its severity or above (a
duplicate folds into one at least as severe); added and promoted findings need a reason. JUDGMENT: ok …, or
judged lines {"id","severity","reason"?,"duplicate_of"?,"dismissed"?:{"quote","reason"}}: every merged id kept at its
severity or above (a duplicate folds into one at least as severe); added and promoted findings need a reason; only a
summary_candidate line may be dismissed, quoted byte for byte with a reason (verify prints DISMISSED: lines). JUDGMENT: ok …, or
JUDGMENT_REJECTED: dropped=… downgraded=… (exit 1)
verify --ledger <f> [--targeted-out <file>]
BRIEF, OUTPUT and JUDGE rows from the ledger alone, then VERIFY: clean (exit 0), VERIFY: dirty (exit 1) or
@@ -290,13 +291,14 @@ function main(): number {
const judged = flag('--judged');
if (!merged || !judged) { process.stderr.write('gstack-review-dispatch judge: needs --merged <file> and --judged <file>\n'); return EXIT.usage; }
const r = recordJudgment(ledger, readJsonl<MergedLine>(merged), readJsonl<JudgedLine>(judged), judged, merged);
process.stdout.write(r.ok ? `JUDGMENT: ok added=${r.added.length} promoted=${r.promoted.length} folded=${r.folded.length}\n`
process.stdout.write(r.ok ? `JUDGMENT: ok added=${r.added.length} promoted=${r.promoted.length} folded=${r.folded.length}${r.dismissed.length ? ` dismissed=${r.dismissed.length}` : ''}\n`
: `JUDGMENT_REJECTED: dropped=${JSON.stringify(r.dropped)} downgraded=${JSON.stringify(r.downgraded)} unexplained=${JSON.stringify(r.unexplained)} invalid=${JSON.stringify(r.invalid)} (lead judgment may add or promote a finding, never drop or downgrade one)\n`);
return r.ok ? EXIT.ok : EXIT.fail;
}
if (cmd === 'verify') {
const v = verify(ledger);
for (const r of v.rows) process.stdout.write(`${r.kind} ${r.reader} ${r.status} ${r.detail}\n`);
for (const d of v.dismissals) process.stdout.write(`DISMISSED: ${d.id} ${d.label} ${JSON.stringify(d.quote)}\n`);
if (!v.briefs) { process.stdout.write('VERIFY: no brief records (this run\'s ledger kept no prompts; dispatched-vs-rendered cannot be checked)\n'); return EXIT.refused; }
const record = targetedRecord(ledger, v.rows);
if (record) {
+11
View File
@@ -0,0 +1,11 @@
**/ship's lead judgment can now dismiss a reader's own closing summary, on record.** HARN-1 paid for extra review passes because lines like "I found three INFORMATIONAL issues and nothing CRITICAL." collected as CRITICAL findings that `judge` would not let go. Collect still keeps every label, but it now tags a line `summary_candidate` when one of its clauses matches a closed list of closing-summary templates and nothing else on the line could be a finding. The judge may dismiss only a tagged line, quoting it byte for byte with a reason. `verify` re-derives each dismissal from the reader's unchanged output and prints a `DISMISSED:` line for it.
### Known
- **A blocker sentence with no label, file:line or "block" word after a summary is tagged.** "Neither finding is CRITICAL. The token leak must be fixed." can be dismissed, and only the judge's recorded, quoted reason covers it. "The token leak blocks the merge." after the same summary is not tagged.
- HARN-15's "None of the fixes blocks landing; I found four INFORMATIONAL defects and no CRITICAL." is not tagged, because of its "block" word, so it stays a phantom.
### Tests
- `test/review-dispatch.test.ts`: tagged and untagged lines, including the review rounds' lost-blocker shapes; a dismissal that is untagged, paraphrased, unreasoned or folded into is a drop; verify prints the dismissal and turns dirty when the output file changes.
### Docs
- `docs/review-policy.md` (collect, judge, verify) describes the tag, the dismissal rule and the residual risk.
+15 -2
View File
@@ -470,7 +470,10 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
wherever they stand, headings and table rows included; a top-level list item under a
label-only heading takes the stronger of the two). Every label word counts, a reader's own
closing summary included ("I found nothing CRITICAL", "Neither finding is CRITICAL"): it
collects at its label and stays at that label in the judged set (GST-435). A `Severity:` or `Blocks:` line
collects at its label (GST-435). Collect also tags such a line `summary_candidate` when one of its clauses
matches a closed list of closing-summary templates and nothing else on it could be a finding. That means no
other label, no file:line, no "block" word, no `Blocks: yes`, no JSON and no joined field line. A tagged line still enters
the judged set at its label; only `judge` may dismiss it (step 4). A `Severity:` or `Blocks:` line
continues the finding above it until a list item, heading, bold paragraph opener or a
second line for the same field starts another: `Severity` raises its label,
`Blocks: yes` makes it CRITICAL and `Blocks: no` never lowers it; an inline
@@ -487,6 +490,13 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
finding, lowers its severity, folds it into a less severe duplicate, or adds or
promotes one without a reason (`JUDGMENT_REJECTED`). A merged file missing any id
the ledger collected is a drop. Each addition and promotion is logged with its reason.
A judged line may carry `dismissed: {quote, reason}` only for a merged line that
`summaryCandidate` tags from its own raw text, quoted byte for byte, with a reason.
Any other dismissal is a drop, and so is a duplicate folded into a dismissed line. Each
dismissal is logged with its label, quote and reason. Known residual risk: a blocker
sentence with no label, file:line or "block" word after a summary ("Neither finding is
CRITICAL. The token leak must be fixed.") is tagged, so only the judge's recorded,
quoted reason stands between it and a dismissal.
5. `verify --ledger <f>` replays all of it from the run's ledger: rendered against
dispatched hashes per render (each render is one dispatch of its reader, so a
two-pass ledger checks pass-1 sends against pass-1 briefs; a later render prints as
@@ -501,7 +511,10 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
byte-exact before anything was collected is void and counted (`voided=N` on the
verdict line); one that produced an output stays `MISMATCH`. Then output hashes
against the merged lines they produced, and the judgment rerun against the
recorded judged set, covering every collected id. A ledger from a run that kept no
recorded judged set, covering every collected id. Each dismissal is re-derived from the
reader's unchanged output file as a tagged finding (`dismissal_not_rederived` otherwise);
the JUDGE row prints `dismissed=N` and verify prints one `DISMISSED: <id> <label> "<quote>"`
line per dismissal for the PR body. A ledger from a run that kept no
prompts prints `VERIFY: no brief records` (exit 3), never clean.
6. `pass --base <b> --out <dir> --pass <id> -- <plan flags>` runs one pass from a
staged manifest (`<dir>/pass-manifest.json`): audit readers marked first, then
+64 -12
View File
@@ -64,7 +64,35 @@ const LOCUS = /[\w./-]+\.\w+:\d+/;
/** A line that is only a finding's `Severity` or `Blocks` field ("Severity: High", "- Blocks: yes", "Severity High"). */
const META = new RegExp(`^(?:[-*+]\\s+)?\\**\\s*(Severity|Blocks)\\**\\s*(?::|\\s+\\**(?:${LABELS}|yes|no)(?![A-Za-z0-9]))`, 'i');
export interface MergedLine { id: string; reader: string; label: string; raw: string; output_sha256: string }
export interface MergedLine { id: string; reader: string; label: string; raw: string; output_sha256: string; summary_candidate?: true }
const SUMMARY_NONE = '(?:no|nothing(?: at)?)';
const SUMMARY_KIND = '(?:issues?|findings?|defects?|ones?|problems?)';
/**
* GST-435's closed list of a reader's closing-summary sentences ("I found three INFORMATIONAL issues and nothing CRITICAL",
* "Neither finding is CRITICAL"), each taken from a HARN-15 or GEVA-77 reply. "Nothing else" and "no other" stay out: they say
* something else is CRITICAL.
*/
const SUMMARY_TEMPLATES = [
new RegExp(`^(?:I found|I see|there (?:are|is)|there's) ${SUMMARY_NONE} CRITICAL(?: ${SUMMARY_KIND})?$`, 'i'),
new RegExp(`^I found (?:\\w+) INFORMATIONAL ${SUMMARY_KIND} and ${SUMMARY_NONE} CRITICAL(?: ${SUMMARY_KIND})?$`, 'i'),
/^(?:neither|none of (?:these|them|the \w+)) (?:(?:finding|findings|fix|fixes) )?(?:is|are) CRITICAL$/i,
/^nothing (?:here|in this diff) is CRITICAL$/i,
new RegExp(`^no CRITICAL(?: or High)? ${SUMMARY_KIND}(?: to report)?$`, 'i'),
];
const summaryClauses = (raw: string) => raw.split(/(?<=[.!?])\s+|;\s+|:\s+|,\s+so\s+/)
.map(c => c.trim().replace(/[.!?]+$/, '').replace(/^(?:[-*+]|\d+[.)])\s+/, '').replace(/\*\*/g, '')).filter(Boolean);
/**
* A collected prose line is a summary_candidate when one of its clauses is a closing-summary template and nothing else on it could
* be a finding: no label outside the template clauses, no file:line, no "block" word, no Blocks: yes, no JSON and no joined field
* line. Collect still keeps the line at its label; only a tagged line may be dismissed by judge, on record with its quote.
*/
export function summaryCandidate(raw: string): boolean {
if (raw.includes(' | ') || raw.trimStart().startsWith('{') || BLOCKS_YES.test(raw) || LOCUS.test(raw)) return false;
const clauses = summaryClauses(raw);
const rest = clauses.filter(c => !SUMMARY_TEMPLATES.some(t => t.test(c)));
return rest.length < clauses.length && rest.every(c => !ANY_LABEL.test(c) && !/block/i.test(c));
}
/**
* One reader's findings as written: a JSON object per line (severity kept as written); else each prose line carrying a severity or
@@ -160,7 +188,7 @@ const hasCleanMarker = (reader: string, output: string) => output.replace(/\r\n/
export function collect(ledger: string, reader: string, output: string, mergedFile: string, outputFile?: string, unavailable?: string): { sha: string; lines: MergedLine[]; review: boolean } {
const sha = promptHash(output);
const prior = readLedger(ledger).filter(e => e.event === 'output' && e.reader === reader).length;
const lines = unavailable ? [] : parseFindings(reader, output).map((f, i) => ({ id: `${reader}#${prior ? `${prior + 1}.` : ''}${i + 1}`, reader, label: f.label, raw: f.raw, output_sha256: sha }));
const lines = unavailable ? [] : parseFindings(reader, output).map((f, i): MergedLine => ({ id: `${reader}#${prior ? `${prior + 1}.` : ''}${i + 1}`, reader, label: f.label, raw: f.raw, output_sha256: sha, ...(summaryCandidate(f.raw) ? { summary_candidate: true as const } : {}) }));
const review = !unavailable && (lines.length > 0 || hasCleanMarker(reader, output));
fs.appendFileSync(mergedFile, lines.map(l => JSON.stringify(l) + '\n').join(''), { mode: 0o600 });
appendEvent(ledger, { event: 'output', reader, sha256: sha, file: outputFile ? path.resolve(outputFile) : null, merged: path.resolve(mergedFile), ids: lines.map(l => l.id), review, ...(unavailable ? { unavailable } : {}) });
@@ -174,21 +202,29 @@ export function readJsonl<T>(file: string): T[] {
return fs.readFileSync(file, 'utf8').split('\n').filter(l => l.trim()).map(l => JSON.parse(l) as T);
}
export interface JudgedLine { id: string; severity: string; reason?: string; duplicate_of?: string }
export interface Judgment { ok: boolean; dropped: string[]; downgraded: string[]; unexplained: string[]; invalid: string[]; added: string[]; promoted: string[]; folded: string[] }
export interface JudgedLine { id: string; severity: string; reason?: string; duplicate_of?: string; dismissed?: { quote?: string; reason?: string } }
export interface Judgment { ok: boolean; dropped: string[]; downgraded: string[]; unexplained: string[]; invalid: string[]; added: string[]; promoted: string[]; folded: string[]; dismissed: string[] }
/** The lead-judgment rule: every merged finding survives at its severity or above; additions and promotions carry a reason. */
/**
* The lead-judgment rule: every merged finding survives at its severity or above; additions and promotions carry a reason. A dismissal
* keeps no survivor, so it counts only for a line summaryCandidate tags from its own raw text, quoted byte for byte, with a reason.
*/
export function judge(merged: MergedLine[], judged: JudgedLine[]): Judgment {
const byId = new Map(judged.map(j => [j.id, j]));
const mergedIds = new Set(merged.map(m => m.id));
const res: Judgment = { ok: true, dropped: [], downgraded: [], unexplained: [], invalid: [], added: [], promoted: [], folded: [] };
const res: Judgment = { ok: true, dropped: [], downgraded: [], unexplained: [], invalid: [], added: [], promoted: [], folded: [], dismissed: [] };
for (const j of judged) if (rank(j.severity) === null) res.invalid.push(j.id);
for (const m of merged) {
const j = byId.get(m.id);
if (!j) { res.dropped.push(m.id); continue; }
if (j.dismissed) {
const recorded = !j.duplicate_of && summaryCandidate(m.raw) && j.dismissed.quote === m.raw && !!j.dismissed.reason?.trim();
(recorded ? res.dismissed : res.dropped).push(m.id);
continue;
}
const target = j.duplicate_of ? byId.get(j.duplicate_of) : j;
// A duplicate folds into a different line that stands on its own; a self-fold, a cycle or a chain keeps no survivor.
if (!target || (j.duplicate_of && (j.duplicate_of === j.id || target.duplicate_of))) { res.dropped.push(m.id); continue; }
if (!target || (j.duplicate_of && (j.duplicate_of === j.id || target.duplicate_of || target.dismissed))) { res.dropped.push(m.id); continue; }
const from = rank(m.label) ?? 0;
const to = rank(target.severity);
if (to === null) continue;
@@ -212,8 +248,9 @@ export function recordJudgment(ledger: string, merged: MergedLine[], judged: Jud
if (res.ok) {
for (const id of res.added) appendEvent(ledger, { event: 'judgment', kind: 'added', id, to: byId.get(id)!.severity, reason: byId.get(id)!.reason });
for (const id of res.promoted) appendEvent(ledger, { event: 'judgment', kind: 'promoted', id, from: labels.get(id), to: byId.get(id)!.severity, reason: byId.get(id)!.reason });
for (const id of res.dismissed) appendEvent(ledger, { event: 'judgment', kind: 'dismissed', id, label: labels.get(id), quote: byId.get(id)!.dismissed!.quote, reason: byId.get(id)!.dismissed!.reason });
}
appendEvent(ledger, { event: 'judge', ok: res.ok, judged: path.resolve(judgedFile), judged_sha256: promptHash(fs.readFileSync(judgedFile, 'utf8')), merged: path.resolve(mergedFile), merged_ids: merged.map(m => m.id), dropped: res.dropped, downgraded: res.downgraded, unexplained: res.unexplained, invalid: res.invalid, added: res.added.length, promoted: res.promoted.length, folded: res.folded.length });
appendEvent(ledger, { event: 'judge', ok: res.ok, judged: path.resolve(judgedFile), judged_sha256: promptHash(fs.readFileSync(judgedFile, 'utf8')), merged: path.resolve(mergedFile), merged_ids: merged.map(m => m.id), dropped: res.dropped, downgraded: res.downgraded, unexplained: res.unexplained, invalid: res.invalid, added: res.added.length, promoted: res.promoted.length, folded: res.folded.length, dismissed: res.dismissed.length });
return res;
}
@@ -234,9 +271,10 @@ const collectedIds = (events: LedgerEvent[]) => events.filter(e => e.event === '
export interface VerifyRow { kind: 'BRIEF' | 'OUTPUT' | 'JUDGE'; reader: string; status: string; detail: string }
/** Replay every check from a finished run's ledger: briefs dispatched as rendered, outputs merged as returned, judgment without loss. */
export function verify(ledger: string): { rows: VerifyRow[]; clean: boolean; briefs: number; voided: number; unavailable: number } {
export function verify(ledger: string): { rows: VerifyRow[]; clean: boolean; briefs: number; voided: number; unavailable: number; dismissals: Array<{ id: string; label: string; quote: string }> } {
const events = readLedger(ledger);
const rows: VerifyRow[] = [];
const dismissals: Array<{ id: string; label: string; quote: string }> = [];
const short = (s: unknown) => (typeof s === 'string' ? s.slice(0, 12) : '-');
// Each render opens one dispatch of its reader (a pass); its sends and outputs run until that reader's next render.
const renders = events.flatMap((e, i) => (e.event === 'rendered' ? [i] : []));
@@ -306,15 +344,29 @@ export function verify(ledger: string): { rows: VerifyRow[]; clean: boolean; bri
const missing = collectedIds(events.slice(0, lastJudge)).filter(id => !judgedIds.has(id));
if (missing.length) notes.push(`merged_ids_missing=${missing.join(',')}`);
let rerun: Judgment | null = null;
let merged: MergedLine[] = [];
try {
const judgedText = fs.readFileSync(String(v.judged), 'utf8');
if (promptHash(judgedText) !== v.judged_sha256) notes.push('judged_changed_since_judge');
rerun = judge(readJsonl<MergedLine>(String(v.merged)), readJsonl<JudgedLine>(String(v.judged)));
merged = readJsonl<MergedLine>(String(v.merged));
rerun = judge(merged, readJsonl<JudgedLine>(String(v.judged)));
} catch { notes.push('judged_or_merged_unreadable'); }
if (rerun && !rerun.ok) notes.push(`rerun_rejects dropped=${JSON.stringify(rerun.dropped)} downgraded=${JSON.stringify(rerun.downgraded)}`);
// A dismissal stands only when its line is re-derived from the reader's unchanged output file as a tagged finding.
const unproven = (rerun?.dismissed ?? []).filter(id => {
const m = merged.find(l => l.id === id);
const o = events.slice(0, lastJudge).find(e => e.event === 'output' && Array.isArray(e.ids) && (e.ids as string[]).includes(id));
let text: string | undefined;
try { text = o && typeof o.file === 'string' ? fs.readFileSync(o.file, 'utf8') : undefined; } catch { /* unreadable */ }
if (!m || !o || text === undefined || promptHash(text) !== o.sha256) return true;
if (!parseFindings(String(o.reader), text).some(f => f.raw === m.raw && summaryCandidate(f.raw))) return true;
dismissals.push({ id, label: m.label, quote: m.raw });
return false;
});
if (unproven.length) notes.push(`dismissal_not_rederived=${JSON.stringify(unproven)}`);
const status = !v.ok ? 'REJECTED' : notes.length ? 'MISMATCH' : 'OK';
rows.push({ kind: 'JUDGE', reader: '-', status, detail: `dropped=${JSON.stringify(v.dropped)} downgraded=${JSON.stringify(v.downgraded)} added=${v.added} promoted=${v.promoted} folded=${v.folded}${notes.length ? ' ' + notes.join(' ') : ''}` });
rows.push({ kind: 'JUDGE', reader: '-', status, detail: `dropped=${JSON.stringify(v.dropped)} downgraded=${JSON.stringify(v.downgraded)} added=${v.added} promoted=${v.promoted} folded=${v.folded} dismissed=${v.dismissed ?? 0}${notes.length ? ' ' + notes.join(' ') : ''}` });
} else if (events.some(e => e.event === 'output')) rows.push({ kind: 'JUDGE', reader: '-', status: 'MISSING', detail: 'outputs were collected but no judged set was checked' });
const clean = episodes.length > 0 && rows.every(r => ['MATCH', 'OK', 'VOIDED', 'UNAVAILABLE'].includes(r.status));
return { rows, clean, briefs: episodes.length, voided, unavailable: rows.filter(r => r.kind === 'BRIEF' && r.status === 'UNAVAILABLE').length };
return { rows, clean, briefs: episodes.length, voided, unavailable: rows.filter(r => r.kind === 'BRIEF' && r.status === 'UNAVAILABLE').length, dismissals };
}
+47 -1
View File
@@ -4,7 +4,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { BriefError, promptHash, renderBrief, sectionTexts } from '../lib/review-brief';
import { collect, judge, parseFindings, readJsonl, type MergedLine } from '../lib/review-ledger';
import { collect, judge, parseFindings, readJsonl, summaryCandidate, type MergedLine } from '../lib/review-ledger';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-review-dispatch');
@@ -559,4 +559,50 @@ describe('gstack-review-dispatch sent, collect, judge and verify: nothing change
const v = run(['verify', '--ledger', ledger]);
expect([v.status, v.stdout.trim()]).toEqual([3, "VERIFY: no brief records (this run's ledger kept no prompts; dispatched-vs-rendered cannot be checked)"]);
});
// Value: protects=collect tags only a closing-summary line with nothing else on it that could be a finding, and judge lets a
// tagged line go only when the dismissal quotes it byte for byte with a reason that verify re-derives from the reader's output;
// fails_when=a line naming a blocker ("blocks", a file:line, a second label, Blocks: yes, a joined field) is tagged, an untagged
// or paraphrased or unreasoned dismissal or a fold into a dismissed line passes, or a dismissal survives an edited output file;
// why_new=GST-435 Part 2 (HARN-1's phantom summaries had no way out of the judged set); seam=summaryCandidate, collect, judge
// and verify; no_claim=that a tagged line is never a real blocker (known residual: "Neither finding is CRITICAL. The token leak
// must be fixed." is tagged, and only the judge's recorded, quoted reason covers it)
test('summary_candidate: only a tagged closing summary may be dismissed, quoted and reasoned, and verify re-derives it', () => {
const fixture = (name: string) => fs.readFileSync(path.join(ROOT, 'test/fixtures/review-ledger', name), 'utf8');
const first = (reader: string, name: string) => parseFindings(reader, fixture(name))[0]!.raw;
for (const [reader, name] of [['red-team', 'harn15-ship2-red-team.txt'], ['red-team', 'harn15-ship2-p2-red-team.txt'], ['fix-check', 'geva-77-fix-check.txt']])
expect([name, summaryCandidate(reader === 'fix-check' ? parseFindings(reader, fixture(name)).at(-1)!.raw : first(reader, name))]).toEqual([name, true]);
expect(summaryCandidate(first('fix-check', 'harn15-ship2-p2-fix-check.txt'))).toBe(false);
for (const line of ['There are no CRITICAL findings.', 'No CRITICAL or High findings to report.', 'I found nothing CRITICAL.', 'Neither finding is CRITICAL.',
'Nothing here is CRITICAL.', 'Neither fix is CRITICAL.', 'Nothing in this diff is CRITICAL.', 'Neither finding is CRITICAL. The token leak must be fixed.'])
expect([line, summaryCandidate(line)]).toEqual([line, true]);
for (const line of ['The token leak in log.ts:3 blocks the merge. Nothing else is CRITICAL.', 'Neither finding is CRITICAL. The token leak blocks the merge.',
'Neither finding is CRITICAL except the SQL injection', 'Nothing else is CRITICAL.', 'This is not CRITICAL.', 'Neither finding is CRITICAL. The race is High.',
'Neither finding is CRITICAL. Blocks: yes', 'Neither finding is CRITICAL | Severity: High', '{"severity":"CRITICAL","summary":"Neither finding is CRITICAL."}'])
expect([line, summaryCandidate(line)]).toEqual([line, false]);
const p = pass();
run(['sent', '--ledger', p.ledger, '--role', 'specialist', '--name', 'testing', '--sent', p.file]);
const summary = 'I found three INFORMATIONAL issues and nothing CRITICAL. All three are in the fix commit.';
const blocker = 'Neither finding is CRITICAL. The token leak blocks the merge.';
const out = p.w('spec.out', summary + '\n\n' + blocker + '\n');
expect(run(['collect', '--ledger', p.ledger, '--role', 'specialist', '--name', 'testing', '--output', out, '--merged', p.merged]).status).toBe(0);
expect(readJsonl<MergedLine>(p.merged).map(m => [m.id, m.label, m.summary_candidate ?? false])).toEqual([['specialist/testing#1', 'CRITICAL', true], ['specialist/testing#2', 'CRITICAL', false]]);
const jl = (rows: object[]) => p.w('judged.jsonl', rows.map(r => JSON.stringify(r)).join('\n') + '\n');
const keep = { id: 'specialist/testing#2', severity: 'CRITICAL' };
const dismiss = (quote: string, reason = 'the reader\'s own closing summary') => ({ id: 'specialist/testing#1', severity: 'INFORMATIONAL', dismissed: { quote, reason } });
for (const [rows, dropped] of [[[dismiss(summary), { ...keep, dismissed: { quote: blocker, reason: 'summary' } }], 'specialist/testing#2'],
[[dismiss('I found nothing CRITICAL.'), keep], 'specialist/testing#1'], [[dismiss(summary, ' '), keep], 'specialist/testing#1'],
[[dismiss(summary), { ...keep, duplicate_of: 'specialist/testing#1' }], 'specialist/testing#2']] as const) {
const r = run(['judge', '--ledger', p.ledger, '--merged', p.merged, '--judged', jl([...rows])]);
expect([r.status, r.stdout]).toEqual([1, expect.stringContaining('JUDGMENT_REJECTED: dropped=["' + dropped + '"]')]);
}
const ok = run(['judge', '--ledger', p.ledger, '--merged', p.merged, '--judged', jl([dismiss(summary), keep])]);
expect([ok.status, ok.stdout.trim()]).toEqual([0, 'JUDGMENT: ok added=0 promoted=0 folded=0 dismissed=1']);
expect(fs.readFileSync(p.ledger, 'utf8')).toContain('"kind":"dismissed","id":"specialist/testing#1","label":"CRITICAL","quote":' + JSON.stringify(summary));
const v = run(['verify', '--ledger', p.ledger]);
expect([v.status, v.stdout.includes('JUDGE - OK') && v.stdout.includes('dismissed=1'), v.stdout.includes('DISMISSED: specialist/testing#1 CRITICAL ' + JSON.stringify(summary))]).toEqual([0, true, true]);
fs.writeFileSync(out, summary + ' Nothing else.\n\n' + blocker + '\n');
const edited = run(['verify', '--ledger', p.ledger]);
expect([edited.status, edited.stdout.includes('dismissal_not_rederived=["specialist/testing#1"]'), edited.stdout.includes('DISMISSED:')]).toEqual([1, true, false]);
});
});