Files
gstack/lib/plan-reality.ts
T
garrytanandcapy-ai[bot] 78ee23240c fix(autoplan): empty findings fences, repo-relative rows, PLAN_REALITY flags, pending/--all/provider (Lane B: B3 B6 B7 B8 B9 B10 B11)
B3  parseFences: the body group is optional (lazy), so an LF/CRLF empty fence is zero
    findings in both the canonical and record grammars; opener without closer still
    RESULT_FINDINGS_MISSING.
B6  plan-reality/3: item/section references are not quantities; backtick phrases are
    exempt only from the plan-scope rule-phrase check (B-f); a measured number inside
    backticks stays flagged in review scope. vaguePlanTerms() feeds an advisory
    `flagged: PLAN_REALITY ...` line under the p1 approval item (never blocking).
B7  canonical rows: absolute repo paths become repo-relative; snapshot/active-plan
    citations become the plan file name with the source line mapped (or dropped).
B8  `status` prints `PENDING: <id> phase=<p> voice=<v> result=<file>` per open attempt.
B9  `gstack-gate render --all`; paged output says to relay every page.
B10 `gstack-plan-reality check --plan <snapshot>`: a number in a "quoted" span copied
    verbatim from the reviewed plan needs no receipt (reviewer scope only).
B11 submit defaults the provider to host-subagent when --runner is omitted.

Lane B files: lib/autoplan-reconcile.ts lib/plan-reality.ts lib/autoplan-gate.ts
lib/gate-list.ts lib/autoplan-plan-text.ts lib/autoplan-run.ts bin/gstack-gate
bin/gstack-plan-reality scripts/resolvers/plan-reality.ts autoplan/sections/ceo-phase.md
autoplan/sections/eng-phase.md test/plan-reality.test.ts test/autoplan-gate-artifacts.test.ts

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-14 23:56:48 +00:00

