mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
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>
This commit is contained in:
1 parent
47d639abff
commit
78ee23240c
13 files changed
+318
-32
No files matched your search
@@ -176,7 +176,7 @@ Sections 1-11 — for EACH section, run the evaluation criteria from the loaded
|
||||
|
||||
**Mandatory outputs from Phase 1:**
|
||||
- Reality rows: the loaded skill's `REALITY:` lines, one per applicable row;
|
||||
`~/.claude/skills/gstack/bin/gstack-plan-reality check --phase ceo <review-file|->` prints `PLAN_REALITY: … verdict=complete` before the phase closes
|
||||
`~/.claude/skills/gstack/bin/gstack-plan-reality check --phase ceo --plan <reviewed snapshot> <review-file|->` prints `PLAN_REALITY: … verdict=complete` before the phase closes
|
||||
(unattended: `incomplete` keeps it open).
|
||||
- "NOT in scope" section with deferred items and rationale
|
||||
- "What already exists" section mapping sub-problems to existing code
|
||||
|
||||
@@ -105,7 +105,7 @@ Retain the historical review-log skill ID; add `"host":"claude","outside_provide
|
||||
1. Step 0 (Scope Challenge): Read actual code referenced by the plan. Map each
|
||||
sub-problem to existing code. Run the complexity check. Produce concrete findings.
|
||||
Reality rows: the loaded skill's `REALITY:` lines, one per applicable row;
|
||||
`~/.claude/skills/gstack/bin/gstack-plan-reality check --phase eng <review-file|->` prints `PLAN_REALITY: … verdict=complete` before the phase closes
|
||||
`~/.claude/skills/gstack/bin/gstack-plan-reality check --phase eng --plan <reviewed snapshot> <review-file|->` prints `PLAN_REALITY: … verdict=complete` before the phase closes
|
||||
(unattended: `incomplete` keeps it open).
|
||||
|
||||
2. Step 0.5 (Dual Voices): Present the completed calls above under Codex SAYS
|
||||
|
||||
+6
-4
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* gstack-gate — render and parse the one gate list (plan B9).
|
||||
*
|
||||
* gstack-gate render <gate.json> [--page N]
|
||||
* gstack-gate render <gate.json> [--page N | --all]
|
||||
* gstack-gate parse <gate.json> --reply "<text>" --gate-rev N [--json]
|
||||
* gstack-gate decisions <gate.json> [--reply "<text>" --gate-rev N] [--answered-at ISO]
|
||||
*
|
||||
@@ -15,7 +15,8 @@ import { resultLine } from '../lib/result-codes';
|
||||
|
||||
const HELP = `gstack-gate — one gate list, one reply grammar
|
||||
|
||||
render <gate.json> [--page N] numbered items with stable ids, auto/approval, the reply shape
|
||||
render <gate.json> [--page N | --all] numbered items with stable ids, auto/approval, the reply shape;
|
||||
--all prints every item on one page (relay every page)
|
||||
parse <gate.json> --reply "<text>" --gate-rev N [--json]
|
||||
resolved choices as JSON; unparsed tokens are listed, never guessed
|
||||
decisions <gate.json> [--reply "<text>" --gate-rev N] [--answered-at ISO]
|
||||
@@ -67,14 +68,15 @@ function parseWith(list: GateList, rest: string[], json: boolean) {
|
||||
function main(argv: string[]): number {
|
||||
const [cmd, ...rest] = argv;
|
||||
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
|
||||
const positional = rest.filter((a, i) => !a.startsWith('--') && !(i > 0 && rest[i - 1]!.startsWith('--') && rest[i - 1] !== '--json'));
|
||||
const positional = rest.filter((a, i) => !a.startsWith('--') && !(i > 0 && rest[i - 1]!.startsWith('--') && !['--json', '--all'].includes(rest[i - 1]!)));
|
||||
const list = loadList(positional[0]);
|
||||
if (typeof list === 'number') return list;
|
||||
|
||||
if (cmd === 'render') {
|
||||
const page = flag(rest, '--page');
|
||||
if (page !== undefined && !/^[0-9]+$/.test(page)) return usage('--page needs a number');
|
||||
process.stdout.write(renderGate(list, page ? Number(page) : 1));
|
||||
if (page !== undefined && rest.includes('--all')) return usage('--page and --all are exclusive');
|
||||
process.stdout.write(rest.includes('--all') ? renderGate(list, 1, list.items.length) : renderGate(list, page ? Number(page) : 1));
|
||||
return EXIT.ok;
|
||||
}
|
||||
if (cmd === 'parse') {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* gstack-plan-reality — the reality rows of a plan review (plan D1).
|
||||
*
|
||||
* gstack-plan-reality rows --phase <ceo|eng|dx|design> [--scope <csv>] [--deep] [--json]
|
||||
* gstack-plan-reality check --phase <p> <review-file|-> [--scope <csv>] [--session-kind <kind>] [--json]
|
||||
* gstack-plan-reality check --phase <p> <review-file|-> [--scope <csv>] [--session-kind <kind>] [--plan <reviewed plan>] [--json]
|
||||
*
|
||||
* Logic: lib/plan-reality.ts. Codes: lib/result-codes.ts (REALITY_ROW_MISSING, REALITY_RECEIPT_MISSING).
|
||||
*/
|
||||
@@ -15,8 +15,9 @@ import { resultLine } from '../lib/result-codes';
|
||||
const HELP = `gstack-plan-reality — reality rows for plan reviews
|
||||
|
||||
rows --phase <p> [--scope <csv>] [--deep] [--json] the rows this review must carry (tier 1 always, tier 2 by scope)
|
||||
check --phase <p> <file|-> [--scope <csv>] [--session-kind <kind>] [--json]
|
||||
check --phase <p> <file|-> [--scope <csv>] [--session-kind <kind>] [--plan <reviewed plan>] [--json]
|
||||
parse the review's REALITY: lines; exit 1 with the missing rows
|
||||
--plan: a number inside a "quoted" span copied verbatim from it needs no receipt
|
||||
|
||||
Row line: \`${REALITY_LINE_SHAPE}\` (receipt: path:line, path:line-line or a commit sha).
|
||||
Scopes: ${REALITY_SCOPES.filter(s => s !== 'always').join(', ')}. Session kind unattended → verdict=incomplete; else verdict=repair.
|
||||
@@ -49,13 +50,16 @@ function main(argv: string[]): number {
|
||||
return EXIT.ok;
|
||||
}
|
||||
if (cmd === 'check') {
|
||||
const valueFlags = new Set(['--phase', '--scope', '--session-kind']);
|
||||
const valueFlags = new Set(['--phase', '--scope', '--session-kind', '--plan']);
|
||||
const file = rest.find((a, i) => !a.startsWith('--') && !(i > 0 && valueFlags.has(rest[i - 1]!)));
|
||||
if (!file) return usage('a review file (or - for stdin) is required');
|
||||
let text: string;
|
||||
try { text = file === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(file, 'utf8'); } catch (e: any) { process.stderr.write(`gstack-plan-reality: ${file}: ${e.message}\n`); return EXIT.fail; }
|
||||
const sessionKind = flag(rest, '--session-kind') ?? process.env.GSTACK_SESSION_KIND;
|
||||
const result = checkRealityRows(text, { phase, scopes, sessionKind });
|
||||
const planFile = flag(rest, '--plan');
|
||||
let planText: string | undefined;
|
||||
try { planText = planFile ? fs.readFileSync(planFile, 'utf8') : undefined; } catch (e: any) { process.stderr.write(`gstack-plan-reality: ${planFile}: ${e.message}\n`); return EXIT.fail; }
|
||||
const result = checkRealityRows(text, { phase, scopes, sessionKind, planText });
|
||||
if (json) process.stdout.write(JSON.stringify(result) + '\n');
|
||||
else process.stdout.write([result.line, ...result.numberLines].join('\n') + '\n');
|
||||
for (const id of result.unknownRows) process.stderr.write(`gstack-plan-reality: ${file}: REALITY row ${JSON.stringify(id)} is not a known row; it was ignored\n`);
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ANSWERED_BY_RE, applyReply, decisionId, decisionRows, keepDirection, pa
|
||||
import type { FindingRow } from './headless-artifacts';
|
||||
import { readJsonl } from './headless-artifacts';
|
||||
import { closeDraftDirectionsFile, loadBriefInputs } from './owner-brief';
|
||||
import { vaguePlanTerms } from './plan-reality';
|
||||
import { settle } from './spend-ledger';
|
||||
|
||||
export function loadOverrides(file: string | undefined): ReconciliationOverride[] {
|
||||
@@ -140,7 +141,9 @@ export function buildGate(state: RunState, gateRev: number): GateList {
|
||||
}
|
||||
}
|
||||
const changes = items.filter(i => i.options.find(o => o.key === i.recommended)?.changes_plan).map(i => `${i.id}${i.recommended}`);
|
||||
items.push({ id: 'p1', title: 'Plan approval', kind: 'approval', recommended: changes.length ? 'b' : 'a',
|
||||
const planFile = fs.existsSync(state.plan_source) ? state.plan_source : state.active_plan;
|
||||
const flags = vaguePlanTerms(fs.readFileSync(planFile, 'utf8')).map(v => `PLAN_REALITY ${JSON.stringify(v.phrase)} has no number, arm list or rule body (${path.basename(state.plan_input ?? planFile)}:${v.line}: ${v.text.length > 120 ? `${v.text.slice(0, 117)}...` : v.text}) (report only)`);
|
||||
items.push({ id: 'p1', title: 'Plan approval', kind: 'approval', recommended: changes.length ? 'b' : 'a', ...(flags.length ? { flags } : {}),
|
||||
options: [{ key: 'a', text: 'approve as-is' }, { key: 'b', text: changes.length ? `approve with the recommended changes: ${changes.join(', ')}` : 'approve with the overrides above' },
|
||||
{ key: 'c', text: 'interrogate (ask questions; stays pending)' },
|
||||
{ key: 'd', text: `rerun every phase on the current plan, Eng last (to review an edited plan instead: gstack-autoplan revise --out ${state.out} --plan <file>)` }, { key: 'e', text: 'reject' }] });
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { extractImplementationPlan, implementationForReview, sectionHeadings } from '../bin/gstack-autoplan-snapshot';
|
||||
import { extractHeading } from './autoplan-prompts';
|
||||
import type { RunState } from './autoplan-state';
|
||||
@@ -79,3 +80,39 @@ export function driftLine(state: RunState, code: 'PLAN_CHANGED' | 'PLAN_SOURCE_C
|
||||
if (!current || current === reviewed) return undefined;
|
||||
return `${code}: ${source} differs from the reviewed plan; run gstack-autoplan revise --out ${state.out} --plan ${source}`;
|
||||
}
|
||||
|
||||
/** The nearest directory at or above `dir` holding `.git` (no git spawn), or undefined. */
|
||||
export function repoRoot(dir: string): string | undefined {
|
||||
for (let d = path.resolve(dir); ; d = path.dirname(d)) {
|
||||
if (fs.existsSync(path.join(d, '.git'))) return d;
|
||||
if (path.dirname(d) === d) return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Line offset of `inner` inside `outer` (0 when it starts on line 1), or undefined when it is not a verbatim block of it. */
|
||||
function blockOffset(inner: string, outer: string): number | undefined {
|
||||
const at = outer.indexOf(inner);
|
||||
if (at >= 0) return outer.slice(0, at).split('\n').length - 1;
|
||||
const restoreLine = /^<!-- \/autoplan restore point:[^\n]*\n/.exec(inner);
|
||||
if (!restoreLine) return undefined;
|
||||
const rest = blockOffset(inner.slice(restoreLine[0].length), outer);
|
||||
return rest === undefined ? undefined : rest - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* A canonical row's `file`/`line` as the owner reads them: an absolute path under the repository root becomes
|
||||
* repo-relative; a phase snapshot or the active plan becomes the plan's own file name, its line mapped to the plan
|
||||
* source through the snapshot's verbatim position (dropped when it cannot be mapped). The reviewer's bytes are untouched.
|
||||
*/
|
||||
export function normalizeFindingFile(f: { file?: string; line?: number }, ctx: { cwd: string; planFiles: string[]; planSource: string; planName: string }): { file?: string; line?: number } {
|
||||
if (!f.file) return f;
|
||||
const planFile = ctx.planFiles.find(p => f.file === p || (!path.isAbsolute(f.file!) && path.basename(p) === f.file));
|
||||
if (planFile) {
|
||||
let offset: number | undefined;
|
||||
try { offset = blockOffset(fs.readFileSync(planFile, 'utf8'), fs.readFileSync(ctx.planSource, 'utf8')); } catch { offset = undefined; }
|
||||
return { file: ctx.planName, ...(f.line !== undefined && offset !== undefined ? { line: f.line + offset } : {}) };
|
||||
}
|
||||
const root = path.isAbsolute(f.file) ? repoRoot(ctx.cwd) : undefined;
|
||||
if (root && f.file.startsWith(root + path.sep)) return { ...f, file: path.relative(root, f.file).split(path.sep).join('/') };
|
||||
return f;
|
||||
}
|
||||
@@ -67,9 +67,9 @@ export function parseReceipt(text: string, expected: { phase: string; sha256: st
|
||||
function parseFences(text: string, errors: string[]): { findings: CanonicalFinding[]; fenceFound: boolean } {
|
||||
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)) {
|
||||
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) => {
|
||||
(fence[2] ?? '').split('\n').forEach((raw, i) => {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
let row: any;
|
||||
|
||||
+11
-4
@@ -21,7 +21,7 @@ import * as path from 'node:path';
|
||||
import { canonicalRows, parseReceipt, parseRecordFindings, parseReviewResult, type CanonicalFinding } from './autoplan-reconcile';
|
||||
import { closePhase, finishGate } from './autoplan-gate';
|
||||
import { GSTACK_VERSION } from './autoplan-migrate';
|
||||
import { driftLine, reviewHash, scaffold } from './autoplan-plan-text';
|
||||
import { driftLine, normalizeFindingFile, reviewHash, scaffold } from './autoplan-plan-text';
|
||||
import { PHASES, SNAPSHOT_TOOL, detectScope, driverFile, extractHeading, phaseOrder, reviewSkillFile, scopeLines, scopeSourceLabel, sectionLoadFor, snapshotTool, writePrompts, type Phase, type Scope, type ScopeFlags } from './autoplan-prompts';
|
||||
import { recoverRevise } from './autoplan-revise';
|
||||
import { FILES, RunError, journal, now, pendingAmendments, readDecisions, readJournal, readState, voiceTerminal, writeState, type AttemptState, type PhaseState, type RunState, type Voice, type VoiceState } from './autoplan-state';
|
||||
@@ -314,14 +314,17 @@ export function submitResult(o: SubmitOptions): SubmitResult {
|
||||
const tmp = `${bound}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, text);
|
||||
fs.renameSync(tmp, bound);
|
||||
const rows = canonicalRows(bind.findings, { run: state.run, phase, voice: o.voice, model });
|
||||
const planFiles = [ps.snapshot_path, ps.snapshot_path && path.join(path.dirname(ps.snapshot_path), 'source-implementation.md'), state.active_plan].filter((p): p is string => !!p);
|
||||
const pathCtx = { cwd: o.cwd, planFiles, planSource: state.plan_source, planName: path.basename(state.plan_input ?? state.plan_source) };
|
||||
const rows = canonicalRows(bind.findings.map(f => { const { file, line, ...rest } = f; return { ...rest, ...normalizeFindingFile({ file, line }, pathCtx) }; }), { run: state.run, phase, voice: o.voice, model });
|
||||
fs.writeFileSync(`${bound}.findings.jsonl`, rows.map(r => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : ''));
|
||||
const sha = sha256File(bound);
|
||||
const provider = o.runner ?? 'host-subagent';
|
||||
const receipt = { receipt: bind.receipt, findings_source: bind.source, reviewed_sha256: ps.snapshot, ...(bind.receipt === 'attested' ? { attest_reason: o.attest } : {}) };
|
||||
journal(state.out, { event: 'submitted', attempt: open.attempt, phase, voice: o.voice, result: bound, result_sha256: sha, model, runner: o.runner ?? null, findings: rows.length, usage_usd: o.usageUsd ?? 'unknown', ...receipt });
|
||||
journal(state.out, { event: 'submitted', attempt: open.attempt, phase, voice: o.voice, result: bound, result_sha256: sha, model, runner: provider, findings: rows.length, usage_usd: o.usageUsd ?? 'unknown', ...receipt });
|
||||
settle(path.join(state.out, FILES.spend), open.attempt, o.usageUsd ?? 'unknown');
|
||||
open.state = 'submitted'; open.terminal_at = now();
|
||||
Object.assign(v, { status: 'completed', model, provider: o.runner ?? (o.voice === 'native' ? 'host-subagent' : 'unknown'), output: path.basename(bound), result_sha256: sha, findings: rows.length, attempt: open.attempt, usage: { usd: o.usageUsd ?? 'unknown' }, ...receipt } satisfies Partial<VoiceState>);
|
||||
Object.assign(v, { status: 'completed', model, provider, output: path.basename(bound), result_sha256: sha, findings: rows.length, attempt: open.attempt, usage: { usd: o.usageUsd ?? 'unknown' }, ...receipt } satisfies Partial<VoiceState>);
|
||||
if (bind.receipt === 'attested') (state.deviations ??= []).push(`receipt attested: ${phase} ${o.voice} ${open.attempt} bound from a hand-run record without a reviewer receipt (snapshot ${ps.snapshot.slice(0, 12)}; reason: ${o.attest})`);
|
||||
state.artifacts[path.basename(bound)] = { path: path.basename(bound), sha256: sha };
|
||||
if (o.voice === 'native') state.native_model = model; else state.outside_model = model;
|
||||
@@ -437,6 +440,10 @@ export function statusLines(state: RunState): string[] {
|
||||
const v = (voice: Voice) => { const s = ps[voice]; return `${voice}=${s?.status ?? 'pending'}${s?.model ? `:${s.model}` : ''}${s?.attempt ? `@${s.attempt}` : ''}`; };
|
||||
lines.push(`PHASE: ${p} ${ps.closed_at ? 'closed' : ps.snapshot ? 'open' : 'pending'} ${v('native')} ${v('outside')}${ps.consensus ? ` confirmed=${(ps.consensus as any).confirmed} disagree=${(ps.consensus as any).disagree} new=${(ps.consensus as any).new}` : ''}`);
|
||||
}
|
||||
for (const p of state.phase_order) for (const voice of ['native', 'outside'] as const) {
|
||||
const open = state.phases[p]?.[voice] && openAttempt(state.phases[p]![voice]!);
|
||||
if (open) lines.push(`PENDING: ${open.attempt} phase=${p} voice=${voice} result=${open.result}`);
|
||||
}
|
||||
if (fs.existsSync(path.join(state.out, FILES.spend))) lines.push(renderSpend(summarize(readLedger(path.join(state.out, FILES.spend)))));
|
||||
return lines;
|
||||
}
|
||||
|
||||
+4
-1
@@ -28,6 +28,8 @@ export interface GateItem {
|
||||
recommended_basis?: string; changes?: string;
|
||||
/** Stable carry-forward identity (phase, finding ids, phase close, snapshot, option text). */
|
||||
origin?: string;
|
||||
/** Advisory lines rendered under the item (PLAN_REALITY: a plan term with no number, arm list or rule body). */
|
||||
flags?: string[];
|
||||
/** Answered at an earlier revision on an unchanged proposal from a phase that did not rerun. */
|
||||
carried?: { from: string; chosen: string; gate_rev: number };
|
||||
}
|
||||
@@ -103,9 +105,10 @@ export function renderGate(list: GateList, page = 1, pageSize = PAGE_SIZE): stri
|
||||
if (item.carried) lines.push(` decided at gate_rev ${item.carried.gate_rev}: ${item.id}${item.carried.chosen} (carried forward; name it again to change it)`);
|
||||
for (const o of item.options) lines.push(` ${item.id}${o.key}) ${o.text}${o.key === item.recommended ? ' (recommended)' : ''}`);
|
||||
if (item.cost) lines.push(` cost: ${item.cost}`);
|
||||
for (const flag of item.flags ?? []) lines.push(` flagged: ${flag}`);
|
||||
});
|
||||
lines.push('');
|
||||
if (pages > 1) lines.push(`Ids are stable across pages; render page N with \`gstack-gate render <file> --page N\`.`);
|
||||
if (pages > 1) lines.push(`Ids are stable across pages; render page N with \`gstack-gate render <file> --page N\`, or every page with \`--all\`. Relay every page.`);
|
||||
lines.push('Reply with exactly one of: `all` · `<id><option>` tokens, e.g. `' + exampleTokens(list) + '` · `all except <tokens>` · `all as-is` (keep every direction).');
|
||||
lines.push('Items marked auto take their recommendation if unanswered; approval items stay pending until answered. Bare yes/no is not accepted.');
|
||||
for (const held of list.items.filter(heldFromAll)) lines.push(`\`all\` does not answer ${held.id} (recommended ${held.recommended} changes the plan); reply ${held.options.map(o => `${held.id}${o.key}`).join(' or ')} yourself.`);
|
||||
|
||||
+49
-12
@@ -203,10 +203,11 @@ export interface RealityCheck {
|
||||
* 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 NOT_A_QUANTITY_RE = new RegExp(`${RECEIPT_RE.source}|\\bv?\\d+(?:\\.\\d+){2,}\\b|#\\d+|\\b[A-Za-z]+\\d+[a-z]?\\b`, 'g');
|
||||
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[^`]*`/;
|
||||
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+/;
|
||||
@@ -228,23 +229,32 @@ export function splitClauses(text: string): string[] {
|
||||
return out.map(c => c.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Clauses of one line that state a measured number and carry no receipt of their own. */
|
||||
export function unreceiptedNumbers(line: string): string[] {
|
||||
return splitClauses(line).filter(c => MEASURED_RE.test(c.replace(NOT_A_QUANTITY_RE, ' '))
|
||||
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. */
|
||||
function ruleUnquoted(lines: string[], i: number): boolean {
|
||||
/** 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(line) || QUOTED_RULE_RE.test(line) || LINK_RE.test(line)) return false;
|
||||
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 }
|
||||
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 {
|
||||
@@ -267,7 +277,7 @@ export function checkRealityRows(reviewText: string, o: CheckOptions): RealityCh
|
||||
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));
|
||||
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);
|
||||
@@ -283,7 +293,7 @@ export function checkRealityRows(reviewText: string, o: CheckOptions): RealityCh
|
||||
}
|
||||
|
||||
/** Bumped whenever checkPlanReality's rules change, so a cached verdict keyed on the plan hash is recomputed. */
|
||||
export const PLAN_REALITY_CHECKER = 'plan-reality/2';
|
||||
export const PLAN_REALITY_CHECKER = 'plan-reality/3';
|
||||
export interface PlanRealityCheck { checker: string; unreceipted_numbers: string[]; unquoted: string[]; numberLines: string[]; verdict: RealityVerdict; line: string }
|
||||
|
||||
/**
|
||||
@@ -299,13 +309,40 @@ export function checkPlanReality(planText: string, o: { sessionKind?: string } =
|
||||
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)) unquoted.push(line.trim());
|
||||
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>';
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ in an unattended run, never a warning; interactive runs follow the repair rule a
|
||||
}
|
||||
if (mode === 'autoplan') {
|
||||
return `Reality rows: the loaded skill's \`REALITY:\` lines, one per applicable row;
|
||||
\`${BIN} check --phase ${args?.[1] ?? 'ceo'} <review-file|->\` prints \`PLAN_REALITY: … verdict=complete\` before the phase closes
|
||||
\`${BIN} check --phase ${args?.[1] ?? 'ceo'} --plan <reviewed snapshot> <review-file|->\` prints \`PLAN_REALITY: … verdict=complete\` before the phase closes
|
||||
(unattended: \`incomplete\` keeps it open).`;
|
||||
}
|
||||
const phase = phaseFor(ctx, mode);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Next wave, Lane B artifacts (GBRA-40 items 7, 11, 14, 15, 18):
|
||||
* B3 an empty gstack-findings fence (LF, CRLF, whitespace-only) is zero findings, not a missing fence;
|
||||
* an opener without a closer and invalid rows are still refused.
|
||||
* B7 canonical rows carry repo-relative paths; snapshot citations become the plan file and its source line.
|
||||
* B8 `status` lists every open attempt with its result file.
|
||||
* B9 `gstack-gate render --all` prints every item; paged output tells the relay to send every page.
|
||||
* B6 a plan naming a minimum effect with no number gets a flagged PLAN_REALITY line under the plan approval item.
|
||||
* B11 an outside voice submitted without --runner is labeled host-subagent in the consensus header.
|
||||
*/
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { normalizeFindingFile } from '../lib/autoplan-plan-text';
|
||||
import { parseRecordFindings, parseReviewResult } from '../lib/autoplan-reconcile';
|
||||
import { renderGate, type GateList } from '../lib/gate-list';
|
||||
import { attemptsOf, autoplan, readRun, review, ROOT, runPhase, SMALL_PLAN, startSmallRun } from './helpers/autoplan-run-fixture';
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-gate-artifacts-'));
|
||||
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
const SHA = 'a'.repeat(64);
|
||||
const GATE = path.join(ROOT, 'bin', 'gstack-gate');
|
||||
|
||||
describe('B3: empty findings fence', () => {
|
||||
const head = `INPUT: ceo ${SHA}\n\n## review\n\n`;
|
||||
test('LF, CRLF and whitespace-only empty fences parse as zero findings in both grammars', () => {
|
||||
for (const text of [`${head}\`\`\`gstack-findings\n\`\`\`\n`, `${head}\`\`\`gstack-findings\n\`\`\`\n`.replace(/\n/g, '\r\n'), `${head}\`\`\`gstack-findings\n \n\`\`\`\n`]) {
|
||||
const r = parseReviewResult(text, { phase: 'ceo', sha256: SHA });
|
||||
expect(r.fenceFound).toBe(true);
|
||||
expect(r.errors).toEqual([]);
|
||||
expect(r.findings).toEqual([]);
|
||||
expect(parseRecordFindings(text)).toEqual({ findings: [], source: 'fence', errors: [] });
|
||||
}
|
||||
});
|
||||
test('an opener without a closer is no fence; an invalid row is refused; two fences still parse', () => {
|
||||
expect(parseReviewResult(`${head}\`\`\`gstack-findings\n{"severity":"High","title":"x"}\n`, { phase: 'ceo', sha256: SHA }).errors).toContain('no ```gstack-findings fence');
|
||||
expect(parseReviewResult(`${head}\`\`\`gstack-findings\n{"severity":"Bogus","title":"x"}\n\`\`\`\n`, { phase: 'ceo', sha256: SHA }).errors[0]).toMatch(/severity "Bogus"/);
|
||||
const two = parseReviewResult(`${head}\`\`\`gstack-findings\n{"severity":"High","title":"a"}\n\`\`\`\n\n\`\`\`gstack-findings\n\`\`\`\n\n\`\`\`gstack-findings\n{"severity":"Low","title":"b"}\n\`\`\`\n`, { phase: 'ceo', sha256: SHA });
|
||||
expect(two.errors).toEqual([]);
|
||||
expect(two.findings.map(f => f.title)).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('B7: repo-relative paths and plan lines', () => {
|
||||
test('absolute repo paths become relative; snapshot and active-plan citations map to the plan source line or drop the line', () => {
|
||||
const repo = path.join(TMP, 'repo');
|
||||
fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
|
||||
fs.mkdirSync(path.join(repo, 'src'), { recursive: true });
|
||||
const source = path.join(TMP, 'plan-source.md');
|
||||
fs.writeFileSync(source, '# Title\n\n## Implementation plan\n\nline A\nline B\nline C\n\n## Review record\n');
|
||||
const snapDir = path.join(TMP, 'autoplan-ceo-Xy12');
|
||||
fs.mkdirSync(snapDir, { recursive: true });
|
||||
const snapshot = path.join(snapDir, 'ceo-implementation.md');
|
||||
fs.writeFileSync(snapshot, '\nline A\nline B\nline C\n\n');
|
||||
const active = path.join(TMP, 'plan-active.md');
|
||||
fs.writeFileSync(active, `<!-- /autoplan restore point: "${TMP}/restore.md" -->\n${fs.readFileSync(source, 'utf8')}`);
|
||||
const stray = path.join(TMP, 'other-snapshot.md');
|
||||
fs.writeFileSync(stray, 'unrelated\n');
|
||||
const ctx = { cwd: path.join(repo, 'src'), planFiles: [snapshot, active, stray], planSource: source, planName: 'plan.md' };
|
||||
expect(normalizeFindingFile({ file: path.join(repo, 'src', 'worker.ts'), line: 9 }, ctx)).toEqual({ file: 'src/worker.ts', line: 9 });
|
||||
expect(normalizeFindingFile({ file: snapshot, line: 3 }, ctx)).toEqual({ file: 'plan.md', line: 6 });
|
||||
expect(normalizeFindingFile({ file: 'ceo-implementation.md', line: 2 }, ctx)).toEqual({ file: 'plan.md', line: 5 });
|
||||
expect(normalizeFindingFile({ file: active, line: 6 }, ctx)).toEqual({ file: 'plan.md', line: 5 });
|
||||
expect(normalizeFindingFile({ file: stray, line: 1 }, ctx)).toEqual({ file: 'plan.md' });
|
||||
expect(normalizeFindingFile({ file: '/elsewhere/x.ts', line: 1 }, ctx)).toEqual({ file: '/elsewhere/x.ts', line: 1 });
|
||||
expect(normalizeFindingFile({ file: 'lib/x.ts', line: 4 }, ctx)).toEqual({ file: 'lib/x.ts', line: 4 });
|
||||
});
|
||||
|
||||
test('end to end: a reviewer citing the snapshot and an absolute repo path binds repo-relative rows; the bound copy stays verbatim', () => {
|
||||
const out = startSmallRun(path.join(TMP, 'paths'));
|
||||
expect(autoplan(['next', '--out', out]).status).toBe(0);
|
||||
const snap = readRun(out).phases.ceo.snapshot_path as string;
|
||||
const findings = JSON.stringify([{ severity: 'High', title: 'cap unexplained', file: snap, line: 2 }, { severity: 'Low', title: 'abs path', file: path.join(ROOT, 'lib', 'gate-list.ts'), line: 3 }]);
|
||||
review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: findings });
|
||||
const ids = attemptsOf(out, 'ceo');
|
||||
const result = readRun(out).phases.ceo.native.attempts[0].result as string;
|
||||
const bind = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', result, '--model', 'anthropic/claude-opus-5-5', '--attempt', ids.native]);
|
||||
expect(bind.status, bind.stderr).toBe(0);
|
||||
const output = readRun(out).phases.ceo.native.output as string;
|
||||
const rows = fs.readFileSync(path.join(out, `${output}.findings.jsonl`), 'utf8').trim().split('\n').map(l => JSON.parse(l));
|
||||
expect(rows.map((r: any) => [r.file, r.line])).toEqual([['plan.md', 5], ['lib/gate-list.ts', 3]]);
|
||||
expect(fs.readFileSync(path.join(out, output), 'utf8')).toContain(snap);
|
||||
});
|
||||
});
|
||||
|
||||
describe('B8/B11: pending attempts in status; outside voice labeled host-subagent', () => {
|
||||
test('status prints PENDING per open attempt; an outside submit without --runner reads model (host-subagent)', () => {
|
||||
const out = startSmallRun(path.join(TMP, 'pending'));
|
||||
expect(autoplan(['next', '--out', out]).status).toBe(0);
|
||||
const ids = attemptsOf(out, 'ceo');
|
||||
const run = readRun(out);
|
||||
const status = autoplan(['status', '--out', out]);
|
||||
expect(status.stdout).toContain(`PENDING: ${ids.native} phase=ceo voice=native result=${run.phases.ceo.native.attempts[0].result}`);
|
||||
expect(status.stdout).toContain(`PENDING: ${ids.outside} phase=ceo voice=outside result=${run.phases.ceo.outside.attempts[0].result}`);
|
||||
for (const voice of ['native', 'outside']) review(path.join(out, `ceo-${voice}-prompt.md`));
|
||||
expect(autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', run.phases.ceo.native.attempts[0].result, '--model', 'anthropic/claude-opus-5-5']).status).toBe(0);
|
||||
expect(autoplan(['status', '--out', out]).stdout).not.toContain(`PENDING: ${ids.native}`);
|
||||
expect(autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', run.phases.ceo.outside.attempts[0].result, '--model', 'openai/gpt-6.1-sol']).status).toBe(0);
|
||||
expect(readRun(out).phases.ceo.outside.provider).toBe('host-subagent');
|
||||
expect(fs.readFileSync(path.join(out, 'ceo-consensus.md'), 'utf8')).toContain('openai/gpt-6.1-sol(host-subagent)');
|
||||
expect(autoplan(['status', '--out', out]).stdout).not.toContain('PENDING:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('B9: every gate page reaches the owner', () => {
|
||||
const item = (i: number) => ({ id: `d${i}`, title: `finding ${i}`, kind: 'auto' as const, recommended: 'a', options: [{ key: 'a', text: 'keep' }, { key: 'b', text: 'change' }] });
|
||||
const list: GateList = { schema_version: 1, run: 'r', gate_rev: 1, items: [...Array.from({ length: 14 }, (_, i) => item(i + 1)), { id: 'p1', title: 'Plan approval', kind: 'approval', recommended: 'a', options: [{ key: 'a', text: 'approve' }, { key: 'b', text: 'other' }] }] };
|
||||
test('14 findings span three pages; --all prints every id once and paged output says to relay every page', () => {
|
||||
expect(renderGate(list)).toContain('or every page with `--all`. Relay every page.');
|
||||
const file = path.join(TMP, 'big-gate.json');
|
||||
fs.writeFileSync(file, JSON.stringify(list));
|
||||
const r = spawnSync('bun', [GATE, 'render', file, '--all'], { encoding: 'utf8', timeout: 30_000 });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain('page=1/1');
|
||||
for (const i of list.items) expect(r.stdout.match(new RegExp(`\\[${i.id}\\]`, 'g'))?.length).toBe(1);
|
||||
const pages = [1, 2, 3].map(p => renderGate(list, p)).join('\n');
|
||||
for (const i of list.items) expect(pages).toContain(`[${i.id}]`);
|
||||
expect(spawnSync('bun', [GATE, 'render', file, '--all', '--page', '2'], { encoding: 'utf8', timeout: 30_000 }).status).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('B6: PLAN_REALITY flag beside the approval item', () => {
|
||||
test('the plan names a minimum effect with no number: gate.json p1 carries the flag and the render prints it; nothing blocks', () => {
|
||||
const dir = path.join(TMP, 'vague');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const plan = path.join(dir, 'plan.md');
|
||||
fs.writeFileSync(plan, SMALL_PLAN.replace('Tests cover the cap', 'Ship once the minimum effect shows in the retry metrics. Tests cover the cap'));
|
||||
const out = path.join(dir, 'run');
|
||||
expect(autoplan(['next', '--out', out, '--plan', plan, '--no-ui']).status).toBe(0);
|
||||
for (const phase of ['ceo', 'eng']) { const r = runPhase(out, phase); expect(r.outside.status, r.outside.stderr).toBe(0); }
|
||||
const gate = JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8')) as GateList;
|
||||
const p1 = gate.items.find(i => i.id === 'p1')!;
|
||||
expect(p1.flags).toEqual([expect.stringMatching(/^PLAN_REALITY "minimum effect" has no number, arm list or rule body \(plan\.md:6: .*minimum effect.*\) \(report only\)$/)]);
|
||||
expect(renderGate(gate)).toMatch(/\n flagged: PLAN_REALITY "minimum effect"/);
|
||||
expect(readRun(out).status).toBe('gate_pending');
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { REALITY_ROWS, REALITY_SCOPES, TIER1_ROW_IDS, checkPlanReality, checkRealityRows, parseRealityLines, parseScopes, renderIncidentTable, renderRows, requiredRows } from '../lib/plan-reality';
|
||||
import { PLAN_REALITY_CHECKER, REALITY_ROWS, REALITY_SCOPES, TIER1_ROW_IDS, checkPlanReality, checkRealityRows, parseRealityLines, parseScopes, renderIncidentTable, renderRows, requiredRows, vaguePlanTerms } from '../lib/plan-reality';
|
||||
import { extractHeading } from '../lib/autoplan-prompts';
|
||||
import { RESULT_CODES } from '../lib/result-codes';
|
||||
import { generatePlanRealityRows } from '../scripts/resolvers/plan-reality';
|
||||
@@ -311,3 +311,57 @@ describe('field wave item 12: a receipt per measured number, the rule text for a
|
||||
expect(r.unreceipted_numbers.length).toBeLessThanOrEqual(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('next wave B6/B10: PLAN_REALITY flags without false positives; plan quotes need no receipt', () => {
|
||||
const others = (id: string) => Object.entries(FIXTURE.rows).filter(([k]) => k !== id).map(([, r]) => r.positive).join('\n');
|
||||
|
||||
test('B6: item and section references are not quantities; a phrase in backticks is named, not claimed (plan scope)', () => {
|
||||
const plan = "B1. `rec`: one word takes every recommendation (item 5).\nB6. When the plan names `minimum effect`, `margin`, `decision rule` or a dollar cost, the gate flags it (items 10, 14–19; section 3).\n";
|
||||
const r = checkPlanReality(plan);
|
||||
expect(r.unreceipted_numbers).toEqual([]);
|
||||
expect(r.unquoted).toEqual([]);
|
||||
expect(checkPlanReality('Startup takes roughly 9 s on a cold machine.\n').unreceipted_numbers).toEqual(['Startup takes roughly 9 s on a cold machine.']);
|
||||
expect(checkPlanReality('The decision rule was written first.\n').unquoted).toEqual(['The decision rule was written first.']);
|
||||
expect(PLAN_REALITY_CHECKER).toBe('plan-reality/3');
|
||||
});
|
||||
|
||||
test('B-f negative control: a measured number inside backticks in review scope stays flagged; a command in backticks still receipts', () => {
|
||||
const quiet = 'REALITY: numbers pass the cold install took `86 s` on Capy';
|
||||
expect(checkRealityRows(`${others('numbers')}\n${quiet}`, { phase: 'eng' }).unreceipted_numbers).toEqual(['the cold install took `86 s` on Capy']);
|
||||
const cmd = 'REALITY: numbers pass the cold install took 86 s `bun run bench --json` on Capy';
|
||||
expect(checkRealityRows(`${others('numbers')}\n${cmd}`, { phase: 'eng' }).unreceipted_numbers).toEqual([]);
|
||||
});
|
||||
|
||||
test('B10: in reviewer scope a number quoted verbatim from the reviewed plan needs no receipt; a reworded or unknown quote does', () => {
|
||||
const plan = 'Add exponential backoff: base 500 ms, factor 2, cap 30 s, jitter 10%.\n';
|
||||
const quoted = 'REALITY: numbers pass the plan says "base 500 ms, factor 2, cap 30 s" and gives no reason for the cap (plan.md:1)';
|
||||
expect(checkRealityRows(`${others('numbers')}\n${quoted}`, { phase: 'eng', planText: plan }).unreceipted_numbers).toEqual([]);
|
||||
expect(checkRealityRows(`${others('numbers')}\n${quoted}`, { phase: 'eng' }).unreceipted_numbers.length).toBeGreaterThan(0);
|
||||
const reworded = 'REALITY: numbers pass the plan says "a 45 s cap" and gives no reason';
|
||||
expect(checkRealityRows(`${others('numbers')}\n${reworded}`, { phase: 'eng', planText: plan }).unreceipted_numbers.length).toBe(1);
|
||||
const own = 'REALITY: numbers pass the plan says "cap 30 s"; retries took 900 ms in my run';
|
||||
expect(checkRealityRows(`${others('numbers')}\n${own}`, { phase: 'eng', planText: plan }).unreceipted_numbers).toEqual(['retries took 900 ms in my run']);
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-reality-quote-'));
|
||||
fs.writeFileSync(path.join(dir, 'plan.md'), plan);
|
||||
const bin = run(['check', '--phase', 'eng', '--plan', path.join(dir, 'plan.md'), '-'], `${others('numbers')}\n${quoted}`);
|
||||
expect(bin.code, bin.err).toBe(EXIT.ok);
|
||||
expect(run(['check', '--phase', 'eng', '-'], `${others('numbers')}\n${quoted}`).code).toBe(EXIT.fail);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('B6: vaguePlanTerms flags minimum effect, margin, decision rule and dollar cost only without a number, arm list or rule body', () => {
|
||||
const plan = [
|
||||
'Ship when the minimum effect is reached.',
|
||||
'Keep a safety margin on the spend.',
|
||||
'A margin of 15% over the pilot price.',
|
||||
'The decision rule:',
|
||||
'- arm A: keep the default',
|
||||
'- arm B: switch',
|
||||
'We price the dollar cost later (item 9).',
|
||||
'The decision rule is "stop when recall drops below the base".',
|
||||
'Name `minimum effect` in the template.',
|
||||
'```', 'margin inside a fence', '```',
|
||||
].join('\n');
|
||||
expect(vaguePlanTerms(plan).map(v => [v.phrase, v.line])).toEqual([['minimum effect', 1], ['margin', 2], ['dollar cost', 7]]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user