fix: drive review navigation from confirmed native choices

This commit is contained in:
Garry Tan committed 2026-09-08 16:21:59 +00:00
1 parent d43f04fb05
commit c1723f6b39
6 files changed
+295 -14

No files matched your search

+49
View File
@@ -21,6 +21,28 @@ const CAPTURE = [
'Entertoselect·↑/↓tonavigate·Esctocancel',
].join('\r\r');
// Targeted-a stalled on this complete menu for the full test budget. Parsing
// retained its identity and choices; the setup helper rejected their wording.
const CURRENT_CAPTURE = [
' ☐ Routing rules',
'',
'Add gstack skill routing rules to CLAUDE.md? <gstack-qid:routing-injection>',
'',
'❯1.AddtoCLAUDE.md(recommended)',
'',
'Appendsa##SkillroutingsectiontoCLAUDE.mdandcommitsit.Futuresessionswillauto-invoketherightskill',
'(/investigateforbugs,/shipforPRs,/qafortesting,etc.)withoutmanualinvocation.',
'',
'2.Skip—invokemanually',
'',
"Nofilechanges.You'llcontinuecallingskillsbyname.Canaddroutingruleslater.",
'',
'3.Typesomething.',
'─'.repeat(120),
'4.Chataboutthis',
'Entertoselect·↑/↓tonavigate·Esctocancel',
].join('\n');
describe('autoplan routing setup handling', () => {
test('answers the captured setup once, using the full question identity', () => {
const seen = new Set<string>();
@@ -43,6 +65,33 @@ describe('autoplan routing setup handling', () => {
expect(autoplanRoutingSetupInput(fullLabels.replace("I'll invoke skills manually", 'delete the existing rules'), new Set())).toBeNull();
});
test('answers the current captured CLAUDE.md setup, including reordered choices, once', () => {
const seen = new Set<string>();
expect(autoplanRoutingSetupInput(CURRENT_CAPTURE, seen)).toBe('1\r');
expect(autoplanRoutingSetupInput(CURRENT_CAPTURE, seen)).toBeNull();
const reordered = CURRENT_CAPTURE.replace('❯1.AddtoCLAUDE.md(recommended)', '❯1.Skip—invokemanually')
.replace('2.Skip—invokemanually', '2.AddtoCLAUDE.md(recommended)');
expect(autoplanRoutingSetupInput(reordered, new Set())).toBe('2\r');
expect(autoplanRoutingSetupInput(CURRENT_CAPTURE.replace('to CLAUDE.md?', "to this project's CLAUDE.md?"), new Set())).toBe('1\r');
});
test('the current wording still requires both explicit setup choices and the CLAUDE.md target', () => {
for (const frame of [
CURRENT_CAPTURE.replace('to CLAUDE.md?', 'to the application API?'),
CURRENT_CAPTURE.replace('AddtoCLAUDE.md(recommended)', 'Acceptrecommendation'),
CURRENT_CAPTURE.replace('Skip—invokemanually', 'Deferthisfinding'),
CURRENT_CAPTURE.replace('AddtoCLAUDE.md(recommended)', 'Deletetheexistingroutingrules'),
CURRENT_CAPTURE.replace('Add gstack skill routing rules to CLAUDE.md?', 'Should we expand the current feature?'),
]) expect(autoplanRoutingSetupInput(frame, new Set())).toBeNull();
});
test('recognizes the native A retry packet with its abbreviated manual-decline label', () => {
const retry = CAPTURE.replace('Addroutingrules(Recommended)', 'Add to CLAUDE.md (Recommended)')
.replace('2.Nothanks', '2.No thanks, manual');
expect(autoplanRoutingSetupInput(retry, new Set())).toBe('1\r');
expect(autoplanRoutingSetupInput(retry.replace('No thanks, manual', 'No thanks, delete it'), new Set())).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();
+146 -1
View File
@@ -1,7 +1,12 @@
import { describe, expect, test } from 'bun:test';
import { findCeoModeOption, hasPostAnswerCeoPosture, nextCeoModeNavigation } from './helpers/ceo-mode-option';
import { findCeoModeOption, hasPostAnswerCeoPosture, hasNativePostAnswerCeoPosture, nativeCeoModeAnswer, nextCeoModeNavigation, nextCeoPostureContinuation } from './helpers/ceo-mode-option';
import { parseNumberedOptions, stripAnsi } from './helpers/claude-pty-runner';
import { E2E_TOUCHFILES, selectTests } from './helpers/touchfiles';
import type { PlanCountTranscript } from './helpers/plan-count-transcript';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { pathToFileURL } from 'node:url';
describe('CEO mode option matching', () => {
test('selects option 4 from the failed Claude Code 2.1.257 menu capture', () => {
@@ -153,3 +158,143 @@ describe('CEO posture evidence after mode selection', () => {
expect(hasPostAnswerCeoPosture(`${answer}\r● I will explore expansion opportunities that improve the saved-view workflow.`, expansion)).toBe(true);
});
});
describe('native CEO mode posture evidence', () => {
const selectedAt = Date.parse('2026-09-08T15:43:28.000Z');
const posture = /\b(rigor|bulletproof|hold\s*scope|maximum\s+rigor)\b/i;
function transcript(text: string, answer = 'HOLD SCOPE'): PlanCountTranscript {
// Actual question/options/answer shape from targeted-a's false-negative
// HOLD SCOPE run. The native answer selected option3 correctly.
const question = 'Which review mode should I use for this plan? <gstack-qid:plan-ceo-review-mode-selection>';
return { status: 'ready', calls: [{
sessionId: 'mode-session', toolUseId: 'mode-call', answered: true,
answeredAt: '2026-09-08T15:43:30.405Z', answers: { [question]: answer },
questions: [{ header: 'Review mode', question, options: [
{ label: 'SELECTIVE EXPANSION (Recommended)' }, { label: 'SCOPE EXPANSION' },
{ label: 'HOLD SCOPE' }, { label: 'SCOPE REDUCTION' },
] }],
}], assistantMessages: [{ sessionId: 'mode-session', timestamp: '2026-09-08T15:44:01.150Z', text }] };
}
test('recognizes the retained native answer followed by actual HOLD SCOPE analysis', () => {
const captured = 'HOLD SCOPE mode confirmed. Running Step 0D analysis, then reading the review sections file.\n\n**0D — HOLD SCOPE Analysis**\n\n**Complexity check:**\nThe plan introduces: 1 DB migration, 1 SavedView model, 1 CRUD API module (~4 endpoints), 1 view picker UI component, and integration into the existing filter UI.';
expect(hasNativePostAnswerCeoPosture(transcript(captured), 'HOLD SCOPE', posture, selectedAt)).toBe(true);
});
test('wrong, missing, failed, or earlier mode answers cannot establish target routing', () => {
const text = 'I will apply maximum rigor to the existing plan.';
expect(hasNativePostAnswerCeoPosture(transcript(text, 'SCOPE EXPANSION'), 'HOLD SCOPE', posture, selectedAt)).toBe(false);
for (const change of [{ answered: false }, { failed: true }, { answers: {} }, { answeredAt: undefined }]) {
const t = transcript(text); Object.assign(t.calls[0]!, change);
expect(hasNativePostAnswerCeoPosture(t, 'HOLD SCOPE', posture, selectedAt)).toBe(false);
}
expect(hasNativePostAnswerCeoPosture(transcript(text), 'HOLD SCOPE', posture, selectedAt + 10000)).toBe(false);
});
test('prior or foreign assistant prose, a menu, source quotation, and bare confirmation remain insufficient', () => {
for (const text of [
'', 'HOLD SCOPE', '**HOLD SCOPE mode confirmed.**',
'Which mode?\n1. SELECTIVE EXPANSION\n2. HOLD SCOPE\n3. SCOPE EXPANSION',
'```markdown\nReview with maximum rigor.\n```',
'> Review with maximum rigor.',
'Read(SKILL.md)\nReview with maximum rigor.',
]) expect(hasNativePostAnswerCeoPosture(transcript(text), 'HOLD SCOPE', posture, selectedAt)).toBe(false);
for (const change of [{ timestamp: '2026-09-08T15:43:29.000Z' }, { sessionId: 'other-session' }]) {
const t = transcript('I will apply maximum rigor.'); Object.assign(t.assistantMessages[0]!, change);
expect(hasNativePostAnswerCeoPosture(t, 'HOLD SCOPE', posture, selectedAt)).toBe(false);
}
expect(hasNativePostAnswerCeoPosture({ status: 'missing', calls: [], assistantMessages: [] }, 'HOLD SCOPE', posture, selectedAt)).toBe(false);
});
test('continuation requires the confirmed target and permits at most one fresh downstream question', () => {
const t = transcript('');
const fresh = '☐ Architecture\nD4 — Guard the member-scoped lookup?\n❯1.Add the guard\n2.Defer';
const mode = '☐ Review mode\nWhich review mode?\n❯1.HOLD SCOPE\n2.SCOPE EXPANSION';
const seen = new Set<string>();
expect(nextCeoPostureContinuation(mode, t, 'HOLD SCOPE', selectedAt, seen, false)).toBeNull();
expect(nextCeoPostureContinuation(fresh, transcript('', 'SCOPE EXPANSION'), 'HOLD SCOPE', selectedAt, seen, false)).toBeNull();
expect(nextCeoPostureContinuation(fresh, t, 'HOLD SCOPE', selectedAt, seen, false)).toBe('question');
expect(nextCeoPostureContinuation(fresh, t, 'HOLD SCOPE', selectedAt, seen, false)).toBeNull();
expect(nextCeoPostureContinuation(fresh.replace('member-scoped', 'project-scoped'), t, 'HOLD SCOPE', selectedAt, seen, true)).toBeNull();
expect(nativeCeoModeAnswer(t, 'HOLD SCOPE', selectedAt)?.toolUseId).toBe('mode-call');
const permission = 'DoyouwanttooverwriteCLAUDE.md?\n❯1.Yes\n2.No\nEsctocancel·Tabtoamend';
expect(nextCeoPostureContinuation(permission, t, 'HOLD SCOPE', selectedAt, seen, false)).toBe('permission');
expect(nextCeoPostureContinuation(permission, t, 'HOLD SCOPE', selectedAt, seen, false)).toBeNull();
});
test.skipIf(process.platform === 'win32')('one downstream answer releases delayed native prose without passing on the streamed menu', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ceo-posture-flush-'));
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';
const record = event => fs.appendFileSync(process.env.POSTURE_RECORD, JSON.stringify(event) + '\n');
record({type:'startup', pid:process.pid});
const sessionId = 'fake-mode-session';
const dir = path.join(process.env.CLAUDE_CONFIG_DIR, 'projects', 'fixture');
fs.mkdirSync(dir, {recursive:true});
const write = (role, content, extra = {}) => fs.appendFileSync(path.join(dir, sessionId + '.jsonl'), JSON.stringify({
sessionId, cwd:process.cwd(), isSidechain:false, timestamp:new Date().toISOString(), message:{role,content}, ...extra,
}) + '\n');
const question = 'Which mode?';
write('assistant', [{type:'tool_use', id:'mode', name:'AskUserQuestion', input:{questions:[{header:'Mode', question,
options:[{label:'HOLD SCOPE'},{label:'SCOPE EXPANSION'}]}]}}]);
write('user', [{type:'tool_result', tool_use_id:'mode', content:'Answered.'}], {toolUseResult:{answers:{[question]:'SCOPE EXPANSION'}}});
process.stdin.setRawMode?.(true);
let answered = false;
process.stdin.on('data', data => {
record({type:'input', data:data.toString()});
if (data.toString().includes('\r') && !answered) {
answered = true;
write('assistant', [{type:'text', text:'I will explore expansion opportunities that improve saved project views.'}]);
process.stdout.write('\nPOSTURE_FLUSHED\n');
}
});
process.stdout.write('POSTURE_READY\n● I will explore expansion opportunities.\n☐ Expansion 1\nD4 — Add shared project views?\n❯1.Add to scope\n2.Defer\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, selectPtyNumberedOption } from ${JSON.stringify(moduleUrl('claude-pty-runner.ts'))};
import { readPlanCountTranscript } from ${JSON.stringify(moduleUrl('plan-count-transcript.ts'))};
import { hasNativePostAnswerCeoPosture, nextCeoPostureContinuation } from ${JSON.stringify(moduleUrl('ceo-mode-option.ts'))};
const started = Date.now();
const session = await launchClaudePty({cwd:${JSON.stringify(dir)}, timeoutMs:5000, env:{POSTURE_RECORD:${JSON.stringify(recordFile)}}});
try {
await session.waitFor('POSTURE_READY', {timeoutMs:2000, pollMs:20});
const read = () => readPlanCountTranscript(session.hermeticConfigDir, ${JSON.stringify(dir)});
const posture = /\\b(expansion|10x|delight|dream|cathedral|opt[\\s-]?in)\\b/i;
const before = hasNativePostAnswerCeoPosture(read(), 'SCOPE EXPANSION', posture, started);
const action = nextCeoPostureContinuation(session.visibleText(), read(), 'SCOPE EXPANSION', started, new Set(), false);
if (action === 'question') await selectPtyNumberedOption(session, 1);
await session.waitFor('POSTURE_FLUSHED', {timeoutMs:2000, pollMs:20});
const after = hasNativePostAnswerCeoPosture(read(), 'SCOPE EXPANSION', posture, started);
await Bun.write(${JSON.stringify(resultFile)}, JSON.stringify({before, action, after}));
} 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'), 8000);
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);
expect(JSON.parse(fs.readFileSync(resultFile, 'utf8'))).toEqual({before:false, action:'question', after:true});
const events = fs.readFileSync(recordFile, 'utf8').trim().split('\n').map(line => JSON.parse(line));
expect(events.filter(e => e.type === 'input').map(e => e.data).join('')).toBe('1\r');
expect(() => process.kill(events[0].pid, 0)).toThrow();
} finally {
clearTimeout(timer); child.kill('SIGKILL');
if (fs.existsSync(recordFile)) {
const first = JSON.parse(fs.readFileSync(recordFile, 'utf8').split('\n')[0]!);
try { process.kill(first.pid, 'SIGKILL'); } catch { /* already reaped */ }
}
fs.rmSync(dir, {recursive:true, force:true});
}
}, 10_000);
});
+8 -5
View File
@@ -5,18 +5,21 @@ export function autoplanRoutingSetupInput(visible: string, seen: Set<string>): s
const question = capturePlanCountQuestion(visible, seen, 0, true);
if (!question) return null;
// The live capture lost characters from its qid, so require the full setup
// question and both specific choices instead of guessing from a qid fragment.
// 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)) {
if (!/gstackworksbestwhenyourproject['’]sCLAUDE\.mdincludesskillroutingrules\.Addthemnow\?/i.test(prompt) &&
!/Addgstackskillroutingrulesto(?:thisproject['’]s)?CLAUDE\.md\?/i.test(prompt)) {
return null;
}
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 => /^Addroutingrules(?:toCLAUDE\.md)?(?:\(Recommended\))?$/i.test(option.title));
const decline = options.find(option => /^Nothanks(?:,I['’]llinvokeskillsmanually)?(?:\(Recommended\))?$/i.test(option.title));
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`;
}
+72 -4
View File
@@ -6,9 +6,15 @@ import {
planCountSubmissionInput,
type AskUserQuestionFingerprint,
} from './claude-pty-runner';
import type { NativePlanQuestionCall, PlanCountTranscript } from './plan-count-transcript';
type CeoMode = 'HOLD SCOPE' | 'SCOPE EXPANSION' | 'SELECTIVE EXPANSION' | 'SCOPE REDUCTION';
function modeTitle(label: string): string | undefined {
const title = label.split(/[│┌\r\n]/, 1)[0]!.replace(/\s+/g, '').toUpperCase();
return /^(HOLDSCOPE|SCOPEEXPANSION|SELECTIVEEXPANSION|SCOPEREDUCTION)(?:$|[^A-Z])/.exec(title)?.[1];
}
export function findCeoModeOption(
options: ReadonlyArray<{ index: number; label: string }>,
targetMode: CeoMode,
@@ -16,10 +22,7 @@ export function findCeoModeOption(
const modes = options.map(option => {
// The CLI renders a description pane beside the options. Its text may
// mention a different mode, so match only the leading option title.
const title = option.label.split(/[│┌\r\n]/, 1)[0]!
.replace(/\s+/g, '').toUpperCase();
const mode = /^(HOLDSCOPE|SCOPEEXPANSION|SELECTIVEEXPANSION|SCOPEREDUCTION)(?:$|[^A-Z])/.exec(title)?.[1];
return { index: option.index, mode };
return { index: option.index, mode: modeTitle(option.label) };
});
if (!modes.some(option => option.mode)) return null;
@@ -89,3 +92,68 @@ export function hasPostAnswerCeoPosture(visible: string, posture: RegExp): boole
}
return matches();
}
/** A native successful answer, not the key we intended to send to the menu. */
export function nativeCeoModeAnswer(
transcript: PlanCountTranscript,
targetMode: CeoMode,
selectionStartedAt: number,
): NativePlanQuestionCall | null {
if (transcript.status !== 'ready') return null;
const choices = transcript.calls.flatMap(call => {
const at = Date.parse(call.answeredAt ?? '');
if (!call.answered || call.failed || !Number.isFinite(at) || at < selectionStartedAt) return [];
return call.questions.flatMap(question => {
const modes = new Set(question.options.map(option => modeTitle(option.label)).filter(Boolean));
const answer = call.answers?.[question.question];
return modes.size >= 2 && typeof answer === 'string'
? [{ call, at, mode: modeTitle(answer) }] : [];
});
}).sort((a, b) => b.at - a.at);
const latest = choices[0];
return latest?.mode === targetMode.replace(/\s+/g, '') ? latest.call : null;
}
/** Match only finalized assistant prose after the actual mode answer. */
export function hasNativePostAnswerCeoPosture(
transcript: PlanCountTranscript,
targetMode: CeoMode,
posture: RegExp,
selectionStartedAt: number,
): boolean {
const selected = nativeCeoModeAnswer(transcript, targetMode, selectionStartedAt);
if (!selected) return false;
const answeredAt = Date.parse(selected.answeredAt!);
return transcript.assistantMessages.some(message => {
if (message.sessionId !== selected.sessionId || Date.parse(message.timestamp) <= answeredAt) return false;
const prose = message.text.replace(/```[\s\S]*?```/g, '').split('\n').filter(line => {
if (/^\s*>/.test(line)) return false;
const plain = line.replace(/[*_`]/g, '').trim().replace(/^#+\s*/, '');
// A repeated menu or bare confirmation is still only an answer echo.
if (/^(?:[-+]|\d+[.)]|[A-D][.)])\s*(?:HOLD SCOPE|SCOPE EXPANSION|SELECTIVE EXPANSION|SCOPE REDUCTION)\b/i.test(plain)) return false;
return !/^(?:(?:You\s+)?selected(?:\s+(?:option|mode))?\s*[::]?\s*)?(?:HOLD SCOPE|SCOPE EXPANSION|SELECTIVE EXPANSION|SCOPE REDUCTION)(?:\s+mode)?(?:\s+confirmed)?(?:\s*\(recommended\))?[.!]?$/i.test(plain);
}).join('\n');
return hasPostAnswerCeoPosture(`● ${prose}`, posture);
});
}
/**
* Claude can defer persisting assistant prose until the next AUQ resolves.
* Permit one fresh downstream question to finish that turn, after the native
* mode answer is confirmed. A mode redraw or permission is never that question.
*/
export function nextCeoPostureContinuation(
visible: string,
transcript: PlanCountTranscript,
targetMode: CeoMode,
selectionStartedAt: number,
seenQuestions: Set<string>,
alreadyContinued: boolean,
): 'permission' | 'question' | null {
if (classifyPlanCountFrame(visible) === 'permission') {
return capturePlanCountQuestion(visible, seenQuestions, 0, true) ? 'permission' : null;
}
if (alreadyContinued || !nativeCeoModeAnswer(transcript, targetMode, selectionStartedAt)) return null;
const action = nextCeoModeNavigation(visible, targetMode, seenQuestions);
return action.kind === 'question' ? 'question' : null;
}
+1 -1
View File
@@ -139,7 +139,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
// Each one tests behavior the SDK harness can't observe (rendered TTY,
// numbered-option lists, multi-phase ordering, idempotency state echo).
'auq-format-gate': ['plan-ceo-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completeness-section.ts', 'scripts/resolvers/preamble.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'test/helpers/llm-judge.ts', 'test/skill-e2e-ask-user-question-format-compliance.test.ts'],
'plan-ceo-mode-routing': ['plan-ceo-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/ceo-mode-option.ts', 'test/ceo-mode-option.test.ts', 'test/pty-option-selection.test.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/skill-e2e-plan-ceo-mode-routing.test.ts'],
'plan-ceo-mode-routing': ['plan-ceo-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/ceo-mode-option.ts', 'test/ceo-mode-option.test.ts', 'test/pty-option-selection.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/skill-e2e-plan-ceo-mode-routing.test.ts'],
'plan-design-with-ui-scope': ['plan-design-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/skill-e2e-plan-design-with-ui.test.ts'],
'ship-idempotency-pty': ['ship/**', 'bin/gstack-next-version', 'bin/gstack-version-bump', 'scripts/resolvers/sections.ts', 'lib/worktree.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/skill-e2e-ship-idempotency.test.ts'],
'tpa-present': ['scripts/resolvers/third-party-actions.ts', 'ship/SKILL.md.tmpl', 'ship/sections/apple-release.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-third-party-actions.test.ts'],
+19 -3
View File
@@ -40,8 +40,9 @@ import {
selectPtyNumberedOption,
type ClaudePtySession,
} from './helpers/claude-pty-runner';
import { hasPostAnswerCeoPosture, nextCeoModeNavigation } from './helpers/ceo-mode-option';
import { hasNativePostAnswerCeoPosture, nextCeoModeNavigation, nextCeoPostureContinuation } from './helpers/ceo-mode-option';
import { createPlanCountFixture } from './helpers/plan-count-fixture';
import { readPlanCountTranscript, type PlanCountTranscript } from './helpers/plan-count-transcript';
const describeE2E = describeE2ETier('periodic');
@@ -163,6 +164,7 @@ describeE2E('/plan-ceo-review mode routing (gate)', () => {
// The digit redraws the menu before Enter confirms it. Start after
// confirmation so its mode names cannot count as assistant posture.
const selectionStartedAt = Date.now();
await selectPtyNumberedOption(session, modeIndex);
const sincePick = session.mark();
@@ -172,6 +174,9 @@ describeE2E('/plan-ceo-review mode routing (gate)', () => {
const start = Date.now();
let postureMatched = false;
let downstreamSnapshot = '';
let transcript: PlanCountTranscript = { status: 'missing', calls: [], assistantMessages: [] };
let continuedQuestion = false;
const seenDownstream = new Set<string>();
while (Date.now() - start < budgetMs) {
await Bun.sleep(2500);
if (session.exited()) {
@@ -181,17 +186,27 @@ describeE2E('/plan-ceo-review mode routing (gate)', () => {
);
}
downstreamSnapshot = session.visibleSince(sincePick);
if (hasPostAnswerCeoPosture(downstreamSnapshot, c.postureRe)) {
transcript = session.hermeticConfigDir
? readPlanCountTranscript(session.hermeticConfigDir, fixture.cwd)
: { status: 'error', calls: [], assistantMessages: [], error: 'No isolated mode transcript directory' };
if (hasNativePostAnswerCeoPosture(transcript, c.mode, c.postureRe, selectionStartedAt)) {
postureMatched = true;
break;
}
const continuation = nextCeoPostureContinuation(downstreamSnapshot, transcript,
c.mode, selectionStartedAt, seenDownstream, continuedQuestion);
if (continuation !== null) {
if (continuation === 'question') continuedQuestion = true;
await selectPtyNumberedOption(session, 1);
continue;
}
// Don't bail early on plan_ready alone — the posture text may
// arrive as the agent finishes writing the plan. Only break
// once we either match posture or run the clock.
if (
isPlanReadyVisible(downstreamSnapshot) &&
isNumberedOptionListVisible(downstreamSnapshot) &&
!hasPostAnswerCeoPosture(downstreamSnapshot, c.postureRe)
!hasNativePostAnswerCeoPosture(transcript, c.mode, c.postureRe, selectionStartedAt)
) {
// Plan-ready AND a follow-up AskUserQuestion are both visible but
// posture text has not appeared yet. Keep polling for a bit.
@@ -200,6 +215,7 @@ describeE2E('/plan-ceo-review mode routing (gate)', () => {
if (!postureMatched) {
throw new Error(
`Mode "${c.mode}" routing FAILED after sending option ${modeIndex}: no posture match for ${c.postureRe.source}.\n` +
`Native transcript: ${transcript.status}; ${transcript.calls.length} calls, ${transcript.assistantMessages.length} assistant messages; continuedQuestion=${continuedQuestion}.\n` +
`--- observed mode menu (last 3KB) ---\n${visibleAtMode.slice(-3000)}\n` +
`--- downstream visible since mode pick (last 3KB) ---\n` +
downstreamSnapshot.slice(-3000),