359 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* plan-reality — the reality rows every plan review must carry (multi-agent
* wave D1, release promise P5). One table owns the rows: the resolver
* scripts/resolvers/plan-reality.ts renders them into the plan-review
* templates, bin/gstack-plan-reality prints them at run time and checks a
* review for them, and the tests pin the row ids and the line grammar.
*
* A review emits one grep-able line per row:
*
* REALITY: <row-id> <pass|finding|n/a> <summary carrying a receipt>
*
* A receipt is `path:line`, `path:line-line` or a commit sha; a row without one
* is not a row. Tier 1 rows are required in every review. Tier 2 rows are
* required only when the review's detected scope (the manifest `scope` field
* from plan B4, passed to the checker as `--scope`) names theirs; otherwise
* they may be omitted or marked `n/a`. A tier 1 row marked `n/a` is missing.
*
* `checkRealityRows` is deterministic and returns the missing rows; the
* session kind decides what a miss means (unattended: the phase is
* `incomplete`; interactive: repair twice, then warn at the gate). The row
* table carries each row's incident, so the deep section can say why the row
* exists without a second table drifting.
*/
export const REALITY_SCOPES = ['always', 'db', 'perf', 'search', 'incident', 'dependency'] as const;
export type RealityScope = typeof REALITY_SCOPES[number];
export const REALITY_STATUSES = ['pass', 'finding', 'n/a'] as const;
export type RealityStatus = typeof REALITY_STATUSES[number];
export interface RealityRow {
id: string;
title: string;
tier: 1 | 2;
/** `always` for tier 1; the scope signal a tier 2 row needs. */
scope: RealityScope;
/** The operative check, one line, imperative. */
check: string;
/** The incident in the gbrain feedback that earned the row, one line. */
incident: string;
/** Optional `key=value` the line must carry (scored in the Completion Summary). */
metric?: string;
}
export const REALITY_ROWS: readonly RealityRow[] = [
{
id: 'premise-table', title: 'Premise table', tier: 1, scope: 'always',
check: '`premise | claim | file:line receipt | verdict` for every factual claim; no receipt means `unverified`, and `unverified=<n>` is scored in the Completion Summary.',
incident: 'A wave rested on "13 of 28 threads ran nothing" with no per-thread evidence; the first table found 1 FALSE and 3 UNVERIFIED claims in 27.',
metric: 'unverified',
},
{
id: 'already-done', title: 'Already done', tier: 1, scope: 'always',
check: '`git fetch origin <base>`, then `git log origin/<base> --since=<ask date> -- <named files>` and `--grep <symbols>`; report a hit before any task is written.',
incident: 'A fix was re-planned and rebuilt the day after main merged it; nobody read the base log.',
},
{
id: 'surface-check', title: 'Surface check', tier: 1, scope: 'always',
check: 'Every named CLI command, flag or HTTP route verified against the registry or `--help`; a miss is a finding.',
incident: 'The installer relied on `bun upgrade --version`; `bun upgrade --help` on the target listed only `--canary`.',
},
{
id: 'binding-decisions', title: 'Binding decisions', tier: 1, scope: 'always',
check: 'Read `gstack-decision-search --scope branch --recent 50` and the plan\'s `## Binding decisions`; they outrank project defaults and CLAUDE.md, and a reintroduced overruled default is a finding.',
incident: 'An owner decision taken mid-review was undone two phases later by the project default it had replaced.',
},
{
id: 'numbers', title: 'Numbers', tier: 1, scope: 'always',
check: 'Every number re-derived from raw data and tagged `[measured: <source>]` or `[estimated]`; a cited baseline is `[re-measure before gate]`.',
incident: 'An effort table with no basis was credited until a reviewer re-counted it; quoted parity ratios were off by two decimals.',
},
{
id: 'deferred-asks', title: 'Deferred asks', tier: 1, scope: 'always',
check: 'The linked issue\'s requests the plan leaves out, each with its reason, listed in the gate template.',
incident: 'An issue asked for six things; the plan shipped four and the owner found the other two after merge.',
},
{
id: 'pr-count', title: 'PR count', tier: 1, scope: 'always',
check: '`pr_count` from `.gstack/ship-policy.json` (default one PR per wave); a split shows the queue cost per extra PR and checks user-facing references across it.',
incident: 'A five-PR wave became seven in the Eng phase; the cost of each extra gate had never been stated.',
},
{
id: 'prior-art', title: 'Prior art', tier: 2, scope: 'dependency',
check: 'For a vendored or patched dependency or a known library behavior: search the upstream tracker and open PRs; record `adopt <link>` or `diverge because <reason>`.',
incident: 'A patched vendored hunk was dropped on upgrade because nobody had recorded why the fork diverged.',
},
{
id: 'transaction-semantics', title: 'Transaction semantics', tier: 2, scope: 'db',
check: 'For a driver, pool or protocol change: the surrounding transaction on idle, pooled and reserved connections; crash robot rerun after the change.',
incident: 'A pool change ended transactions on idle connections; the crash robot was not rerun.',
},
{
id: 'contract-assumptions', title: 'Contract assumptions', tier: 2, scope: 'dependency',
check: 'One row per default or flag assumed from another repo, rechecked against merged code before the next stage.',
incident: 'A sibling repo\'s default changed between planning and build; the plan still assumed the old flag.',
},
{
id: 'falsification-box', title: 'Falsification box', tier: 2, scope: 'incident',
check: 'Top hypothesis, the evidence that kills it, a time box, and the action in each branch.',
incident: 'An outage plan chased its first hypothesis for a day with nothing stated that would have killed it.',
},
{
id: 'stop-reasons', title: 'Stop reasons', tier: 2, scope: 'incident',
check: 'Every new stop reason, hold code and progress line names the test that triggers it.',
incident: 'A hold code shipped with no test that could produce it; it first fired in production.',
},
{
id: 'reporter-environment', title: 'Reporter environment', tier: 2, scope: 'incident',
check: 'A repro that does not match the reporter\'s environment is `class not confirmed`, never `ruled out`.',
incident: 'A bug was "ruled out" on a different runtime than the reporter\'s; the reporter still had it.',
},
{
id: 'units', title: 'Units', tier: 2, scope: 'perf',
check: 'A change to an input\'s units or accuracy lists every threshold that consumes it and shows the re-tune.',
incident: 'A timing input moved from ms to s; three downstream thresholds kept their numbers.',
},
{
id: 'perf-table', title: 'Perf table', tier: 2, scope: 'perf',
check: 'Machine, engine, data size, N, p50/p95, statistics state, base and branch interleaved, pathological and steady-state cases, `reproduced on main?`.',
incident: 'A speedup came from one warm-cache run with no base comparison; main was as fast.',
},
{
id: 'recall', title: 'Recall', tier: 2, scope: 'search',
check: 'A search or query-plan change reports recall against exact search across scope buckets.',
incident: 'A query-plan change sped up the hot path and dropped recall on the smallest bucket.',
},
{
id: 'dependency-fallback', title: 'Dependency fallback', tier: 2, scope: 'dependency',
check: 'What ships without each predecessor, and by when.',
incident: 'A stacked PR waited on a predecessor that never merged; nothing said what it could ship alone.',
},
{
id: 'obligation-tiers', title: 'Obligation tiers', tier: 2, scope: 'dependency',
check: 'Must-ship, next PR, deferred, under a stated size budget; the ledger status column carries the tier.',
incident: 'A PR outgrew review because every accepted finding was treated as must-ship.',
},
];
export const TIER1_ROW_IDS: readonly string[] = REALITY_ROWS.filter(r => r.tier === 1).map(r => r.id);
export function rowById(id: string): RealityRow | undefined {
return REALITY_ROWS.find(r => r.id === id);
}
/** Rows a review must carry for the given scopes: tier 1 always, tier 2 when its scope is detected. */
export function requiredRows(scopes: readonly string[]): RealityRow[] {
return REALITY_ROWS.filter(r => r.tier === 1 || scopes.includes(r.scope));
}
export function parseScopes(csv: string | undefined): { scopes: RealityScope[]; unknown: string[] } {
const scopes: RealityScope[] = [];
const unknown: string[] = [];
for (const raw of (csv ?? '').split(',').map(s => s.trim()).filter(Boolean)) {
if ((REALITY_SCOPES as readonly string[]).includes(raw)) scopes.push(raw as RealityScope);
else unknown.push(raw);
}
return { scopes, unknown };
}
/** `path:line` or `path:line-line` (the path has a letter, may start with a dot, no spaces), or a 7–40 hex sha with a hex letter, or digits-only after `commit ` or in backticks. */
export const RECEIPT_RE = /(?:(?<![\w./-])\.?[A-Za-z~$][\w./~$@-]*:\d+(?:-\d+)?)|(?<![0-9a-f])(?=[0-9]*[a-f])[0-9a-f]{7,40}(?![0-9a-f])|\bcommit [0-9]{7,40}\b|`[0-9]{7,40}`/;
export const REALITY_LINE_RE = /^\s*(?:[-*>|]\s*)?`?REALITY:\s+(?<id>[a-z][a-z0-9-]*)\s+(?<status>pass|finding|n\/a)\b(?<rest>.*)$/;
export interface RealityLine { id: string; status: RealityStatus; rest: string; receipt: string | null; line: number; metrics: Record<string, string> }
export function parseRealityLines(text: string): RealityLine[] {
const out: RealityLine[] = [];
text.split('\n').forEach((raw, i) => {
const m = REALITY_LINE_RE.exec(raw);
if (!m?.groups) return;
const rest = m.groups.rest!.replace(/`\s*$/, '').trim();
const receipt = RECEIPT_RE.exec(rest)?.[0] ?? null;
const metrics: Record<string, string> = {};
for (const kv of rest.matchAll(/\b([a-z_]+)=([^\s,;|]+)/g)) metrics[kv[1]!] = kv[2]!;
out.push({ id: m.groups.id!, status: m.groups.status as RealityStatus, rest, receipt, line: i + 1, metrics });
});
return out;
}
export type RealityVerdict = 'complete' | 'incomplete' | 'repair';
export interface RealityCheck {
phase: string;
required: string[];
present: string[];
/** Required rows with no line, or a tier 1 row marked n/a. */
missing: string[];
/** Required rows whose line carries no receipt. */
unreceipted: string[];
/** Clauses stating a measured number without a receipt of their own. */
unreceipted_numbers: string[];
/** Lines claiming a decision rule exists without quoting or linking its text. */
unquoted: string[];
/** One `NUMBER_UNRECEIPTED: <clause>` line per unreceipted number. */
numberLines: string[];
/** Lines naming a row id the table does not define. */
unknownRows: string[];
metrics: Record<string, string>;
verdict: RealityVerdict;
line: string;
}
/**
* A measured quantity: a number with a unit (s, ms, %, $, USD, tokens, ×, MB, GB) or a number shortly after
* measured/costs/takes/took. Receipts, versions and ids are removed before matching, so they never count.
*/
const MEASURED_RE = /\$\s?\d|\b\d[\d,]*(?:\.\d+)?\s?[kKM]?\s?(?:ms|s|%|USD|tokens?|MB|GB)(?![\w])|\b\d[\d,]*(?:\.\d+)?\s?[×x](?![\w])|\b(?:measured|costs?|takes|take|took)\b\W+(?:[A-Za-z]+\W+){0,3}?\$?\d/i;
const ITEM_REF_RE = /\b(?:items?|sections?)\s+\d+(?:\s*[–-]\s*\d+)?(?:\s*(?:,|and)\s*\d+(?:\s*[–-]\s*\d+)?)*|§\s?\d+(?:\.\d+)*/;
const NOT_A_QUANTITY_RE = new RegExp(`${RECEIPT_RE.source}|${ITEM_REF_RE.source}|\\bv?\\d+(?:\\.\\d+){2,}\\b|#\\d+|\\b[A-Za-z]+\\d+[a-z]?\\b`, 'g');
const EXEMPT_TAG_RE = /\[(?:estimated|re-measure before gate)\]/i;
const SCRIPT_RE = /(?:^|[\s(`"'])(?:\.?[\w-]+\/)*[\w.-]+\.(?:ts|js|mjs|cjs|sh|py|rb|go)\b|(?:^|[\s(`"'])(?:bin|scripts)\/[\w.-]+/;
const COMMAND_RE = /`(?!\s*\$?\d)[^`]*\s[^`]*`/;
const RULE_CLAIM_RE = /\b(?:decision rule|rules? (?:was |were )?written first|pre-?registered rule)\b/i;
const QUOTED_RULE_RE = /"[^"]{8,}"|\u201c[^\u201d]{8,}\u201d/;
const LINK_RE = /(?<![\w./-])\.?[A-Za-z~$][\w./~$@-]*:\d+/;
/** Split on `;`, `, ` and sentence ends, never inside brackets, parentheses or backticks. */
export function splitClauses(text: string): string[] {
const out: string[] = [];
let depth = 0, tick = false, start = 0;
for (let i = 0; i < text.length; i++) {
const c = text[i]!;
if (c === '`') tick = !tick;
else if (!tick && '([{'.includes(c)) depth++;
else if (!tick && ')]}'.includes(c)) depth = Math.max(0, depth - 1);
else if (!tick && depth === 0 && (c === ';' || ((c === ',' || c === '.') && /\s/.test(text[i + 1] ?? '')))) {
out.push(text.slice(start, c === '.' ? i + 1 : i)); start = i + 1;
}
}
out.push(text.slice(start));
return out.map(c => c.trim()).filter(Boolean);
}
const QUOTED_SPAN_RE = /"([^"]+)"|\u201c([^\u201d]+)\u201d/g;
const BACKTICK_SPAN_RE = /`[^`]*`/g;
/**
* Clauses of one line that state a measured number and carry no receipt of their own. With `quotedFrom` (the
* reviewed plan, reviewer scope only), a span quoted verbatim from it is the plan's number, not the reviewer's claim.
*/
export function unreceiptedNumbers(line: string, quotedFrom?: string): string[] {
const text = quotedFrom ? line.replace(QUOTED_SPAN_RE, (span, a, b) => (quotedFrom.includes((a ?? b) as string) ? '""' : span)) : line;
return splitClauses(text).filter(c => MEASURED_RE.test(c.replace(NOT_A_QUANTITY_RE, ' '))
&& !RECEIPT_RE.test(c) && !SCRIPT_RE.test(c) && !COMMAND_RE.test(c) && !EXEMPT_TAG_RE.test(c));
}
/** A rule claim needs the rule: a quoted span, a file:line link on the line, or a fenced or quoted block right after it. Plan scope ignores phrases inside backticks (a phrase named, not claimed). */
function ruleUnquoted(lines: string[], i: number, scope: 'plan' | 'review' = 'review'): boolean {
const line = lines[i]!;
if (!RULE_CLAIM_RE.test(scope === 'plan' ? line.replace(BACKTICK_SPAN_RE, ' ') : line) || QUOTED_RULE_RE.test(line) || LINK_RE.test(line)) return false;
const after = lines.slice(i + 1).find(l => l.trim());
return !after || !/^\s*(?:```|~~~|>)/.test(after);
}
const verdictFor = (ok: boolean, sessionKind?: string): RealityVerdict => (ok ? 'complete' : sessionKind === 'unattended' ? 'incomplete' : 'repair');
export interface CheckOptions { phase: string; scopes?: readonly string[]; sessionKind?: string;
/** The reviewed plan snapshot: a number inside a span quoted verbatim from it needs no receipt of its own. */
planText?: string }
/** Deterministic: which required rows the review carries. The verdict word depends on the session kind only. */
export function checkRealityRows(reviewText: string, o: CheckOptions): RealityCheck {
const required = requiredRows(o.scopes ?? []).map(r => r.id);
const lines = parseRealityLines(reviewText);
const byId = new Map<string, RealityLine>();
const unknownRows: string[] = [];
for (const l of lines) {
if (!rowById(l.id)) { if (!unknownRows.includes(l.id)) unknownRows.push(l.id); continue; }
if (!byId.has(l.id) || (byId.get(l.id)!.status === 'n/a' && l.status !== 'n/a')) byId.set(l.id, l);
}
const present: string[] = [], missing: string[] = [], unreceipted: string[] = [];
const metrics: Record<string, string> = {};
for (const id of required) {
const l = byId.get(id);
if (!l || l.status === 'n/a') { missing.push(id); continue; }
if (!l.receipt) { unreceipted.push(id); continue; }
present.push(id);
const metric = rowById(id)!.metric;
if (metric && l.metrics[metric] !== undefined) metrics[metric] = l.metrics[metric]!;
}
const rows = lines.filter(l => rowById(l.id) && l.status !== 'n/a');
const numbers = rows.flatMap(l => unreceiptedNumbers(l.rest, o.planText));
const rawLines = reviewText.split('\n');
const unquoted = rows.filter(l => ruleUnquoted(rawLines, l.line - 1)).map(l => rawLines[l.line - 1]!.trim());
const verdict = verdictFor(missing.length === 0 && unreceipted.length === 0 && numbers.length === 0 && unquoted.length === 0, o.sessionKind);
const parts = [`PLAN_REALITY: phase=${o.phase}`, `rows=${present.length}/${required.length}`,
`missing=${missing.length ? missing.join(',') : 'none'}`];
if (unreceipted.length) parts.push(`unreceipted=${unreceipted.join(',')}`);
if (numbers.length) parts.push(`unreceipted_numbers=${numbers.length}`);
if (unquoted.length) parts.push(`unquoted=${unquoted.length}`);
for (const [k, v] of Object.entries(metrics)) parts.push(`${k}=${v}`);
parts.push(`verdict=${verdict}`);
return { phase: o.phase, required, present, missing, unreceipted, unreceipted_numbers: numbers, unquoted,
numberLines: numbers.map(c => `NUMBER_UNRECEIPTED: ${c}`), unknownRows, metrics, verdict, line: parts.join(' ') };
}
/** Bumped whenever checkPlanReality's rules change, so a cached verdict keyed on the plan hash is recomputed. */
export const PLAN_REALITY_CHECKER = 'plan-reality/3';
export interface PlanRealityCheck { checker: string; unreceipted_numbers: string[]; unquoted: string[]; numberLines: string[]; verdict: RealityVerdict; line: string }
/**
* The same number and rule checks over plan prose (the Implementation plan the caller passes), outside fenced blocks.
* Pure: reads nothing, writes nothing. `gstack-autoplan submit` records it per plan hash; it never refuses a submit.
*/
export function checkPlanReality(planText: string, o: { sessionKind?: string } = {}): PlanRealityCheck {
const lines = planText.split('\n');
const numbers: string[] = [], unquoted: string[] = [];
let fence: string | null = null;
lines.forEach((line, i) => {
const delimiter = /^\s{0,3}(`{3,}|~{3,})/.exec(line)?.[1];
if (delimiter && (!fence || delimiter.startsWith(fence))) { fence = fence ? null : delimiter; return; }
if (fence || /^\s*<!--/.test(line)) return;
numbers.push(...unreceiptedNumbers(line));
if (ruleUnquoted(lines, i, 'plan')) unquoted.push(line.trim());
});
const verdict = verdictFor(numbers.length === 0 && unquoted.length === 0, o.sessionKind);
return { checker: PLAN_REALITY_CHECKER, unreceipted_numbers: numbers, unquoted, numberLines: numbers.map(c => `NUMBER_UNRECEIPTED: ${c}`), verdict,
line: `PLAN_REALITY: scope=plan unreceipted_numbers=${numbers.length} unquoted=${unquoted.length} verdict=${verdict}` };
}
const VAGUE_RE = /\b(minimum (?:detectable )?effect|margins?|decision rule|dollar cost|cost in dollars|USD cost)\b/i;
const DETAIL_AFTER_RE = /^\s*(?:[-*+]\s|\d+[.)]\s|\||```|~~~|>)/;
/**
* PLAN_REALITY flags for the gate (advisory, never blocking): a plan line naming a minimum effect, a margin, a
* decision rule or a dollar cost with no number on it (item references and phrases in backticks do not count)
* and no arm list, table, quoted rule or block right after it. `line` is 1-based in `planText`.
*/
export function vaguePlanTerms(planText: string): Array<{ phrase: string; line: number; text: string }> {
const lines = planText.split('\n');
const out: Array<{ phrase: string; line: number; text: string }> = [];
let fence: string | null = null;
lines.forEach((raw, i) => {
const delimiter = /^\s{0,3}(`{3,}|~{3,})/.exec(raw)?.[1];
if (delimiter && (!fence || delimiter.startsWith(fence))) { fence = fence ? null : delimiter; return; }
if (fence || /^\s*<!--/.test(raw)) return;
const prose = raw.replace(BACKTICK_SPAN_RE, ' ');
const m = VAGUE_RE.exec(prose);
if (!m) return;
if (/\d/.test(prose.replace(NOT_A_QUANTITY_RE, ' ')) || QUOTED_RULE_RE.test(prose)) return;
const after = lines.slice(i + 1).find(l => l.trim());
if (after && DETAIL_AFTER_RE.test(after)) return;
out.push({ phrase: m[1]!.toLowerCase(), line: i + 1, text: raw.trim() });
});
return out;
}
/** The example line every template quotes; one grammar, stated once. */
export const REALITY_LINE_SHAPE = 'REALITY: <row> pass|finding|n/a <summary> <file:line>';
/** The operative rows as the bin prints them (`deep` adds each row's incident). */
export function renderRows(rows: readonly RealityRow[], o: { deep: boolean }): string {
return rows.map(r => `- ${r.id} (tier ${r.tier}${r.scope === 'always' ? '' : `, scope ${r.scope}`}): ${r.check}${o.deep ? `\n Incident: ${r.incident}` : ''}`).join('\n');
}
/** The deep-section table: why each row exists. The check itself is what `rows` prints. */
export function renderIncidentTable(rows: readonly RealityRow[]): string {
const head = '| Row | Tier | Scope | Incident that earned it |\n|---|---|---|---|';
return [head, ...rows.map(r => `| \`${r.id}\` | ${r.tier} | ${r.scope} | ${r.incident.replace(/\|/g, '\\|')} |`)].join('\n');
}