Files
gstack/lib/autoplan-reconcile.ts
Garry Tanandcapy-ai[bot] 140003be95 v1.91.72.0 feat(autoplan): unattended runner gstack-autoplan next|submit, pluggable outside voice, review checklists, spec-loop cap (multi-agent wave B-runner) (#3117)
* v1.91.70.0 feat(unattended): artifact contract, unattended session kind, findings-derived review log, gate list

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.72.0 feat(autoplan): unattended runner next|submit, pluggable outside voice, review checklists, spec-loop cap

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(b-runner): atomic lock creation in spend-ledger, judge bundle skips runner-only sections, fixture env spelling

- lib/spend-ledger.ts: create the lock by linking a complete temp file so a
  contender never reads an empty lock and breaks a live owner (CI admitted 20
  of a cap of 8 under contention); unreadable locks break only by mtime age.
- test/helpers/workflow-judge-input.ts: exclude manifest runner_only sections
  (the B4 checklists) from the judge bundle; the judge rates the interactive
  workflow. test/workflow-judge-input.test.ts expects the same set.
- test/helpers/autoplan-run-fixture.ts: spell the runner's session kind
  without the literal the free-suite environment scan rejects.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(spend-ledger): accept Windows' status-only report of the killed child

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-11 00:32:15 +00:00

224 lines
14 KiB
TypeScript

/**
* autoplan-reconcile — canonical reviewer findings and the reconciliation of
* an outside pass against the native one (plan B11, B0). Every reviewer
* returns, beside its prose, a full-read receipt (`INPUT: <phase> <sha256>`)
* and one ```gstack-findings fence of JSONL rows; `parseReviewResult` checks
* both. `canonicalRows` binds the rows to run-bound ids
* (`<run>-<phase>-<voice>-<n>`, the findings.jsonl shape in
* lib/headless-artifacts.ts). `reconcile` records, for each outside finding,
* `confirmed`, `disagree` or `new` against the native result from exact ids,
* keeping disagreement and resolution as separate fields; a parent may hand in
* a semantic reconciliation that overrides the structural one, validated
* against the same ids. The consensus table is rendered from these rows, so
* the reconciliation is the completeness check a prose hash cannot be.
*/
import type { FindingRow, Severity, Voice } from './headless-artifacts';
export const SEVERITIES: readonly Severity[] = ['Critical', 'High', 'Medium', 'Low', 'Informational'];
const SEVERITY_ALIASES: Record<string, Severity> = {
critical: 'Critical', p0: 'Critical', blocker: 'Critical',
high: 'High', p1: 'High', major: 'High',
medium: 'Medium', p2: 'Medium', moderate: 'Medium',
low: 'Low', p3: 'Low', minor: 'Low',
informational: 'Informational', info: 'Informational', note: 'Informational',
};
export interface CanonicalFinding {
severity: Severity; title: string; file?: string; line?: number; fix?: string; local_id?: string;
user_challenge?: boolean; urgent?: boolean; suggested_owner?: string; plan_items?: string[]; dimension?: string;
}
export interface ParsedReviewResult {
receipt: { ok: boolean; phase?: string; sha256?: string; line?: string };
findings: CanonicalFinding[];
fenceFound: boolean;
errors: string[];
}
export function severityOf(value: unknown): Severity | undefined {
if (typeof value !== 'string') return undefined;
return SEVERITY_ALIASES[value.trim().toLowerCase()];
}
/** Parse a reviewer's result: the INPUT receipt for this phase and snapshot, then the canonical findings fence(s). */
export function parseReviewResult(text: string, expected: { phase: string; sha256: string }): ParsedReviewResult {
const errors: string[] = [];
const head = text.split('\n').map(l => l.trim()).filter(Boolean).slice(0, 5);
const receiptLine = head.find(l => /^INPUT:\s*\S+\s+[0-9a-f]{64}\b/i.test(l));
const m = receiptLine ? /^INPUT:\s*(\S+)\s+([0-9a-f]{64})/i.exec(receiptLine) : null;
const receipt = m
? { ok: m[1]!.toLowerCase() === expected.phase && m[2]!.toLowerCase() === expected.sha256.toLowerCase(), phase: m[1]!.toLowerCase(), sha256: m[2]!.toLowerCase(), line: receiptLine }
: { ok: false };
if (!receipt.ok) errors.push(m ? `receipt names ${m[1]} ${m[2]!.slice(0, 12)}…, expected ${expected.phase} ${expected.sha256.slice(0, 12)}…` : 'no INPUT: <phase> <sha256> line in the first five lines');
const findings: CanonicalFinding[] = [];
let fenceFound = false;
for (const fence of text.matchAll(/^[ \t]*(`{3,}|~{3,})gstack-findings[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*\1[ \t]*$/gm)) {
fenceFound = true;
fence[2]!.split('\n').forEach((raw, i) => {
const line = raw.trim();
if (!line) return;
let row: any;
try { row = JSON.parse(line); } catch (e: any) { errors.push(`findings row ${i + 1}: ${e.message}`); return; }
if (!row || typeof row !== 'object' || Array.isArray(row)) { errors.push(`findings row ${i + 1}: not an object`); return; }
const severity = severityOf(row.severity);
if (!severity) { errors.push(`findings row ${i + 1}: severity ${JSON.stringify(row.severity)} is not one of ${SEVERITIES.join('|')}`); return; }
if (typeof row.title !== 'string' || !row.title.trim()) { errors.push(`findings row ${i + 1}: title is required`); return; }
const finding: CanonicalFinding = { severity, title: row.title.trim() };
if (typeof row.file === 'string' && row.file) finding.file = row.file;
if (Number.isInteger(row.line)) finding.line = row.line;
if (typeof row.fix === 'string' && row.fix) finding.fix = row.fix;
if (typeof row.id === 'string' && row.id) finding.local_id = row.id;
if (row.user_challenge === true) finding.user_challenge = true;
if (row.urgent === true) finding.urgent = true;
if (typeof row.suggested_owner === 'string') finding.suggested_owner = row.suggested_owner;
if (Array.isArray(row.plan_items)) finding.plan_items = row.plan_items.filter((p: unknown): p is string => typeof p === 'string');
if (typeof row.dimension === 'string') finding.dimension = row.dimension;
findings.push(finding);
});
}
if (!fenceFound) errors.push('no ```gstack-findings fence');
return { receipt, findings, fenceFound, errors };
}
/** Run-bound findings.jsonl rows for one voice of one phase. */
export function canonicalRows(findings: CanonicalFinding[], ctx: { run: string; phase: string; voice: Voice; model?: string }): FindingRow[] {
return findings.map((f, i) => ({
schema_version: 1, run: ctx.run, id: `${ctx.run}-${ctx.phase}-${ctx.voice}-${i + 1}`, phase: ctx.phase, voice: ctx.voice,
severity: f.severity, title: f.title, disposition: 'open',
...(ctx.model ? { model: ctx.model } : {}), ...(f.file ? { file: f.file } : {}), ...(f.line !== undefined ? { line: f.line } : {}),
...(f.fix ? { resolution: f.fix } : {}), ...(f.plan_items?.length ? { plan_items: f.plan_items } : {}),
...(f.local_id ? { source: f.local_id } : {}),
...(f.user_challenge ? { user_challenge: true } : {}), ...(f.urgent ? { urgent: true } : {}),
...(f.suggested_owner ? { suggested_owner: f.suggested_owner } : {}), ...(f.dimension ? { dimension: f.dimension } : {}),
} as FindingRow));
}
export type ReconciliationDisposition = 'confirmed' | 'disagree' | 'new';
export interface ReconciliationRow {
outside_id: string; native_id: string | null; disposition: ReconciliationDisposition;
/** Why this disposition: the matched location or title, or the parent's override reason. */
resolution: string;
basis: 'file_line' | 'title' | 'override' | 'none';
severity: { outside: Severity; native: Severity | null };
}
export interface ReconciliationOverride { outside_id: string; native_id?: string | null; disposition: ReconciliationDisposition; resolution: string }
const tokens = (title: string) => new Set(title.toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').split(/\s+/).filter(t => t.length > 2));
function jaccard(a: Set<string>, b: Set<string>): number {
if (!a.size || !b.size) return 0;
let inter = 0;
for (const t of a) if (b.has(t)) inter++;
return inter / (a.size + b.size - inter);
}
const level = (s: Severity) => SEVERITIES.indexOf(s);
/**
* Structural reconciliation: a file:line match (±3 lines) is `confirmed`
* unless severities differ by two or more levels (`disagree`); otherwise a
* title overlap of at least half the words is `confirmed`; else `new`. Each
* native finding is matched at most once. Overrides replace the structural
* row for their outside id and must name ids from these two lists.
*/
export function reconcile(native: FindingRow[], outside: FindingRow[], overrides: ReconciliationOverride[] = []): { rows: ReconciliationRow[]; native_only: string[]; errors: string[] } {
const errors: string[] = [];
const nativeIds = new Set(native.map(n => n.id));
const outsideIds = new Set(outside.map(o => o.id));
const byOutside = new Map<string, ReconciliationOverride>();
for (const o of overrides) {
if (!outsideIds.has(o.outside_id)) { errors.push(`override names unknown outside id ${o.outside_id}`); continue; }
if (o.native_id && !nativeIds.has(o.native_id)) { errors.push(`override for ${o.outside_id} names unknown native id ${o.native_id}`); continue; }
if (!['confirmed', 'disagree', 'new'].includes(o.disposition)) { errors.push(`override for ${o.outside_id}: disposition ${JSON.stringify(o.disposition)} is not confirmed|disagree|new`); continue; }
if (o.disposition !== 'new' && !o.native_id) { errors.push(`override for ${o.outside_id}: ${o.disposition} needs a native_id`); continue; }
if (typeof o.resolution !== 'string' || !o.resolution.trim()) { errors.push(`override for ${o.outside_id}: resolution is required`); continue; }
byOutside.set(o.outside_id, o);
}
const used = new Set<string>();
const rows: ReconciliationRow[] = [];
for (const o of outside) {
const override = byOutside.get(o.id);
if (override) {
const n = override.native_id ? native.find(x => x.id === override.native_id) : undefined;
if (n) used.add(n.id);
rows.push({ outside_id: o.id, native_id: n?.id ?? null, disposition: override.disposition, resolution: override.resolution, basis: 'override', severity: { outside: o.severity, native: n?.severity ?? null } });
continue;
}
let match: FindingRow | undefined;
let basis: ReconciliationRow['basis'] = 'none';
if (o.file && o.line !== undefined) {
match = native.find(n => !used.has(n.id) && n.file === o.file && n.line !== undefined && Math.abs(n.line - o.line!) <= 3);
if (match) basis = 'file_line';
}
if (!match) {
const ot = tokens(o.title);
let best = 0;
for (const n of native) {
if (used.has(n.id)) continue;
const score = jaccard(ot, tokens(n.title));
if (score >= 0.5 && score > best) { best = score; match = n; }
}
if (match) basis = 'title';
}
if (!match) { rows.push({ outside_id: o.id, native_id: null, disposition: 'new', resolution: 'not raised by the native voice', basis: 'none', severity: { outside: o.severity, native: null } }); continue; }
used.add(match.id);
const delta = Math.abs(level(match.severity) - level(o.severity));
rows.push({
outside_id: o.id, native_id: match.id, disposition: delta >= 2 ? 'disagree' : 'confirmed',
resolution: delta >= 2 ? `severity: native ${match.severity}, outside ${o.severity} (${basis === 'file_line' ? `${o.file}:${o.line}` : 'same title'})` : `both voices (${basis === 'file_line' ? `${o.file}:${o.line}` : 'same title'}): ${match.title}`,
basis, severity: { outside: o.severity, native: match.severity },
});
}
return { rows, native_only: native.filter(n => !used.has(n.id)).map(n => n.id), errors };
}
export interface ConsensusRecord {
schema_version: 1; phase: string;
native: { model: string | null; findings: number; by_severity: Record<Severity, number> } | null;
outside: { model: string | null; provider: string | null; findings: number; by_severity: Record<Severity, number> } | null;
confirmed: number; disagree: number; new: number; native_only: number;
rows: ReconciliationRow[]; native_only_ids: string[];
/** CONFIRMED requires both voices completed. */
coverage: 'both' | 'native_only' | 'outside_only' | 'none';
}
function bySeverity(rows: FindingRow[]): Record<Severity, number> {
const out = Object.fromEntries(SEVERITIES.map(s => [s, 0])) as Record<Severity, number>;
for (const r of rows) out[r.severity]++;
return out;
}
export function consensusRecord(phase: string, native: { rows: FindingRow[]; model?: string } | null, outside: { rows: FindingRow[]; model?: string; provider?: string } | null, rec: ReturnType<typeof reconcile>): ConsensusRecord {
const rows = native && outside ? rec.rows : [];
return {
schema_version: 1, phase,
native: native ? { model: native.model ?? null, findings: native.rows.length, by_severity: bySeverity(native.rows) } : null,
outside: outside ? { model: outside.model ?? null, provider: outside.provider ?? null, findings: outside.rows.length, by_severity: bySeverity(outside.rows) } : null,
confirmed: rows.filter(r => r.disposition === 'confirmed').length, disagree: rows.filter(r => r.disposition === 'disagree').length,
new: rows.filter(r => r.disposition === 'new').length, native_only: native && outside ? rec.native_only.length : (native?.rows.length ?? 0),
rows, native_only_ids: native && outside ? rec.native_only : (native?.rows.map(r => r.id) ?? []),
coverage: native && outside ? 'both' : native ? 'native_only' : outside ? 'outside_only' : 'none',
};
}
/** The consensus table and per-finding reconciliation, rendered from canonical ids. */
export function renderConsensus(c: ConsensusRecord, findings: FindingRow[]): string {
const title = (id: string | null) => findings.find(f => f.id === id)?.title ?? '';
const nat = c.native ? `${c.native.model ?? 'unknown'}(native)` : 'native: N/A';
const out = c.outside ? `${c.outside.model ?? 'unknown'}(${c.outside.provider ?? 'outside'})` : 'outside: N/A';
const lines = [
`${c.phase.toUpperCase()} DUAL VOICES — CONSENSUS TABLE:`,
` Severity ${nat.padEnd(28)} ${out}`,
...SEVERITIES.map(s => ` ${s.padEnd(15)} ${String(c.native?.by_severity[s] ?? '—').padEnd(28)} ${c.outside?.by_severity[s] ?? '—'}`),
` Findings ${String(c.native?.findings ?? '—').padEnd(28)} ${c.outside?.findings ?? '—'}`,
c.coverage === 'both'
? `CONFIRMED ${c.confirmed} · DISAGREE ${c.disagree} · NEW (outside only) ${c.new} · native only ${c.native_only}. Both completed.`
: `Consensus: N/A (voice coverage: ${c.coverage.replace('_', ' ')}); confirmed counts require both voices.`,
'',
'Reconciliation (each outside finding against the native result):',
'| outside | native | disposition | resolution |',
'|---|---|---|---|',
...c.rows.map(r => `| ${r.outside_id} ${title(r.outside_id)} | ${r.native_id ? `${r.native_id} ${title(r.native_id)}` : '—'} | ${r.disposition} | ${r.resolution} |`),
...c.native_only_ids.map(id => `| — | ${id} ${title(id)} | native_only | not raised by the outside voice |`),
];
return lines.join('\n') + '\n';
}