diff --git a/autoplan/sections/ceo-phase.md b/autoplan/sections/ceo-phase.md index de9d0d1bd..a878f308b 100644 --- a/autoplan/sections/ceo-phase.md +++ b/autoplan/sections/ceo-phase.md @@ -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 ` prints `PLAN_REALITY: … verdict=complete` before the phase closes + `~/.claude/skills/gstack/bin/gstack-plan-reality check --phase ceo --plan ` 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 diff --git a/autoplan/sections/eng-phase.md b/autoplan/sections/eng-phase.md index ad9da2018..d9d3856f1 100644 --- a/autoplan/sections/eng-phase.md +++ b/autoplan/sections/eng-phase.md @@ -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 ` prints `PLAN_REALITY: … verdict=complete` before the phase closes + `~/.claude/skills/gstack/bin/gstack-plan-reality check --phase eng --plan ` 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 diff --git a/bin/gstack-gate b/bin/gstack-gate index 6e4e10007..59e7abcd0 100755 --- a/bin/gstack-gate +++ b/bin/gstack-gate @@ -2,7 +2,7 @@ /** * gstack-gate — render and parse the one gate list (plan B9). * - * gstack-gate render [--page N] + * gstack-gate render [--page N | --all] * gstack-gate parse --reply "" --gate-rev N [--json] * gstack-gate decisions [--reply "" --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 [--page N] numbered items with stable ids, auto/approval, the reply shape + render [--page N | --all] numbered items with stable ids, auto/approval, the reply shape; + --all prints every item on one page (relay every page) parse --reply "" --gate-rev N [--json] resolved choices as JSON; unparsed tokens are listed, never guessed decisions [--reply "" --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') { diff --git a/bin/gstack-plan-reality b/bin/gstack-plan-reality index a5a880a75..8ca759c60 100755 --- a/bin/gstack-plan-reality +++ b/bin/gstack-plan-reality @@ -3,7 +3,7 @@ * gstack-plan-reality — the reality rows of a plan review (plan D1). * * gstack-plan-reality rows --phase [--scope ] [--deep] [--json] - * gstack-plan-reality check --phase

[--scope ] [--session-kind ] [--json] + * gstack-plan-reality check --phase

[--scope ] [--session-kind ] [--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

[--scope ] [--deep] [--json] the rows this review must carry (tier 1 always, tier 2 by scope) - check --phase

[--scope ] [--session-kind ] [--json] + check --phase

[--scope ] [--session-kind ] [--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`); diff --git a/lib/autoplan-gate.ts b/lib/autoplan-gate.ts index 23c327929..b46e895ea 100644 --- a/lib/autoplan-gate.ts +++ b/lib/autoplan-gate.ts @@ -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 )` }, { key: 'e', text: 'reject' }] }); diff --git a/lib/autoplan-plan-text.ts b/lib/autoplan-plan-text.ts index c6b41f7be..83d69cee1 100644 --- a/lib/autoplan-plan-text.ts +++ b/lib/autoplan-plan-text.ts @@ -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 = /^\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'); + }); +}); diff --git a/test/plan-reality.test.ts b/test/plan-reality.test.ts index 9bccc7a6b..ecdf21d55 100644 --- a/test/plan-reality.test.ts +++ b/test/plan-reality.test.ts @@ -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]]); + }); +});