mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-08 09:48:20 +01:00
fix: drive review navigation from confirmed native choices
This commit is contained in:
1 parent
d43f04fb05
commit
c1723f6b39
6 files changed
+295
-14
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user