fix: stabilize planning eval navigation and phase reporting

This commit is contained in:
Garry Tan committed 2026-09-08 17:38:46 +00:00
1 parent eaff1688fb
commit 823bdaf40c
12 files changed
+803 -47

No files matched your search

+190
View File
@@ -0,0 +1,190 @@
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { pathToFileURL } from 'node:url';
import { autoplanPhaseCompletions } from './helpers/autoplan-phase-observer';
import type { PlanCountTranscript } from './helpers/plan-count-transcript';
import { E2E_TOUCHFILES, selectTests } from './helpers/touchfiles';
const START = Date.parse('2026-09-08T16:00:00.000Z');
const transcript = (...messages: Array<[number, string]>): PlanCountTranscript => ({
status: 'ready', calls: [],
assistantMessages: messages.map(([ms, text]) => ({
sessionId: 'autoplan-fixture', timestamp: new Date(START + ms).toISOString(), text,
})),
});
describe('native autoplan phase observation', () => {
test('retains actual completion timestamps when several phases arrive between polls', () => {
expect(autoplanPhaseCompletions(transcript(
[1, '**Phase 1 complete.** Codex: 2 concerns. Native: 3 issues.'],
[2, 'Phase 2 complete. Design outputs are in the plan.'],
[3, '**Phase 2.5 complete.** DX overall: 8/10.'],
[4, 'Phase 3 complete. Both engineering reviews finished.'],
), START)).toEqual([1, 2, 2.5, 3].map((phase, index) => ({ phase, ts: START + index + 1 })));
});
test('duplicate announcements retain their first timestamp without reordering phases', () => {
expect(autoplanPhaseCompletions(transcript(
[3, 'Phase 1 complete.'], [2, 'Phase 2 complete.'],
[1, '**Phase 1 complete.**'], [4, '**Phase 3 complete.**'],
), START)).toEqual([{ phase: 1, ts: START + 1 }, { phase: 2, ts: START + 2 }, { phase: 3, ts: START + 4 }]);
});
test('headings, quoted skill text, examples, tables and planned checklists provide no completion', () => {
for (const text of [
'## Phase 3 complete.',
'**PHASE 3 COMPLETE.** Emit phase-transition summary:',
'> **Phase 3 complete.** Codex: [N concerns].',
'The section says "**Phase 3 complete.**".',
'Example: **Phase 3 complete.**',
'Example announcement:\n**Phase 3 complete.**',
'Example announcement:\nPhase 2 complete.\nPhase 3 complete.',
'The template requires this completion marker:\n\n**Phase 3 complete.**',
' **Phase 3 complete.**',
'\tPhase 3 complete.',
'```markdown\n**Phase 3 complete.**\n```',
'```markdown\n```still-code\nPhase 3 complete.\n```',
'````markdown\n```\nPhase 3 complete.\n````',
'~~~markdown\nPhase 3 complete.\n~~~',
'```markdown\nPhase 3 complete.',
'| **Phase 3 complete.** | pending |',
'- [ ] **Phase 3 complete.**',
'1. Phase 3 complete.',
'I will announce **Phase 3 complete.** after the review.',
'Phase 3 complete when the engineering review ends.',
'Phase 3 complete?',
'Phase 4 complete.',
]) expect(autoplanPhaseCompletions(transcript([1, text]), START), text).toEqual([]);
});
test('a real announcement after a closed example fence still establishes completion', () => {
expect(autoplanPhaseCompletions(transcript([1, 'Example:\n```markdown\nPhase 3 complete.\n```\n\n**Phase 1 complete.**']), START))
.toEqual([{ phase: 1, ts: START + 1 }]);
});
test('accepts a template-compliant quoted transition with actual consensus while rejecting quoted examples', () => {
const actual = '> **Phase 1 complete.** Codex: 2 concerns. Claude subagent: 3 issues.\n' +
'> Consensus: 4/6 confirmed, 2 disagreements → surfaced at gate.\n> Passing to Phase 2.';
expect(autoplanPhaseCompletions(transcript([1, actual]), START)).toEqual([{ phase: 1, ts: START + 1 }]);
expect(autoplanPhaseCompletions(transcript([1, actual.replace('Codex: 2 concerns.', 'Codex: unavailable.')]), START))
.toEqual([{ phase: 1, ts: START + 1 }]);
for (const quoted of [
actual.replace('4/6', 'X/6'),
actual.replace('2 concerns', '[N concerns]'),
'The template contains this example:\n' + actual,
'Example announcement:\n' + actual + '\n' + actual.replace('Phase 1', 'Phase 3'),
'> **Phase 3 complete.**',
]) expect(autoplanPhaseCompletions(transcript([1, quoted]), START), quoted).toEqual([]);
});
test('missing/error transcripts and pre-command declarations cannot establish coverage', () => {
const prior = transcript([-1, '**Phase 3 complete.**']);
expect(autoplanPhaseCompletions(prior, START)).toEqual([]);
for (const status of ['missing', 'error'] as const) {
expect(autoplanPhaseCompletions({ ...transcript([1, '**Phase 3 complete.**']), status }, START)).toEqual([]);
}
});
test('does not repair an out-of-order chain or synthesize omitted phases', () => {
expect(autoplanPhaseCompletions(transcript([1, 'Phase 3 complete.'], [2, 'Phase 1 complete.']), START))
.toEqual([{ phase: 3, ts: START + 1 }, { phase: 1, ts: START + 2 }]);
expect(autoplanPhaseCompletions(transcript([1, 'Phase 2 skipped — no UI scope.']), START)).toEqual([]);
});
test('phase observer changes select the autoplan eval', () => {
for (const file of ['test/helpers/autoplan-phase-observer.ts', 'test/autoplan-phase-observer.test.ts']) {
expect(selectTests([file], E2E_TOUCHFILES).selected).toEqual(['autoplan-chain-pty']);
}
});
test.skipIf(process.platform === 'win32')('ANSI-rendered completions use native evidence while displayed Read/source markers do not', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-phase-replay-'));
const fake = path.join(dir, 'fake-claude');
const worker = path.join(dir, 'worker.ts');
const recordFile = path.join(dir, 'events.jsonl');
const resultFile = path.join(dir, 'result.json');
fs.writeFileSync(fake, `#!${process.execPath}\n` + String.raw`
import * as fs from 'node:fs';
import * as path from 'node:path';
fs.writeFileSync(process.env.PHASE_RECORD, JSON.stringify({pid:process.pid}) + '\n');
const sessionId = 'fake-autoplan-session';
const dir = path.join(process.env.CLAUDE_CONFIG_DIR, 'projects', 'fixture');
fs.mkdirSync(dir, {recursive:true});
const base = Date.now();
const write = (role, content, offset, extra = {}) => fs.appendFileSync(path.join(dir, sessionId + '.jsonl'), JSON.stringify({
sessionId, cwd:process.cwd(), isSidechain:false, timestamp:new Date(base + offset).toISOString(), message:{role,content}, ...extra,
}) + '\n');
write('user', [{type:'tool_result', tool_use_id:'read', content:'**Phase 3 complete.**'}], 0);
write('assistant', [{type:'text', text:'> **Phase 3 complete.** is the quoted source marker.'}], 0);
write('assistant', [{type:'text', text:'**Phase 3 complete.**'}], 0, {isSidechain:true});
write('assistant', [{type:'text', text:'**Phase 3 complete.**'}], 0, {cwd:path.join(process.cwd(), 'foreign')});
process.stdin.setRawMode?.(true);
let sent = false;
process.stdin.on('data', data => {
if (sent || !data.toString().includes('\r')) return;
sent = true;
process.stdout.write('\x1b[2J\x1b[H');
[1, 2, 2.5, 3].forEach((phase, index) => {
const message = '**Phase ' + phase + ' complete.**';
write('assistant', [{type:'text', text:message}], index + 1);
process.stdout.write('● \x1b[1mPhase ' + phase + ' complete.\x1b[22m\n');
});
process.stdout.write('NATIVE_PHASES_READY\n');
});
process.stdout.write('Read: autoplan/sections/eng-phase.md\n> **Phase 3 complete.**\nSOURCE_READY\n');
process.on('SIGINT', () => process.exit(0));
process.stdin.resume();
`);
fs.chmodSync(fake, 0o755);
const moduleUrl = (name: string) => pathToFileURL(path.resolve(import.meta.dir, 'helpers', name)).href;
fs.writeFileSync(worker, `
import { launchClaudePty } from ${JSON.stringify(moduleUrl('claude-pty-runner.ts'))};
import { readPlanCountTranscript } from ${JSON.stringify(moduleUrl('plan-count-transcript.ts'))};
import { autoplanPhaseCompletions } from ${JSON.stringify(moduleUrl('autoplan-phase-observer.ts'))};
const start = Date.now();
const session = await launchClaudePty({cwd:${JSON.stringify(dir)}, timeoutMs:8000, env:{PHASE_RECORD:${JSON.stringify(recordFile)}}});
try {
await session.waitFor('SOURCE_READY', {timeoutMs:4000, pollMs:20});
const read = () => readPlanCountTranscript(session.hermeticConfigDir, ${JSON.stringify(dir)});
const sourceOnly = autoplanPhaseCompletions(read(), start);
const oldPattern = /\\*\\*Phase\\s+(\\d+(?:\\.\\d+)?)\\s+complete\\.?\\*\\*/g;
const sourceFalsePositive = [...session.visibleText().matchAll(oldPattern)].length;
const since = session.mark();
session.send('\\r');
await session.waitFor('NATIVE_PHASES_READY', {timeoutMs:4000, pollMs:20});
const rendered = session.visibleSince(since);
const oldPatternMissed = [...rendered.matchAll(oldPattern)].length;
const hits = autoplanPhaseCompletions(read(), start);
await Bun.write(${JSON.stringify(resultFile)}, JSON.stringify({sourceOnly, sourceFalsePositive, oldPatternMissed, hits, rendered}));
} finally { await session.close(); }
`);
const child = Bun.spawn([process.execPath, worker], {
env: { ...process.env, BROWSE_TERMINAL_BINARY: fake, EVALS_HERMETIC: '1' }, stdout: 'pipe', stderr: 'pipe',
});
const timer = setTimeout(() => child.kill('SIGKILL'), 12_000);
try {
const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
expect(code, stdout + stderr).toBe(0);
const result = JSON.parse(fs.readFileSync(resultFile, 'utf8'));
expect(result.sourceOnly).toEqual([]);
expect(result.sourceFalsePositive).toBe(1);
expect(result.oldPatternMissed).toBe(0);
expect(result.rendered).toContain('Phase 3 complete.');
expect(result.hits.map((hit: { phase: number }) => hit.phase)).toEqual([1, 2, 2.5, 3]);
for (let index = 1; index < result.hits.length; index++) {
expect(result.hits[index].ts).toBeGreaterThan(result.hits[index - 1].ts);
}
const pid = JSON.parse(fs.readFileSync(recordFile, 'utf8').split('\n')[0]!).pid;
expect(() => process.kill(pid, 0)).toThrow();
} finally {
clearTimeout(timer); child.kill('SIGKILL');
if (fs.existsSync(recordFile)) {
const pid = JSON.parse(fs.readFileSync(recordFile, 'utf8').split('\n')[0]!).pid;
try { process.kill(pid, 'SIGKILL'); } catch { /* already reaped */ }
}
fs.rmSync(dir, {recursive:true, force:true});
}
}, 15_000);
});
+117
View File
@@ -43,7 +43,80 @@ const CURRENT_CAPTURE = [
'Entertoselect·↑/↓tonavigate·Esctocancel',
].join('\n');
// Targeted-b's first attempt stayed on this complete setup menu until its
// 15-minute deadline. The parser retained the prompt and both labels, but
// the setup selector rejected "No thanks, invoke manually".
const B_CAPTURE = [
' ☐ CLAUDE.md',
'',
'│ D1 — Add gstack skill routing rules to CLAUDE.md? <gstack-qid:routing-injection>',
'│',
'│ELI10:ThisprojecthasnoCLAUDE.md.Thatfileiswheregstacklooksforroutingrules—instructionstellingClaude',
'│Codewhichskilltoauto-invokeforwhichrequest(e.g."ship→/ship","bugs→/investigate").Withoutityoutype',
'│theskillnameeverytime.Withit,gstackcanrecognizeyourintentandrouteautomatically.',
'│',
'│Stakesifweskip:Noauto-routing;youinvokeskillsmanuallyeachsession.',
'│',
'│Recommendation:A—one-timesetup,saveskeystrokesoneveryfuturesession.',
'│Completeness:A=9/10,B=5/10',
'',
'❯1.AddroutingrulestoCLAUDE.md(Recommended)',
'AppendsthestandardgstackroutingblocktoanewCLAUDE.mdandcommitsit.Doneonce,activeforever.',
'2.Nothanks,invokemanually',
'SkipCLAUDE.mdsetup.Youcontinuecalling/autoplan,/ship,/qa,etc.bynameeachtime.',
'3.Typesomething.',
'─'.repeat(120),
'4.Chataboutthis',
'Entertoselect·↑/↓tonavigate·Esctocancel',
].join('\r\r');
// Fresh broad retry: the complete setup menu uses a noun for the manual
// alternative. This is the same opposed setup action as "invoke manually".
const FRESH_RETRY_CAPTURE = [
'☐Routingsetup',
"│gstackworksbestwhenyourproject'sCLAUDE.mdincludesskillroutingrules.Addthemnow?",
'❯1.Addroutingrules(Recommended)',
'AppendskillroutingrulestoCLAUDE.mdsoClaudeautomaticallyinvokestherightskillforproduct,engineering,',
'design,andshipworkflows.Willbedoneafterplanapproval(planmodeisactivenow).',
'2.Nothanks,manualinvocation',
"Skip—I'llinvokeskillsmanually.Thispromptwon'tappearagain.",
'3.Typesomething.',
'─'.repeat(120),
'4.Chataboutthis',
'Entertoselect·↑/↓tonavigate·Esctocancel',
].join('\n');
describe('autoplan routing setup handling', () => {
test('answers the fresh retry manual-invocation setup once in either option order', () => {
const seen = new Set<string>();
expect(autoplanRoutingSetupInput(FRESH_RETRY_CAPTURE, seen)).toBe('1\r');
expect(autoplanRoutingSetupInput(FRESH_RETRY_CAPTURE, seen)).toBeNull();
const reordered = FRESH_RETRY_CAPTURE.replace('❯1.Addroutingrules(Recommended)', '❯1.Nothanks,manualinvocation')
.replace('2.Nothanks,manualinvocation', '2.Addroutingrules(Recommended)');
expect(autoplanRoutingSetupInput(reordered, new Set())).toBe('2\r');
});
test('requires opposed manual setup actions and rejects ambiguous or unrelated choices', () => {
for (const decline of [
'No thanks, delete the file manually',
'No thanks, manual data migration',
'No thanks, invoke the deploy manually',
'Manual invocation',
'Accept recommendation',
'No thanks, manual invocation then delete CLAUDE.md',
]) {
const frame = FRESH_RETRY_CAPTURE.replace('Nothanks,manualinvocation', decline);
expect(autoplanRoutingSetupInput(frame, new Set()), decline).toBeNull();
}
expect(autoplanRoutingSetupInput(FRESH_RETRY_CAPTURE.replace('3.Typesomething.', '3.Add routing rules'), new Set())).toBeNull();
expect(autoplanRoutingSetupInput(FRESH_RETRY_CAPTURE.replace('3.Typesomething.', '3.Skip—invoke manually'), new Set())).toBeNull();
const review = FRESH_RETRY_CAPTURE.replace(
"gstackworksbestwhenyourproject'sCLAUDE.mdincludesskillroutingrules.Addthemnow?",
'Which product routing design should we ship? <gstack-qid:routing-injection>',
);
expect(autoplanRoutingSetupInput(review, new Set())).toBeNull();
});
test('answers the captured setup once, using the full question identity', () => {
const seen = new Set<string>();
expect(autoplanRoutingSetupInput(CAPTURE, seen)).toBe('1\r');
@@ -92,6 +165,50 @@ describe('autoplan routing setup handling', () => {
expect(autoplanRoutingSetupInput(retry.replace('No thanks, manual', 'No thanks, delete it'), new Set())).toBeNull();
});
test('answers the exact B timeout menu by its routing label, in either order', () => {
const seen = new Set<string>();
expect(autoplanRoutingSetupInput(B_CAPTURE, seen)).toBe('1\r');
expect(autoplanRoutingSetupInput(B_CAPTURE, seen)).toBeNull();
const reordered = B_CAPTURE.replace('❯1.AddroutingrulestoCLAUDE.md(Recommended)', '❯1.Nothanks,invokemanually')
.replace('2.Nothanks,invokemanually', '2.AddroutingrulestoCLAUDE.md(Recommended)');
expect(autoplanRoutingSetupInput(reordered, new Set())).toBe('2\r');
expect(autoplanRoutingSetupInput(B_CAPTURE.replace('Nothanks,invokemanually', 'Nothanks,deletethefilemanually'), new Set())).toBeNull();
expect(autoplanRoutingSetupInput(B_CAPTURE.replace('Add gstack skill routing rules to CLAUDE.md?', 'Which routing design should the application use?'), new Set())).toBeNull();
});
test('recognizes the setup premise without depending on its closing sentence', () => {
const openings = [
"gstack works best when your project's CLAUDE.md includes skill routing rules. Would you like to add them?",
"gstack works best when your project's CLAUDE.md includes skill routing rules. Enable them for this repository?",
'Should we configure skill routing rules for gstack in CLAUDE.md?',
'Set up gstack skill routing rules in CLAUDE.md.',
];
for (const opening of openings) {
const frame = CURRENT_CAPTURE.replace('Add gstack skill routing rules to CLAUDE.md? <gstack-qid:routing-injection>', opening);
expect(autoplanRoutingSetupInput(frame, new Set()), opening).toBe('1\r');
}
});
test('recognizes an intact setup qid with an explicit CLAUDE.md action and opposed manual decline', () => {
const frame = CURRENT_CAPTURE.replace('Add gstack skill routing rules to CLAUDE.md?', 'Configure this project’s CLAUDE.md?');
expect(autoplanRoutingSetupInput(frame, new Set())).toBe('1\r');
expect(autoplanRoutingSetupInput(frame.replace('gstack-qid:routing-injection', 'gstack-qid:product-routing'), new Set())).toBeNull();
expect(autoplanRoutingSetupInput(frame.replace('AddtoCLAUDE.md(recommended)', 'Acceptrecommendation'), new Set())).toBeNull();
expect(autoplanRoutingSetupInput(frame.replace('Skip—invokemanually', 'Deferthisfinding'), new Set())).toBeNull();
});
test('keeps generic review, quoted premises and different routing targets out of setup handling', () => {
for (const question of [
'Which dashboard layout should we ship?',
'Add routing rules to the application API? <gstack-qid:product-routing>',
'The plan quotes gstack CLAUDE.md skill routing rules. Which API design should we use?',
'The document references gstack skill routing rules in CLAUDE.md. Should we expand the feature?',
]) {
const frame = CURRENT_CAPTURE.replace('Add gstack skill routing rules to CLAUDE.md? <gstack-qid:routing-injection>', question);
expect(autoplanRoutingSetupInput(frame, new Set()), question).toBeNull();
}
});
test('waits for complete recognized choices rather than guessing a default', () => {
expect(autoplanRoutingSetupInput(CAPTURE.replace('2.Nothanks', '2.Ask me later'), new Set())).toBeNull();
expect(autoplanRoutingSetupInput(CAPTURE.replace('Addroutingrules(Recommended)', 'Accept recommendation'), new Set())).toBeNull();
+76
View File
@@ -0,0 +1,76 @@
import type { PlanCountTranscript } from './plan-count-transcript';
export interface AutoplanPhaseHit {
phase: number;
ts: number;
}
function phaseDeclaration(text: string): RegExpExecArray | null {
const plain = text.replace(/^\*\*(Phase[ \t]+[\d.]+[ \t]+complete\.?)\*\*/i, '$1');
if (/\bEmit\s+phase-transition\s+summary\s*:/i.test(plain)) return null;
return /^Phase[ \t]+(1|2(?:\.5)?|3)[ \t]+complete(?:\.(?:[ \t]+.*)?|)$/i.exec(plain);
}
/**
* Observe actual assistant announcements from this fixture's native transcript.
* The terminal renders Markdown bold as ANSI, and its Read output can contain
* the same source markers. Neither rendered styling nor tool output is evidence
* that a phase completed. Native timestamps also preserve order when several
* completed messages arrive between two polls.
*/
export function autoplanPhaseCompletions(
transcript: PlanCountTranscript,
commandStartedAt: number,
): AutoplanPhaseHit[] {
if (transcript.status !== 'ready') return [];
const hits: AutoplanPhaseHit[] = [];
const messages = [...transcript.assistantMessages]
.filter(message => Number.isFinite(Date.parse(message.timestamp)) && Date.parse(message.timestamp) >= commandStartedAt)
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
for (const message of messages) {
let fence: { char: string; length: number } | undefined;
let previousLine = '';
const lines = message.text.split(/\r?\n/);
for (let index = 0; index < lines.length; index++) {
const line = lines[index]!;
// Four spaces/a tab creates an indented Markdown code block. Preserve
// that distinction before trimming the declaration's whitespace.
if (/^(?: {4}|\t)/.test(line)) continue;
const text = line.trim();
const delimiter = /^(`{3,}|~{3,})/.exec(text)?.[1];
if (delimiter) {
if (!fence) fence = { char: delimiter[0]!, length: delimiter.length };
else if (delimiter[0] === fence.char && delimiter.length >= fence.length &&
!text.slice(delimiter.length).trim()) fence = undefined;
previousLine = text;
continue;
}
if (fence) continue;
// Accept a plain/bold declaration, never a heading, quoted source,
// table cell, checklist, or a sentence promising future completion.
let match = phaseDeclaration(text);
if (/^>\s/.test(text)) {
// The skill's transition summary itself is a blockquote. Accept a
// filled-in single-phase summary with concrete consensus counts;
// a bare quotation or the template's [N]/[X/Y] examples cannot pass.
const block: string[] = [];
for (let cursor = index; cursor < lines.length && /^ {0,3}>/.test(lines[cursor]!); cursor++) {
block.push(lines[cursor]!.replace(/^ {0,3}>\s?/, ''));
}
const concreteSummary = block.filter(value => phaseDeclaration(value)).length === 1 &&
block.some(value => /^Consensus:\s*\d+\s*\/\s*\d+\b/i.test(value)) &&
!/\[[^\]]*\]|\{\{/.test(block.join('\n'));
if (concreteSummary) match = phaseDeclaration(text.replace(/^>\s?/, ''));
}
const introducedExample = /\b(?:example|sample|quote(?:d)?|source|template|instruction|marker|expected\s+(?:output|announcement))\b.*[::]\s*$/i.test(previousLine);
// Keep an example introduction across all its marker/quoted lines,
// rather than allowing its second marker to look like real completion.
if (text && !(introducedExample && (match || text.startsWith('>')))) previousLine = text;
if (!match || introducedExample) continue;
const phase = Number(match[1]);
if (!hits.some(hit => hit.phase === phase)) hits.push({ phase, ts: Date.parse(message.timestamp) });
}
}
return hits;
}
+28 -13
View File
@@ -5,21 +5,36 @@ export function autoplanRoutingSetupInput(visible: string, seen: Set<string>): s
const question = capturePlanCountQuestion(visible, seen, 0, true);
if (!question) return null;
// Live captures vary in wording and can lose characters from the qid.
// Require an explicit CLAUDE.md skill-routing setup question plus both
// named setup actions; a routing qid or a generic recommendation alone
// cannot authorize answering a review/taste question.
const prompt = question.promptSnippet.replace(/\s+/g, '');
if (!/gstackworksbestwhenyourproject['’]sCLAUDE\.mdincludesskillroutingrules\.Addthemnow\?/i.test(prompt) &&
!/Addgstackskillroutingrulesto(?:thisproject['’]s)?CLAUDE\.md\?/i.test(prompt)) {
return null;
}
// The model rephrases the setup question's closing sentence. Its routing
// identity/premise and two opposed setup actions establish what is being
// asked; an exact "Add them now?" sentence is not a stable interface.
// Keep the actual question/premise separate from its header and later ELI10
// prose, which may mention CLAUDE.md even on an unrelated question.
const primary = question.promptSnippet.replace(/^(?:Routing\s*rules|CLAUDE\.md)\s*/i, '').split('?', 1)[0]!;
const prompt = primary.replace(/\s+/g, '');
const options = question.options.map(option => ({
index: option.index,
title: option.label.split(/[│┌\r\n]/, 1)[0]!.replace(/\s+/g, ''),
}));
const add = options.find(option => /^Add(?:routingrules(?:toCLAUDE\.md)?|toCLAUDE\.md)(?:\(Recommended\))?$/i.test(option.title));
const decline = options.find(option => /^(?:Nothanks(?:,manual|,I['’]llinvokeskillsmanually)?|Skip[—–-]invoke(?:skills)?manually)(?:\(Recommended\))?$/i.test(option.title));
if (!add || !decline) return null;
return `${add.index}\r`;
const add = options.filter(option => /^Add(?:routingrules(?:toCLAUDE\.md)?|toCLAUDE\.md)(?:\(Recommended\))?$/i.test(option.title));
// Match the declined setup action, not every English label separately:
// No thanks/Skip may stand alone or opt into manual invocation. A manual
// migration, deletion, or unrelated workflow is not the opposed action.
const decline = options.filter(option => {
const title = option.title.replace(/\(Recommended\)$/i, '');
const prefix = /^(?:Nothanks|Skip)(?:[,—–-])?/i.exec(title);
if (!prefix) return false;
const action = title.slice(prefix[0].length);
return action === '' || /^(?:manual(?:invocation)?|(?:I['’]ll)?invoke(?:skills)?manually)$/i.test(action);
});
if (add.length !== 1 || decline.length !== 1 || add[0]!.index === decline[0]!.index) return null;
const routingId = /<gstack-qid:routing-injection>/i.test(question.promptSnippet);
const routingPremise = /gstack/i.test(prompt) && /CLAUDE\.md/i.test(prompt) && /skillroutingrules/i.test(prompt);
// A qid can replace the longer premise, but cannot override a question
// about a different target. The Add action and question must agree on
// project setup rather than a product routing or taste decision.
const claudeTarget = /CLAUDE\.md/i.test(prompt);
const quotedPremise = /\b(?:plan|spec|document)\s+(?:quotes?|cites?|references?)\b/i.test(primary);
if (!claudeTarget || quotedPremise || (!routingId && !routingPremise)) return null;
return `${add[0]!.index}\r`;
}
+43 -2
View File
@@ -1273,6 +1273,35 @@ export function capturePlanCountQuestion(
return { signature, promptSnippet, options, observedAtMs, preReview };
}
/**
* Consume file-permission menus separately from native review questions.
* A granted menu can be repainted, then linger after the tool completes.
* Its text is not a new answer request. The same file can ask again after
* a successful native Write/Edit result, so content-only dedup is too broad.
*/
export function createPlanCountPermissionGuard(): (visible: string) => 'grant' | 'handled' | null {
let granted: { signature: string; completedAt: number } | undefined;
return (visible) => {
const normalized = stripPtyResidue(visible).replace(/\r+\n?/g, '\n');
const start = Math.max(0, normalized.length - 4096);
const cursor = [...normalized.slice(start).matchAll(/❯\s*1\./g)].at(-1);
if (!cursor) return null;
const cursorAt = start + cursor.index;
const question = [...normalized.slice(start, cursorAt).matchAll(/^[\t │]*(Do\s*you\s*want\s*to\s*(?:create|overwrite|edit|make\s*this\s*edit\s*to)[^\n?]*\?)/gim)].at(-1)?.[1];
if (!question || !isPermissionDialogVisible(question + '\n' + normalized.slice(cursorAt))) return null;
const signature = question.replace(/\s+/g, '');
// Match actual native file-tool results, not proposed diff rows, tool
// headers, or tips. A diff repaint after the menu is still pending.
const completed = [...normalized.matchAll(/^[\t ]*⎿[\t \u00a0]*(?:Wrote\s*\d+\s*lines?|Added\s*\d+\s*lines?|Removed\s*\d+\s*lines?|Updated\b|Edited\b)/gm)].at(-1);
const completedAt = completed?.index ?? -1;
if (completedAt > cursorAt) return 'handled';
if (granted?.signature === signature && completedAt <= granted.completedAt) return 'handled';
granted = { signature, completedAt };
return 'grant';
};
}
/** Keep a seeded count plan intact by declining its optional prerequisite. */
export function planCountPrerequisitePick(fp: AskUserQuestionFingerprint): number | null {
// Require the recognized prerequisite body AND both opposed labels. A
@@ -1472,6 +1501,13 @@ export const ceoFirstReviewAUQ: Step0BoundaryPredicate = (fp) =>
}) ?? false;
export const engStep0Boundary: Step0BoundaryPredicate = (fp) =>
// Native onboarding identifies this choice by its header and opposed
// scopes; the question body can paraphrase "cross-project learnings".
// An answered sibling tab cannot close setup for this unanswered choice.
(fp.nativeCall?.answered === true && !fp.nativeCall.failed && fp.nativeCall.questions.some(q =>
Boolean(fp.nativeCall?.answers?.[q.question]) && /^cross[- ]project$/i.test(q.header.trim()) &&
q.options.some((enabled, i) => /^enable\s+cross[- ]project\b/i.test(enabled.label.trim()) &&
q.options.some((scoped, j) => j !== i && /\bproject[- ]scoped\b/i.test(scoped.label))))) ||
// The native complexity gate can say "Reduce scope or proceed as-is"
// without the historical "scope reduction recommendation" wording.
// It closes setup only once that exact scope question has an answer.
@@ -2289,6 +2325,7 @@ export async function runPlanSkillCounting(opts: {
const fingerprints: AskUserQuestionFingerprint[] = [];
const seen = new Set<string>();
const countedCalls = new Set<string>();
const filePermission = createPlanCountPermissionGuard();
let transcript: PlanCountTranscript = { status: 'missing', calls: [], assistantMessages: [] };
let boundaryFired = false;
let step0Count = 0;
@@ -2391,8 +2428,9 @@ export async function runPlanSkillCounting(opts: {
}
const frame = classifyPlanCountFrame(visible);
if (frame === 'permission') {
session.send(`${defaultPick}\r`);
const permission = filePermission(visible);
if (frame === 'permission' || (permission === 'grant' && frame === null)) {
if (permission !== 'handled') session.send(`${defaultPick}\r`);
await Bun.sleep(1500);
continue;
}
@@ -2447,6 +2485,9 @@ export async function runPlanSkillCounting(opts: {
);
}
// A dismissed or repainted permission is never a native question.
if (permission === 'handled') continue;
// Dedupe the complete question, not just its answer labels: separate
// findings often reuse the same Add to plan / Defer / Skip menu.
const pending = transcript.calls.find(c => !c.answered && !c.failed);
+219
View File
@@ -41,6 +41,7 @@ import {
COMPLETION_SUMMARY_RE,
classifyPlanCountFrame,
capturePlanCountQuestion,
createPlanCountPermissionGuard,
planCountPrerequisitePick,
planCountSubmissionInput,
assertReviewReportAtBottom,
@@ -1597,6 +1598,150 @@ describe('Step0BoundaryPredicate per-skill', () => {
};
}
describe('native Cross-project onboarding boundary', () => {
// Fresh paid run D, 2026-09-08: native question stems, labels and
// successful answers. Long explanatory paragraphs are omitted; they
// must not determine this structural setup boundary.
const captured = [
{
"header": "Routing rules",
"question": "Should I add gstack skill routing rules to your project's CLAUDE.md? (Note: we're in plan mode — if you pick A, I'll make the edit after we exit plan mode.)",
"options": [
"Add routing rules (recommended)",
"Skip — invoke manually"
],
"answer": "Add routing rules (recommended)"
},
{
"header": "Scope challenge",
"question": "D2 — The plan introduces 4 new classes across 12 files. Should I flag scope reduction as a primary recommendation in the review, or accept the 4-class design and focus findings on quality issues?",
"options": [
"Accept 4-class design, focus on quality",
"Flag scope reduction as primary finding (recommended)"
],
"answer": "Accept 4-class design, focus on quality"
},
{
"header": "Cross-project",
"question": "D3 — Should gstack search learnings from your other projects on this machine when reviewing?",
"options": [
"Enable cross-project learnings (recommended)",
"Keep learnings project-scoped only"
],
"answer": "Enable cross-project learnings (recommended)"
},
{
"header": "AuthCache race",
"question": "D4 — AuthCache is shared mutable state mutated by two services with no serialization. How should we fix it?",
"options": [
"Single-writer: AuthBroker owns all writes (recommended)",
"Immutable cache + versioned replace",
"Accept and document the race"
],
"answer": "Single-writer: AuthBroker owns all writes (recommended)"
},
{
"header": "Double-cache risk",
"question": "D5 — The plan introduces a new AuthCache class but doesn't say what happens to the existing cache adapter. Are they running in parallel?",
"options": [
"AuthCache replaces the adapter — add migration to plan (recommended)",
"AuthCache wraps the adapter — adapter stays as storage layer",
"Leave ambiguous — clarify in implementation"
],
"answer": "AuthCache replaces the adapter — add migration to plan (recommended)"
},
{
"header": "Error swallowing",
"question": "D6 — validateAndDispatch() swallows three different error classes across nested try/catch blocks. How should this be resolved in the plan?",
"options": [
"Decompose + typed error results (recommended)",
"Keep structure, add logging + rethrow",
"Leave as-is — document that swallowing is intentional"
],
"answer": "Decompose + typed error results (recommended)"
},
{
"header": "Invalidation tests",
"question": "D7 — When AuthCache replaces the existing adapter (per D5), the existing invalidation tests (logout, revocation, tenant suspension) become dead — they're testing a retired object. Should the plan explicitly require migrating them?",
"options": [
"Migrate invalidation tests to AuthCache — add to plan (recommended)",
"Scope to new component tests only — leave invalidation as follow-up",
"Assume existing tests cover it — no explicit migration step"
],
"answer": "Migrate invalidation tests to AuthCache — add to plan (recommended)"
},
{
"header": "IDP parallelization",
"question": "D8 — The plan identifies 5 sequential IDP calls that are independent and could be parallelized with Promise.all. Should we include the fix in this PR or defer it?",
"options": [
"Parallelize with Promise.all in this PR (recommended)",
"Defer to TODOS.md",
"Leave sequential — document as known limitation"
],
"answer": "Parallelize with Promise.all in this PR (recommended)"
}
];
const fingerprint = (index: number) => {
const row = captured[index];
return nativePlanCallFingerprint({
sessionId: 'fresh-eng-cross-project', toolUseId: `call-${index}`, answered: true,
questions: [{ header: row.header, question: row.question, options: row.options.map(label => ({ label })) }],
answers: { [row.question]: row.answer },
}, index, true);
};
test('keeps D3 as setup and counts each following actual review call', () => {
let started = false;
const phases = captured.map((_, i) => {
const phase = planCountQuestionPhase(fingerprint(i), started, engStep0Boundary);
started = phase.reviewStarted;
return phase;
});
expect(phases.slice(0, 3).map(p => p.preReview)).toEqual([true, true, true]);
expect(phases[2].reviewStarted).toBe(true);
expect(phases.slice(3).map(p => p.preReview)).toEqual([false, false, false, false, false]);
});
test('uses the native opposed scope choices without depending on a prompt suffix', () => {
const fp = fingerprint(2);
expect(engStep0Boundary(fp)).toBe(true);
const q = fp.nativeCall!.questions[0];
q.question = 'Should local lessons from other repositories be included during reviews?';
q.options.reverse();
fp.nativeCall!.answers = { [q.question]: q.options[0].label };
expect(engStep0Boundary(nativePlanCallFingerprint(fp.nativeCall!, 0, true))).toBe(true);
});
test("an unanswered Cross-project tab cannot borrow another tab's answer", () => {
const call = structuredClone(fingerprint(2).nativeCall!);
const other = { header: 'Routing rules', question: 'Add routing rules?', options: [{ label: 'Add rules' }, { label: 'Skip' }] };
call.questions.push(other);
call.answers = { [other.question]: 'Add rules' };
expect(engStep0Boundary(nativePlanCallFingerprint(call, 0, true))).toBe(false);
call.answers = { [call.questions[0].question]: call.questions[0].options[1].label };
expect(engStep0Boundary(nativePlanCallFingerprint(call, 0, true))).toBe(true);
});
test('pending, failed, missing native metadata and ordinary review questions are not this gate', () => {
const fp = fingerprint(2);
expect(engStep0Boundary({ ...fp, nativeCall: undefined })).toBe(false);
for (const alter of [
(call: any) => { call.answered = false; },
(call: any) => { call.failed = true; },
(call: any) => { call.questions[0].header = 'Architecture'; },
(call: any) => { call.questions[0].options = [{ label: 'Enable cross-project search' }, { label: 'Disable all search' }]; },
(call: any) => { call.questions[0].options = [{ label: 'Enable cross-project search with project-scoped storage' }, { label: 'Discuss later' }]; },
(call: any) => { call.questions[0].question = 'How should concurrent AuthCache writes across projects be serialized?';
call.questions[0].options = [{ label: 'Serialize mutations' }, { label: 'Use project-scoped locks' }]; },
]) {
const call = structuredClone(fp.nativeCall!);
alter(call);
call.answers = { [call.questions[0].question]: call.questions[0].options[0].label };
expect(engStep0Boundary(nativePlanCallFingerprint(call, 0, true))).toBe(false);
}
});
});
describe('ceoStep0Boundary', () => {
test('FIRES on Step 0F mode-pick AUQ (HOLD SCOPE in options)', () => {
const f = fp('Pick a mode', ['HOLD SCOPE', 'SCOPE EXPANSION', 'SELECTIVE EXPANSION', 'SCOPE REDUCTION']);
@@ -1889,3 +2034,77 @@ describe('Step0BoundaryPredicate per-skill', () => {
});
});
});
describe('file permission lifecycle replay', () => {
const permission = (file = 'gstack-test-plan-design.md') => [
`Do you want to make this edit to ${file}?`,
'❯ 1. Yes',
'2.Yes,andswitchtoacceptedits(auto-approvefileeditsandcommonfilecommands)forthissession;Yes,and',
'alwaysallowaccessto/tmp/fixtureforthissession',
'3.No',
'Esctocancel·Tabtoamend',
].join('\n');
test('ignores the granted menu and its redraw until a new request follows file-tool completion', () => {
const guard = createPlanCountPermissionGuard();
const first = permission();
expect(guard(first)).toBe('grant');
expect(guard(first)).toBe('handled');
const redraw = first + '\n' + permission();
expect(guard(redraw)).toBe('handled');
const completed = redraw + '\n●Write(/tmp/fixture/gstack-test-plan-design.md)\n' +
'⎿ Wrote320linesto../fixture/gstack-test-plan-design.md\n' + '·'.repeat(1600);
expect(classifyPlanCountFrame(completed)).toBeNull();
expect(guard(completed)).toBe('handled');
expect(guard(completed + '\n' + permission())).toBe('grant');
});
test('singular native Write/Edit results release a fresh identical permission', () => {
for (const result of ['⎿ Added1line,removed1line', '⎿ Wrote1lineto../fixture/plan.md', '⎿ Removed1line', '⎿\u00a0Wrote320linesto../fixture/plan.md']) {
const guard = createPlanCountPermissionGuard();
const first = permission();
expect(guard(first)).toBe('grant');
const completed = first + '\n' + result;
expect(guard(completed)).toBe('handled');
expect(guard(completed + '\n' + permission())).toBe('grant');
}
});
test('the captured active Edit menu remains a permission behind a long diff repaint', () => {
const visible = permission('gstack-test-plan-ceo.md') + '\n' +
' 89 +The plan adds StripePaymentWebhookHandler outside WebhookDispatcher.\n'.repeat(40);
expect(visible.length).toBeLessThan(4096);
expect(classifyPlanCountFrame(visible)).toBeNull(); // The old 1.5 KB scan misses it.
const guard = createPlanCountPermissionGuard();
expect(guard(visible)).toBe('grant');
expect(guard(visible)).toBe('handled');
});
test('a completed Write invalidates an old menu even if polling missed the original grant', () => {
const visible = permission() + '\n⎿ Wrote320linesto../fixture/gstack-test-plan-design.md';
expect(createPlanCountPermissionGuard()(visible)).toBe('handled');
});
test('proposed results and tool headers do not release the same pending permission', () => {
const guard = createPlanCountPermissionGuard();
let visible = permission();
expect(guard(visible)).toBe('grant');
for (const line of ['320 +⎿ Wrote320lines', '●Write(/tmp/fixture/plan.md)', '⎿ Tip: use /btw', '⎿ Error: denied']) {
visible += '\n' + line + '\n' + permission();
expect(guard(visible)).toBe('handled');
}
});
test('a different file and a genuine native file-policy question retain their own input', () => {
const guard = createPlanCountPermissionGuard();
const first = permission('first.md');
expect(guard(first)).toBe('grant');
expect(guard(first + '\n' + permission('FIRST.md'))).toBe('grant'); // Targets remain case-sensitive.
expect(guard(first + '\n' + permission('second.md'))).toBe('grant');
const question = '\n☐ File policy\nDo you want to create first.md?\n❯1.Yes\n2.No\n' +
'Enter to select · ↑/↓ to navigate · Esc to cancel';
expect(guard(first + question)).toBeNull();
expect(capturePlanCountQuestion(first + question, new Set(), 0, true)?.promptSnippet).toContain('File policy');
});
});
+1 -1
View File
@@ -154,7 +154,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
// the selector sets GSTACK_CARVE_SKILL=<name> to scope cost to the changed
// skill (D-CODEX A). Touching the registry/helper or sections.ts runs all.
'carve-section-loading': ['design-html/**', 'design-shotgun/**', 'qa/**', 'browse/**', 'retro/**', 'autoplan/**', 'spec/**', 'setup-gbrain/**', 'review/**', 'codex/**', 'land-and-deploy/**', 'plan-eng-review/**', 'plan-design-review/**', 'plan-devex-review/**', 'office-hours/**', 'document-release/**', 'design-consultation/**', 'cso/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'test/session-runner-tools.test.ts', 'test/carve-section-loading.test.ts'],
'autoplan-chain-pty': ['autoplan/**', 'plan-ceo-review/**', 'plan-design-review/**', 'plan-eng-review/**', 'plan-devex-review/**', 'test/fixtures/plans/ui-heavy-feature.md', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/autoplan-setup-question.ts', 'test/autoplan-setup-question.test.ts', 'test/skill-e2e-autoplan-chain.test.ts'],
'autoplan-chain-pty': ['autoplan/**', 'plan-ceo-review/**', 'plan-design-review/**', 'plan-eng-review/**', 'plan-devex-review/**', 'test/fixtures/plans/ui-heavy-feature.md', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/autoplan-setup-question.ts', 'test/autoplan-setup-question.test.ts', 'test/helpers/autoplan-phase-observer.ts', 'test/autoplan-phase-observer.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/skill-e2e-autoplan-chain.test.ts'],
// Per-finding AskUserQuestion count + review-report-at-bottom assertion.
// Each test drives its skill end-to-end; touchfiles include preamble +
+49 -1
View File
@@ -168,6 +168,7 @@ try {
{ name: 'design-direct', skillName: 'plan-design-review', prompt: PROMPT, mode: 'direct-finding' },
{ name: 'design-batched', skillName: 'plan-design-review', prompt: PROMPT, mode: 'batched-finding' },
{ name: 'failed-native', skillName: 'plan-design-review', prompt: PROMPT, mode: 'failed-call' },
{ name: 'permission-lifecycle', skillName: 'plan-design-review', prompt: PROMPT, mode: 'permission-lifecycle' },
{ name: 'permission', skillName: 'plan-design-review', prompt: PROMPT, mode: 'permission' },
{ name: 'missing-transcript', skillName: 'plan-design-review', prompt: PROMPT, mode: 'missing-transcript' },
{ name: 'ceo', skillName: 'plan-ceo-review', prompt: '# Independent CEO plan\nUnique product context.', mode: 'complete' },
@@ -245,6 +246,10 @@ let firstInput = true;
let completion;
let question = 0;
let selected = '';
let permissionStage = 'first';
const filePermission = () => '\nDo you want to make this edit to gstack-test-plan-design.md?\n' +
'❯1.Yes\n2.Yes,andswitchtoacceptedits(auto-approvefileeditsandcommonfilecommands)forthissession;Yes,and\n' +
'alwaysallowaccessto/tmp/fixtureforthissession\n3.No\nEsctocancel·Tabtoamend\n';
process.stdin.on('data', (data) => {
const input = data.toString('utf8');
record({ type: 'input', data: input });
@@ -313,6 +318,35 @@ process.stdin.on('data', (data) => {
}
return;
}
if (process.env.FIXTURE_MODE === 'permission-lifecycle' && input.includes('\r')) {
if (permissionStage === 'first') {
record({ type: 'permission-grant', number: 1, input });
permissionStage = 'pending';
process.stdout.write(filePermission()); // Same pending menu, appended redraw.
completion = setTimeout(() => {
permissionStage = 'completed';
process.stdout.write('\n⎿ Wrote320linesto../fixture/gstack-test-plan-design.md\n' + '·'.repeat(1600));
completion = setTimeout(() => {
permissionStage = 'second';
process.stdout.write(filePermission()); // New request, identical file/text.
}, 4500);
}, 4500);
} else if (permissionStage === 'second') {
record({ type: 'permission-grant', number: 2, input });
permissionStage = 'question';
process.stdout.write('\n⎿ Added2lines\n');
ask([questionMetadata('File policy', 'Do you want to create gstack-test-plan-design.md?', ['Yes', 'No'])]);
process.stdout.write('\n☐ File policy\nDo you want to create gstack-test-plan-design.md?\n❯1.Yes\n2.No\n' +
'Enter to select · ↑/↓ to navigate · Esc to cancel\n');
} else if (permissionStage === 'question') {
record({ type: 'file-policy-answer', input });
native('user', [{ type: 'tool_result', tool_use_id: 'question-' + callId, content: 'Your question has been answered.' }],
{ toolUseResult: { answers: { [nativeQuestions[0].question]: input.startsWith('2') ? 'No' : 'Yes' } } });
permissionStage = 'done';
process.stdout.write('\nGSTACK REVIEW REPORT\n');
} else record({ type: 'unexpected-permission-input', stage: permissionStage, input });
return;
}
if (process.env.FIXTURE_MODE === 'permission' && input.includes('\r')) {
process.stdout.write('\nGSTACK REVIEW REPORT\n');
return;
@@ -381,6 +415,10 @@ process.stdin.on('data', (data) => {
process.stdout.write('\r☐ Setup\rWhich fixture setup should be used?\r❯1.First setup\r2.Second setup\r');
return;
}
if (process.env.FIXTURE_MODE === 'permission-lifecycle') {
process.stdout.write(filePermission());
return;
}
if (process.env.FIXTURE_MODE === 'permission') {
process.stdout.write('\rDo you want to create PLAN.md?\r❯1.Yes\r2.Yes, and switch to accept edits\r3.No\r');
return;
@@ -424,7 +462,7 @@ const results = await Promise.all(cases.map(async (item) => ({
pickAUQ: ['late-mode', 'batched-mode'].includes(item.mode) ? devexReviewModePick
: item.custom ? fp => fp.promptSnippet.includes('routing-proof-after-240') ? 1 : null : undefined,
reviewCountCeiling: 8,
timeoutMs: 15000,
timeoutMs: item.mode === 'permission-lifecycle' ? 22000 : 15000,
firstAUQPick: () => ['late-mode', 'batched-mode'].includes(item.mode) ? 1 : 2,
env: {
FIXTURE_RECORD: item.record, FIXTURE_SKILL: item.skillName, FIXTURE_MODE: item.mode,
@@ -487,6 +525,7 @@ await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify({ results, onboard
expect(startup.sections).toBe(fs.readFileSync(path.join(ROOT, item.skillName, 'sections/review-sections.md'), 'utf8'));
expect(events.filter((event) => event.type === 'input').map((event) => event.data).join(''))
.toBe(`/${item.skillName}\r` + (item.mode === 'prerequisite' ? `${item.custom ? 1 : 2}\r${item.skipIndex}\r`
: item.mode === 'permission-lifecycle' ? '1\r1\r2\r'
: item.mode === 'damaged-submit' ? '\x1b[Z2\r\r'
: item.mode === 'batched-finding' ? '2\r1\r' : item.mode === 'batched-mode' ? '1\r'
: ['direct-finding', 'failed-call', 'permission', 'late-mode', 'damaged-menu'].includes(item.mode) ? '2\r' : ''));
@@ -520,6 +559,15 @@ await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify({ results, onboard
expect(result.observation.transcript.calls[0].failure).toContain('is_error');
expect(result.observation.transcript.calls[0].answered).toBe(false);
}
if (item.mode === 'permission-lifecycle') {
expect(events.filter(event => event.type === 'unexpected-permission-input')).toEqual([]);
expect(events.filter(event => event.type === 'permission-grant').map(event => event.input)).toEqual(['1\r', '1\r']);
expect(events.filter(event => event.type === 'file-policy-answer').map(event => event.input)).toEqual(['2\r']);
expect(result.observation.step0Count).toBe(1);
expect(result.observation.reviewCount).toBe(0);
expect(result.observation.transcript.calls).toHaveLength(1);
expect(result.observation.transcript.calls[0].answers['Do you want to create gstack-test-plan-design.md?']).toBe('No');
}
if (['permission', 'missing-transcript'].includes(item.mode)) {
expect(result.observation.reviewCount).toBe(0);
expect(result.observation.step0Count).toBe(0);
+38 -30
View File
@@ -11,14 +11,12 @@
* gate reviews the final amended plan)
*
* Why this exists: each individual phase has its own plan-mode smoke
* test. Nothing verifies the SEQUENCING — that phases don't run in
* parallel, that Phase 3 doesn't start before Phase 1 ends, that
* conditional phases (Design, DX) are skipped when their scope is absent.
* A regression where the autoplan template wires phases concurrently
* would not be caught by per-phase tests.
* test. This checks cross-phase completion order, including the conditional
* Design/DX phases when they run. Completion markers do not establish phase
* start times or prove that no work overlapped between phases.
*
* Approach: tee timestamps as each "**Phase N complete." marker first
* appears in the visible buffer. Assert observed ordering. Phase 2 is
* Approach: read standalone phase-completion announcements and their native
* assistant timestamps from the isolated transcript. Assert observed ordering. Phase 2 is
* optional — UI-heavy fixture should make it run; backend-only fixtures
* should make it skip.
*
@@ -41,17 +39,15 @@ import {
selectPtyNumberedOption,
} from './helpers/claude-pty-runner';
import { autoplanRoutingSetupInput } from './helpers/autoplan-setup-question';
import { autoplanPhaseCompletions, type AutoplanPhaseHit } from './helpers/autoplan-phase-observer';
import { readPlanCountTranscript, type PlanCountTranscript } from './helpers/plan-count-transcript';
import { createPlanCountSnapshotWriter } from './helpers/plan-count-artifacts';
const describeE2E = describeE2ETier('periodic');
const ROOT = path.resolve(import.meta.dir, '..');
const UI_FIXTURE = path.join(ROOT, 'test', 'fixtures', 'plans', 'ui-heavy-feature.md');
interface PhaseHit {
phase: number;
ts: number;
}
function diagnosticTail(text: string): string {
return stripVTControlCharacters(text)
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
@@ -60,7 +56,7 @@ function diagnosticTail(text: string): string {
describeE2E('/autoplan chain ordering (periodic)', () => {
test(
'phases run sequentially: Phase 1 (CEO) before Phase 3 (Eng), Phase 2 (Design) between when present',
'phase completions are ordered: Phase 1 (CEO) before Phase 3 (Eng), Phase 2 (Design) between when present',
async () => {
// UI-heavy fixture so Phase 2 runs.
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-autoplan-chain-'));
@@ -85,29 +81,47 @@ describeE2E('/autoplan chain ordering (periodic)', () => {
seedSkills: true,
});
const hits: PhaseHit[] = [];
let hits: AutoplanPhaseHit[] = [];
let transcript: PlanCountTranscript = { status: 'missing', calls: [], assistantMessages: [] };
let outcome: 'chain_complete' | 'plan_ready' | 'timeout' | 'exited' = 'timeout';
let evidence = '';
let fullSessionEvidence = '';
let exitCode: number | null = null;
let commandStartedAt = Date.now();
const saveSnapshot = createPlanCountSnapshotWriter();
let artifacts: { artifactDir?: string; artifactError?: string } = {};
const observe = () => {
transcript = session.hermeticConfigDir
? readPlanCountTranscript(session.hermeticConfigDir, tempDir)
: { status: 'error', calls: [], assistantMessages: [], error: 'No isolated autoplan transcript directory' };
hits = autoplanPhaseCompletions(transcript, commandStartedAt);
};
const capture = (state: string) => {
artifacts = saveSnapshot({
skillName: 'autoplan', cwd: tempDir, claudeConfigDir: session.hermeticConfigDir,
raw: session.rawOutput(), visible: session.visibleText(),
observation: { state, hits, native: transcript, exitCode: session.exitCode() },
});
};
try {
await Bun.sleep(8000);
const since = session.mark();
commandStartedAt = Date.now();
session.send('/autoplan\r');
const budgetMs = 900_000; // 15 min
const start = Date.now();
// Phase markers live in autoplan's carved phase sections
// (autoplan/sections/{ceo,design,eng,dx}-phase.md — the skeleton
// STOP-Reads each one at its phase boundary):
// "**Phase 1 complete." / "**Phase 2 complete." / "**Phase 2.5 complete." / "**Phase 3 complete."
const phasePattern = /\*\*Phase\s+(\d+(?:\.\d+)?)\s+complete\.?\*\*/g;
let lastPermSig = '';
let lastCheckpointAt = start;
const seenSetupQuestions = new Set<string>();
while (Date.now() - start < budgetMs) {
await Bun.sleep(5000);
observe();
if (Date.now() - lastCheckpointAt >= 30_000) {
capture('in_progress');
lastCheckpointAt = Date.now();
}
if (session.exited()) {
outcome = 'exited';
evidence = session.visibleSince(since).slice(-3000);
@@ -140,16 +154,6 @@ describeE2E('/autoplan chain ordering (periodic)', () => {
continue;
}
// Re-scan for any phase markers we haven't yet recorded.
phasePattern.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = phasePattern.exec(visible)) !== null) {
const phaseNum = parseFloat(m[1] ?? '0');
if (Number.isNaN(phaseNum)) continue;
if (hits.some(h => h.phase === phaseNum)) continue;
hits.push({ phase: phaseNum, ts: Date.now() });
}
// Terminal: Phase 3 (Eng) seen — chain reached the required end.
if (hits.some(h => h.phase === 3)) {
outcome = 'chain_complete';
@@ -170,12 +174,15 @@ describeE2E('/autoplan chain ordering (periodic)', () => {
// status before close() deliberately terminates a live session.
exitCode = session.exitCode();
fullSessionEvidence = diagnosticTail(session.visibleText());
observe();
capture(outcome);
await session.close();
}
if (outcome === 'exited' || outcome === 'timeout') {
throw new Error(
`autoplan chain test FAILED: outcome=${outcome}, exitCode=${exitCode}, hits=${JSON.stringify(hits)}\n` +
`Native transcript: ${transcript.status}; artifacts=${JSON.stringify(artifacts)}\n` +
`--- post-command evidence (last 3KB) ---\n${diagnosticTail(evidence)}\n` +
`--- full-session visible tail, including startup (last 3KB) ---\n${fullSessionEvidence}`,
);
@@ -188,6 +195,7 @@ describeE2E('/autoplan chain ordering (periodic)', () => {
if (!ceo || !eng) {
throw new Error(
`Required phase markers missing. Saw: ${JSON.stringify(hits)}\n` +
`Native transcript: ${transcript.status}; artifacts=${JSON.stringify(artifacts)}\n` +
`--- evidence ---\n${evidence}`,
);
}
@@ -84,6 +84,10 @@ const planCeo5Findings = (planPath: string) => [
'HTTP 200, logs the event, and stops before user updates or email fan-out.',
'The ingress wrapper already logs event IDs, outcomes, and durations, with',
'alerts for failed webhook processing. Those controls remain in place.',
'DB lookup/update exceptions propagate to that ingress wrapper, which logs',
'the failure and returns HTTP 500 so Stripe retries the event. The existing',
'event-ID dedup guard records completion only after the database transaction',
'commits; failed or rolled-back database attempts remain retryable.',
'The deployment already has a handler feature flag and a documented, tested',
'rollback to the prior handler; this change uses that existing rollout path.',
'',
@@ -38,6 +38,17 @@ const designSystem = [
'Profile contains Display name (text) and Email (email). Notifications contains',
'Weekly digest and Product tips switches. Existing server defaults are the',
'account name/email, Weekly digest on, and Product tips off; labels/order stay fixed.',
'The page title “Account settings” is h1. Profile and Notifications are h2',
'headings that label their fieldsets via aria-labelledby; no heading level is skipped.',
'The existing DOM and visual order are:',
'```text',
'Persistent app navigation',
'main: Account settings (h1) + description',
' Save | Reset | Cancel | Export',
' InlineStatus',
' Profile (h2): Display name, Email',
' Notifications (h2): Weekly digest, Product tips',
'```',
'',
'Save is the only filled primary action (#1d4ed8 with white text). Reset, Cancel',
'and Export are neutral ghost buttons; destructive intent is explained in the',
@@ -53,6 +64,11 @@ const designSystem = [
'inside the disabled Save button, aria-busy=true, with reduced-motion support.',
'Success uses the persistent inline status “Saved” plus the save time (aria-live=polite).',
'The existing formatter renders “Saved at HH:mm” in the user’s local 24-hour time.',
'Editing a field away from its saved value changes that same persistent status',
'to “Unsaved changes” (role=status, aria-live=polite, aria-atomic=true); text',
'communicates the dirty state without relying on color or button enabled state.',
'Reverting all edits or confirming Reset restores “Saved at HH:mm” for the',
'last successful save. Failed saves retain “Unsaved changes” alongside the error.',
'Reset dialog: “Discard unsaved changes?” / “Your saved preferences will be restored.”',
'Its actions are “Keep editing” (default) and “Discard changes”. Cancel navigation',
'uses the same dialog with “Keep editing” (default) and “Discard and leave”.',
@@ -74,10 +90,27 @@ const planDesign5Findings = (planPath: string) => [
'sections, with visible headings and associated field labels. The page header',
'contains the title, a short description, and Save/Reset/Cancel/Export actions.',
'Preserve the approved single-column structure and component behavior.',
'The page title “Account settings” is h1. Profile and Notifications are h2',
'headings that label their fieldsets via aria-labelledby; no heading level is skipped.',
'The existing DOM and visual order are:',
'```text',
'Persistent app navigation',
'main: Account settings (h1) + description',
' Save | Reset | Cancel | Export',
' InlineStatus',
' Profile (h2): Display name, Email',
' Notifications (h2): Weekly digest, Product tips',
'```',
'',
'Journey: a user arrives from account navigation wanting to adjust preferences,',
'edits the labeled fields, saves, and reads the inline Saved timestamp before',
'leaving. The feedback preserves confidence that their preferences were stored.',
'The persistent InlineStatus has role=status, aria-live=polite, aria-atomic=true.',
'After success it reads “Saved at HH:mm” in the user’s local 24-hour time.',
'Editing away from a saved value changes its text to “Unsaved changes”, so',
'dirty state never relies on color or the Save button being enabled. Reverting',
'all edits or confirming Reset restores the last successful save timestamp.',
'Failed saves retain “Unsaved changes” alongside the error message.',
'Initial loading uses the existing form skeleton. A new account sees useful',
'default preferences as specified in DESIGN.md rather than an empty page. Read failures show Retry.',
'Save is atomic: all fields persist together or none do, so partial success is',
@@ -92,6 +125,8 @@ const planDesign5Findings = (planPath: string) => [
'below it, preserving DOM/tab order. The form fits 320px without horizontal scroll.',
'All controls have visible focus rings and 44px targets. Use semantic fieldsets,',
'labels, a main landmark and heading order; errors link through aria-describedby.',
'Focus-visible on every control and dialog action is the existing 2px solid',
'#1d4ed8 outline, offset 2px on white, with measured contrast above 3:1.',
'Dialogs trap focus and restore it on close; Escape cancels. Export remains a',
'clearly labeled button. Respect reduced motion. No additional visual exploration',
'or component replacement is part of this established form update.',
@@ -38,6 +38,9 @@ const planEng5Findings = (planPath: string) => [
'and policy version. It evicts expired tokens and invalidates entries on',
'logout, token revocation, or tenant suspension. AuthCache retains these',
'unchanged validity and tenant-key rules; they do not serialize mutations.',
'AuthCache is a service-facing facade over that same existing adapter,',
'with one backing cache. The adapter, its invalidation hooks, and their',
'existing tests remain in use unchanged.',
'Unit and integration coverage is planned for the new components and their',
'success/error paths. That coverage does not exercise legacyAuthFlow() or',
'assert compatibility with its prior behavior.',