From c1723f6b3909b21d4b43101eb04f34c63a066b4e Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 8 Sep 2026 16:21:59 +0000 Subject: [PATCH] fix: drive review navigation from confirmed native choices --- test/autoplan-setup-question.test.ts | 49 +++++++ test/ceo-mode-option.test.ts | 147 ++++++++++++++++++- test/helpers/autoplan-setup-question.ts | 13 +- test/helpers/ceo-mode-option.ts | 76 +++++++++- test/helpers/touchfiles-data.ts | 2 +- test/skill-e2e-plan-ceo-mode-routing.test.ts | 22 ++- 6 files changed, 295 insertions(+), 14 deletions(-) diff --git a/test/autoplan-setup-question.test.ts b/test/autoplan-setup-question.test.ts index 55bb1c32c..9c0ade550 100644 --- a/test/autoplan-setup-question.test.ts +++ b/test/autoplan-setup-question.test.ts @@ -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? ', + '', + '❯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(); @@ -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(); + 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(); diff --git a/test/ceo-mode-option.test.ts b/test/ceo-mode-option.test.ts index df9f3d69d..c728aa3a9 100644 --- a/test/ceo-mode-option.test.ts +++ b/test/ceo-mode-option.test.ts @@ -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? '; + 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(); + 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); +}); diff --git a/test/helpers/autoplan-setup-question.ts b/test/helpers/autoplan-setup-question.ts index 2edb7946b..3e17f0c50 100644 --- a/test/helpers/autoplan-setup-question.ts +++ b/test/helpers/autoplan-setup-question.ts @@ -5,18 +5,21 @@ export function autoplanRoutingSetupInput(visible: string, seen: Set): 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`; } diff --git a/test/helpers/ceo-mode-option.ts b/test/helpers/ceo-mode-option.ts index d7c4b6f90..86fd68529 100644 --- a/test/helpers/ceo-mode-option.ts +++ b/test/helpers/ceo-mode-option.ts @@ -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, + 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; +} diff --git a/test/helpers/touchfiles-data.ts b/test/helpers/touchfiles-data.ts index 39f658450..5611167ca 100644 --- a/test/helpers/touchfiles-data.ts +++ b/test/helpers/touchfiles-data.ts @@ -139,7 +139,7 @@ export const E2E_TOUCHFILES: Record = { // 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'], diff --git a/test/skill-e2e-plan-ceo-mode-routing.test.ts b/test/skill-e2e-plan-ceo-mode-routing.test.ts index d09b035ff..e4f447908 100644 --- a/test/skill-e2e-plan-ceo-mode-routing.test.ts +++ b/test/skill-e2e-plan-ceo-mode-routing.test.ts @@ -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(); 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),