test: stabilize native review counting and interactive navigation

Co-Authored-By: OpenAI Codex <noreply@openai.com>
This commit is contained in:
Garry TanandOpenAI Codex committed 2026-09-08 07:57:30 +00:00
1 parent 58a2dc860a
commit 123d43d4e9
16 files changed
+1071 -142

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, test } from 'bun:test';
import { autoplanRoutingSetupInput } from './helpers/autoplan-setup-question';
import { E2E_TOUCHFILES, selectTests } from './helpers/touchfiles';
// Sanitized terminal frame from the 2026-09-08 autoplan timeout. The qid is
// visibly incomplete; the prompt body and explicit choices remain intact.
const CAPTURE = [
'─'.repeat(120),
'Planning: /tmp/hermetic/.claude/plans/modular-bouncing-swing.md',
'─'.repeat(120),
' ☐ Routing rules',
"│ gstack works best when your project's CLAUDE.md includes skill routing rules. Add them now?",
'│<gstck-qid:routing-injectin>',
'❯1.Addroutingrules(Recommended)',
'CreatesCLAUDE.mdwithskillroutingrulessogstackknowswhentoinvoke/office-hours,/autoplan,/ship,/qa,',
"etc.automatically.We'lldothisafterthereview.",
'2.Nothanks',
"Skip—I'llinvokeskillsmanually.Youcanenablethislaterbyrunninggstack-configsetrouting_declinedfalse.",
'3.Typesomething.',
'4.Chataboutthis',
'Entertoselect·↑/↓tonavigate·Esctocancel',
].join('\r\r');
describe('autoplan routing setup handling', () => {
test('answers the captured setup once, using the full question identity', () => {
const seen = new Set<string>();
expect(autoplanRoutingSetupInput(CAPTURE, seen)).toBe('1\r');
expect(autoplanRoutingSetupInput(CAPTURE, seen)).toBeNull();
expect(autoplanRoutingSetupInput(CAPTURE.replace('works best', 'works best'), seen)).toBeNull();
});
test('chooses Add routing rules by label when option order changes', () => {
const reordered = CAPTURE.replace('❯1.Addroutingrules(Recommended)', '❯1.Nothanks')
.replace('2.Nothanks', '2.Add routing rules (Recommended)');
expect(autoplanRoutingSetupInput(reordered, new Set())).toBe('2\r');
});
test('accepts the full option labels captured from the subsequent live setup prompt', () => {
const fullLabels = CAPTURE.replace('Addroutingrules(Recommended)', 'Add routing rules to CLAUDE.md (Recommended)')
.replace('2.Nothanks', "2.No thanks, I'll invoke skills manually");
expect(autoplanRoutingSetupInput(fullLabels, new Set())).toBe('1\r');
expect(autoplanRoutingSetupInput(fullLabels.replace('CLAUDE.md (Recommended)', 'product routes (Recommended)'), new Set())).toBeNull();
expect(autoplanRoutingSetupInput(fullLabels.replace("I'll invoke skills manually", 'delete the existing rules'), 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();
expect(autoplanRoutingSetupInput('❯1.Addroutingrules(Recommended)\r2.Nothanks', new Set())).toBeNull();
});
test('never answers review or taste questions, even with a routing qid or the same choices', () => {
const prompts = [
'Which visual direction should this settings page use?',
'Should the payment handler bypass the existing dispatcher?',
'Add routing rules to the product API now? <gstack-qid:routing-injection>',
'The plan quotes CLAUDE.md skill routing rules. Should we change this feature?',
];
for (const prompt of prompts) {
const frame = `☐ Review decision\r${prompt}\r❯1.Addroutingrules(Recommended)\r2.Nothanks`;
expect(autoplanRoutingSetupInput(frame, new Set())).toBeNull();
}
});
test('setup helper and captured-frame changes select the autoplan eval only', () => {
for (const file of ['test/helpers/autoplan-setup-question.ts', 'test/autoplan-setup-question.test.ts']) {
expect(selectTests([file], E2E_TOUCHFILES).selected).toEqual(['autoplan-chain-pty']);
}
});
});
+87 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { findCeoModeOption } from './helpers/ceo-mode-option';
import { findCeoModeOption, hasPostAnswerCeoPosture, nextCeoModeNavigation } from './helpers/ceo-mode-option';
import { parseNumberedOptions, stripAnsi } from './helpers/claude-pty-runner';
import { E2E_TOUCHFILES, selectTests } from './helpers/touchfiles';
@@ -63,8 +63,93 @@ describe('CEO mode option matching', () => {
});
test('helper and regression changes select only the mode-routing paid eval', () => {
for (const file of ['test/helpers/ceo-mode-option.ts', 'test/ceo-mode-option.test.ts']) {
for (const file of ['test/helpers/ceo-mode-option.ts', 'test/ceo-mode-option.test.ts', 'test/pty-option-selection.test.ts']) {
expect(selectTests([file], E2E_TOUCHFILES).selected).toEqual(['plan-ceo-mode-routing']);
}
});
});
describe('CEO mode navigation replay', () => {
test('advances different setup questions with the same choices and ignores redraws', () => {
const seen = new Set<string>();
const first = '☐Routing\rEnable skill routing?\r❯1.Enable\r2.Skip';
const next = '☐Learnings\rEnable cross-project learnings?\r❯1.Enable\r2.Skip';
expect(nextCeoModeNavigation(first, 'HOLD SCOPE', seen).kind).toBe('question');
expect(nextCeoModeNavigation(first, 'HOLD SCOPE', seen).kind).toBe('wait');
expect(nextCeoModeNavigation(next, 'HOLD SCOPE', seen).kind).toBe('question');
expect(seen.size).toBe(2);
});
test('navigates the captured unanswered setup tab before submitting, without counting Submit', () => {
const seen = new Set<string>();
const partial = [
'← ☒ Skill routing ☐ Learnings scope ✔ Submit →',
'Review your answers',
'⚠You have not answered all questions',
'❯1.Submit aswers',
'2Cancel',
].join('\r\r');
expect(nextCeoModeNavigation(partial, 'HOLD SCOPE', seen)).toEqual({ kind: 'submission', input: '\x1b[Z' });
expect(seen.size).toBe(0);
const question = '← ☒ Skill routing ☐ Learnings scope ✔ Submit →\rEnable cross-project learnings?\r❯1.Enable\r2.Skip';
expect(nextCeoModeNavigation(`${partial}\r${question}`, 'HOLD SCOPE', seen).kind).toBe('question');
const answered = partial.replace('☐ Learnings scope', '☒ Learnings scope').replace('⚠You have not answered all questions', '');
expect(nextCeoModeNavigation(answered, 'HOLD SCOPE', seen)).toEqual({ kind: 'submission', input: '\r' });
expect(seen.size).toBe(1);
});
test('handles native permission controls before question parsing and dedup', () => {
const seen = new Set<string>();
const permission = 'DoyouwanttooverwriteCLAUDE.md?\r❯1.Yes\r2.No\rEsctocancel·Tabtoamend';
expect(nextCeoModeNavigation(permission, 'HOLD SCOPE', seen)).toEqual({ kind: 'permission', input: '1\r' });
expect(seen.size).toBe(0);
const actual = '☐ Approaches\rWhich storage strategy?\r❯1.Server\r2.Local';
expect(nextCeoModeNavigation(`${permission}\r${actual}`, 'HOLD SCOPE', seen).kind).toBe('question');
});
test('selects the intended mode from the observed menu, preserving its index', () => {
const frame = '☐ReviewMode\rWhat review posture should I use?\r❯1.SELECTIVEEXPANSION(recommended)\r2.HOLDSCOPE\r3.SCOPEEXPANSION\r4.SCOPEREDUCTION';
for (const [mode, index] of [['HOLD SCOPE', 2], ['SCOPE EXPANSION', 3]] as const) {
const action = nextCeoModeNavigation(frame, mode, new Set());
expect(action.kind).toBe('mode');
if (action.kind === 'mode') expect(action.index).toBe(index);
}
});
});
describe('CEO posture evidence after mode selection', () => {
const posture = /\b(rigor|bulletproof|hold\s*scope|maximum\s+rigor)\b/i;
const menu = [
'☐ Review mode',
'❯1.SELECTIVEEXPANSION(recommended)',
'2.HOLD SCOPE │ Code is written. Make it bulletproof.',
'3.SCOPE EXPANSION',
'4.SCOPE REDUCTION',
'Enter to select · ↑/↓ to navigate',
].join('\r');
test('menu redraw and native selected-option echo cannot satisfy the posture gate', () => {
expect(posture.test(menu)).toBe(true); // The old unscoped check passed here.
expect(hasPostAnswerCeoPosture(menu, posture)).toBe(false);
const answer = "⏺ User answered Claude's questions:\r⎿ · Review mode? → HOLD SCOPE";
expect(hasPostAnswerCeoPosture(`${menu}\r${answer}`, posture)).toBe(false);
expect(hasPostAnswerCeoPosture('❯ HOLD SCOPE\r● HOLD SCOPE', posture)).toBe(false);
expect(hasPostAnswerCeoPosture('● Selected option: HOLD SCOPE', posture)).toBe(false);
expect(hasPostAnswerCeoPosture('● HOLD SCOPE\r✶ Honking… (5s · ↓ 300 tokens)', posture)).toBe(false);
});
test('assistant output must itself contain the existing posture evidence', () => {
expect(hasPostAnswerCeoPosture(`${menu}\r● I will inspect the plan now.`, posture)).toBe(false);
expect(hasPostAnswerCeoPosture('⏺ Read(plan-ceo-review/SKILL.md)\r Review with maximum rigor.', posture)).toBe(false);
expect(hasPostAnswerCeoPosture('● high · /effort\rHOLD SCOPE', posture)).toBe(false);
expect(hasPostAnswerCeoPosture(`● I will inspect the plan now.\r${menu}`, posture)).toBe(false);
});
test('accepts new assistant posture after the answered-question echo, including wrapped prose', () => {
const answer = "⏺UseransweredClaude'squestions:\r⎿Reviewmode?→HOLDSCOPE";
expect(hasPostAnswerCeoPosture(`${answer}\r● HOLD SCOPE. I will review the existing scope for failure modes.`, posture)).toBe(true);
expect(hasPostAnswerCeoPosture(`${answer}\r⏺\rI will apply maximum rigor\rto the agreed scope.`, posture)).toBe(true);
const expansion = /\b(expansion|10x|delight|dream|cathedral|opt[\s-]?in)\b/i;
expect(hasPostAnswerCeoPosture(`${answer}\r● I will explore expansion opportunities that improve the saved-view workflow.`, expansion)).toBe(true);
});
});
+22
View File
@@ -0,0 +1,22 @@
import { capturePlanCountQuestion } from './claude-pty-runner';
/** Answer only gstack's routing setup prompt in the isolated autoplan fixture. */
export function autoplanRoutingSetupInput(visible: string, seen: Set<string>): string | null {
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.
const prompt = question.promptSnippet.replace(/\s+/g, '');
if (!/gstackworksbestwhenyourproject['’]sCLAUDE\.mdincludesskillroutingrules\.Addthemnow\?/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));
if (!add || !decline) return null;
return `${add.index}\r`;
}
+65
View File
@@ -1,4 +1,12 @@
/** Match CEO mode labels after the PTY capture strips cursor-spacing escapes. */
import {
capturePlanCountQuestion,
classifyPlanCountFrame,
isNumberedOptionListVisible,
planCountSubmissionInput,
type AskUserQuestionFingerprint,
} from './claude-pty-runner';
type CeoMode = 'HOLD SCOPE' | 'SCOPE EXPANSION' | 'SELECTIVE EXPANSION' | 'SCOPE REDUCTION';
export function findCeoModeOption(
@@ -24,3 +32,60 @@ export function findCeoModeOption(
}
return target.index;
}
type ModeNavigationAction =
| { kind: 'wait' }
| { kind: 'permission' | 'submission'; input: string }
| { kind: 'question'; question: AskUserQuestionFingerprint }
| { kind: 'mode'; index: number; question: AskUserQuestionFingerprint };
/** Handle native controls before deduping actual navigation questions. */
export function nextCeoModeNavigation(
visible: string,
targetMode: CeoMode,
seenQuestions: Set<string>,
): ModeNavigationAction {
const frame = classifyPlanCountFrame(visible);
if (frame === 'permission') return { kind: 'permission', input: '1\r' };
const submission = frame === null ? planCountSubmissionInput(visible) : null;
if (submission !== null) return { kind: 'submission', input: submission };
if (!isNumberedOptionListVisible(visible)) return { kind: 'wait' };
const question = capturePlanCountQuestion(visible, seenQuestions, 0, true);
if (!question) return { kind: 'wait' };
const index = findCeoModeOption(question.options, targetMode);
return index === null ? { kind: 'question', question } : { kind: 'mode', index, question };
}
/** Match new assistant prose, never the native mode menu or answer echo. */
export function hasPostAnswerCeoPosture(visible: string, posture: RegExp): boolean {
let assistant: string[] | null = null;
const matches = () => {
if (!assistant?.length) return false;
const compact = assistant.join('').replace(/\s+/g, '');
// Tool headings use the same bullet as assistant messages. Their output
// can quote the selected mode or the skill's posture instructions.
if (/^(?:UseransweredClaude['’]squestions|[A-Za-z][\w.:_-]*\(|(?:high|medium|low)·\/effort)/i.test(compact)) return false;
// A bare selected title gains no evidentiary value when the next terminal
// update appends a spinner or other chrome to the same captured block.
const prose = assistant.filter(line =>
!/^(?:(?:You)?selected(?:option|mode)?[::]?)?(?:HOLDSCOPE|SCOPEEXPANSION|SELECTIVEEXPANSION|SCOPEREDUCTION)(?:\(recommended\))?\.?$/i.test(line.replace(/\s+/g, '')) &&
!/^[✶✻✽✢·]/.test(line),
).join('\n');
return prose.search(posture) !== -1;
};
for (const line of visible.replace(/\r\n?/g, '\n').split('\n')) {
const text = line.trim();
const message = /^[●⏺]\s*(.*)$/.exec(text);
if (message) {
if (matches()) return true;
assistant = message[1] ? [message[1]] : [];
} else if (/^(?:[☐☒❯⎿│┌└─⏸]|←|\d+[.)]\s*|Enter\s*to\s*select)/i.test(text)) {
if (matches()) return true;
assistant = null;
} else if (assistant && text) {
assistant.push(text);
}
}
return matches();
}
+172 -65
View File
@@ -153,6 +153,19 @@ export interface ClaudePtySession {
close(): Promise<void>;
}
/** Let a numbered menu apply its selection before confirming it. */
export async function selectPtyNumberedOption(
session: Pick<ClaudePtySession, 'send'>,
index: number,
): Promise<void> {
if (!Number.isInteger(index) || index < 1 || index > 9) {
throw new RangeError(`Invalid numbered option: ${index}`);
}
session.send(String(index));
await Bun.sleep(500);
session.send('\r');
}
/** Detect a complete, recognized workspace-trust menu. */
export function isTrustDialogVisible(visible: string): boolean {
return trustDialogInput(visible) !== null;
@@ -288,6 +301,19 @@ export const TAIL_SCAN_BYTES = 1500;
* remain unconditional.
*/
export function isPermissionDialogVisible(visible: string): boolean {
// Cursor-positioning escapes supply spaces visually, but stripping those
// escapes leaves labels such as "alwaysallowaccessto" in captured frames.
const compact = visible.replace(/\s+/g, '');
if (/requestedpermissions?to|allowalledits|alwaysallowaccessto|Bashcommand.*requirespermission/i.test(compact)) {
return true;
}
// Native Write/Edit confirmation captured during the design-count eval.
// Require the native footer as well as the file question so an AUQ about
// whether the plan should overwrite a file remains a real skill question.
if (/Doyouwantto(?:overwrite|create|edit)\S+\?/i.test(compact) &&
/Esctocancel[·•]Tabtoamend/i.test(compact)) {
return true;
}
// Standalone signatures — high specificity, never appear in skill questions.
if (/requested\s+permissions?\s+to/i.test(visible)) return true;
// "Yes / Yes, allow all edits / No" shape — file-edit permission grants.
@@ -710,7 +736,7 @@ export function isScopeGateAutoSelectVisible(visible: string): boolean {
export function parseNumberedOptions(
visible: string,
): Array<{ index: number; label: string }> {
visible = stripPtyResidue(visible);
visible = stripPtyResidue(visible).replace(/\r+\n?/g, '\n');
const tail = visible.length > 4096 ? visible.slice(-4096) : visible;
// Split on lines, look for `❯ N.` or ` N.` patterns. Up to N=9.
// The `\s*` after `.` (not `\s+`) is required because stripAnsi removes
@@ -765,6 +791,14 @@ export function parseNumberedOptions(
const cursorSegment = cursorStart >= 0 ? cursorLine.slice(cursorStart) : cursorLine;
const tokenRe = /(?:^|[^0-9])([1-9])\.(?!\d)\s*/g;
const tokens: Array<{ idx: number; labelStart: number; matchStart: number }> = [];
// The known cursor anchor is unambiguously an option, even when its label
// starts with a digit (captured: "❯1.1retryattempt..."). Keep the decimal
// guard for number-like text inside the remaining labels.
const firstToken = /^[\s❯]*1\.\s*/.exec(cursorSegment);
if (firstToken) {
tokens.push({ idx: 1, labelStart: firstToken[0].length, matchStart: 0 });
tokenRe.lastIndex = firstToken[0].length;
}
for (let m = tokenRe.exec(cursorSegment); m !== null; m = tokenRe.exec(cursorSegment)) {
tokens.push({
idx: Number(m[1]),
@@ -1056,21 +1090,16 @@ export type Step0BoundaryPredicate = (
* the same options + empty prompt across two distinct questions collide.
*/
export function parseQuestionPrompt(visible: string): string {
// Tail-only — older prompts higher in the buffer are stale.
const tail = visible.length > 4096 ? visible.slice(-4096) : visible;
visible = visible.replace(/\r+\n?/g, '\n');
// Anchor context to the latest menu, not the moving end of output. After
// an answer, spinner/prose output can push just the prompt's beginning
// out of a trailing 4KB window while leaving its options visible. That
// shortened same prompt must not acquire a new finding fingerprint.
const cursor = [...visible.matchAll(/❯\s*1\./g)].at(-1);
if (!cursor) return '';
const tail = visible.slice(Math.max(0, cursor.index - 4096), cursor.index + cursor[0].length);
const lines = tail.split('\n');
// Find the latest line containing `❯<spaces>1.` (matching parseNumberedOptions —
// unanchored to handle the box-layout case where cursor is mid-line after
// divider + header + prompt text on the same logical line).
let cursorLineIdx = -1;
for (let i = lines.length - 1; i >= 0; i--) {
if (/❯\s*1\./.test(lines[i] ?? '')) {
cursorLineIdx = i;
break;
}
}
if (cursorLineIdx < 0) return '';
const cursorLineIdx = lines.length - 1;
// Box-layout case: prompt text may be ON the cursor line, BEFORE `❯1.`.
// Extract that prefix (after stripping leading box-drawing characters and
@@ -1080,11 +1109,19 @@ export function parseQuestionPrompt(visible: string): string {
let inlinePrompt = '';
const cursorPos = cursorLine.search(/❯\s*1\./);
if (cursorPos > 0) {
inlinePrompt = cursorLine
.slice(0, cursorPos)
const prefix = cursorLine.slice(0, cursorPos);
const boxStart = Math.max(prefix.lastIndexOf('☐'), prefix.lastIndexOf('□'));
inlinePrompt = prefix
.slice(boxStart >= 0 ? boxStart : 0)
// Strip box-drawing chars + dividers + leading checkbox sigil.
.replace(/^[─━┄┅┈┉─┌┐└┘├┤┬┴┼│┃☐□■\s]+/, '')
.trim();
// A complete inline AUQ starts at its checkbox/header. Earlier lines
// belong to the CLI's Planning chrome or prior output, not this prompt.
// Including them can consume all 240 chars before the question starts.
if (boxStart >= 0 && inlinePrompt.length > 0) {
return inlinePrompt.replace(/\s+/g, ' ').slice(0, 240);
}
}
// Walk up at most 6 lines collecting prompt text. Stop at:
@@ -1102,9 +1139,12 @@ export function parseQuestionPrompt(visible: string): string {
continue;
}
blankRun = 0;
if (/^[─━┄┅┈┉╌┌┐└┘├┤┬┴┼│┃]+$/.test(trimmed) ||
/^Plan+ing\s*:/i.test(trimmed)) break;
// Stop if we hit what looks like a previous numbered list.
if (/^[\s❯]*[1-9]\.\s+\S/.test(raw)) break;
promptLines.unshift(trimmed);
if (/[☐□]/.test(trimmed)) break;
}
const all = inlinePrompt.length > 0 ? [...promptLines, inlinePrompt] : promptLines;
@@ -1132,6 +1172,36 @@ export function auqFingerprint(
return (Bun as any).hash(normalized + '||' + sig).toString(16);
}
/** Capture each distinct question once, including questions sharing a menu. */
export function capturePlanCountQuestion(
visible: string,
seen: Set<string>,
observedAtMs: number,
preReview: boolean,
): AskUserQuestionFingerprint | null {
const options = parseNumberedOptions(visible);
if (options.length < 2) return null;
const promptSnippet = parseQuestionPrompt(visible);
if (promptSnippet === '') return null;
const signature = auqFingerprint(promptSnippet, options);
if (seen.has(signature)) return null;
seen.add(signature);
return { signature, promptSnippet, options, observedAtMs, preReview };
}
/** 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
// generic Skip, an outside-review offer, or a review finding keeps its
// existing answer policy. Collapsed whitespace occurs in captured PTYs.
if (!fp.preReview || !/\/office-hours/i.test(fp.promptSnippet) ||
!/(?:no\s*design\s*doc|produce\s*a\s*design\s*doc)/i.test(fp.promptSnippet)) return null;
const run = fp.options.filter(({ label }) => /^Run\s*\/office-hours\s*(?:now|first)/i.test(label));
const skip = fp.options.filter(({ label }) =>
/^Skip\s*[—–-]\s*(?:proceed\s*with\s*)?standard\s*review(?:\s*\(recommended\))?$/i.test(label));
return run.length === 1 && skip.length === 1 ? skip[0].index : null;
}
/**
* Detects when a plan-* skill has reached its Completion Summary / Review
* Report — a terminal signal complementary to plan-mode's "Ready to execute"
@@ -1143,7 +1213,48 @@ export function auqFingerprint(
* stop signal; this regex is the "we're done, go gracefully" hint.
*/
export const COMPLETION_SUMMARY_RE =
/(GSTACK REVIEW REPORT|## Completion [Ss]ummary|Status:\s*(clean|issues_open)|^VERDICT:)/m;
/^[\t ]*(?:[⏺●][\t ]*)?(?:#{1,6}[\t ]*)?(?:GSTACK[\t ]*REVIEW[\t ]*REPORT|Completion[\t ]*[Ss]ummary|Status:[\t ]*(?:clean|issues_open)|(?:\*\*)?VERDICT:)/m;
/** Classify a counting frame before treating numbered native dialogs as AUQs. */
export function classifyPlanCountFrame(
visible: string,
): 'permission' | 'completion_summary' | 'plan_ready' | null {
const report = [...visible.matchAll(new RegExp(COMPLETION_SUMMARY_RE.source, 'gm'))].at(-1);
const menuCursor = [...visible.matchAll(/❯\s*[1-9]\./g)].at(-1)?.index ?? -1;
const activeQuestionStart = Math.max(
visible.lastIndexOf('☐', menuCursor), visible.lastIndexOf('☒', menuCursor),
);
const permissionTail = visible.slice(Math.max(visible.length - TAIL_SCAN_BYTES, activeQuestionStart, 0));
if (isNumberedOptionListVisible(visible) &&
isPermissionDialogVisible(permissionTail) &&
(!report || report.index <= menuCursor)) {
return 'permission';
}
// Headings must be assistant output, not numbered Read/Write diff rows
// such as "409 +## GSTACK REVIEW REPORT". The anchored matcher also
// leaves a completed tool's diff in scrollback without ending the review.
// A later assistant report supersedes a dismissed permission menu still
// present in short scrollback; a pending menu below the report does not.
if (report && report.index > menuCursor) return 'completion_summary';
if (isPlanReadyVisible(visible)) return 'plan_ready';
return null;
}
/** Navigate native multi-question review without counting Submit as a finding. */
export function planCountSubmissionInput(visible: string): string | null {
const bars = [...visible.matchAll(/←[^\r\n]*[☐☒][^\r\n]*✔\s*Submit\s*→/g)];
const bar = bars.at(-1);
if (!bar) return null;
const panel = visible.slice(bar.index + bar[0].length).replace(/\s+/g, '');
const cursor = [...panel.matchAll(/❯1\./g)].at(-1);
if (!/Reviewyouranswers/i.test(panel) || !cursor ||
!/^❯1\.Submit/i.test(panel.slice(cursor.index))) return null;
const tabs = [...bar[0].matchAll(/[☐☒]/g)].map((match) => match[0]);
const unanswered = tabs.indexOf('☐');
// At the Submit tab, move back to the first unanswered question. Only
// submit after every question tab carries the native answered marker.
return unanswered >= 0 ? '\x1b[Z'.repeat(tabs.length - unanswered) : '\r';
}
/**
* Result of asserting that a plan file ends with `## GSTACK REVIEW REPORT`
@@ -1258,7 +1369,7 @@ export const ceoStep0Boundary: Step0BoundaryPredicate = (fp) =>
fp.options.some((o) => /skip\s+interview|plan\s+immediately/i.test(o.label));
export const engStep0Boundary: Step0BoundaryPredicate = (fp) =>
/scope reduction recommendation|cross[\s-]?project learnings/i.test(
/scope\s*reduction\s*recommendation|cross[\s-]*project\s*learnings/i.test(
fp.promptSnippet,
) ||
// plan-eng-review's Step 0 may legitimately end with NO scope-reduction /
@@ -1271,12 +1382,12 @@ export const engStep0Boundary: Step0BoundaryPredicate = (fp) =>
/gstack-qid:\s*(?:plan-)?eng-review-/i.test(fp.promptSnippet);
export const designStep0Boundary: Step0BoundaryPredicate = (fp) =>
/design system|design posture|design score|first dimension/i.test(
/design\s*(?:system|posture|score|completeness)|first\s*dimension/i.test(
fp.promptSnippet,
);
export const devexStep0Boundary: Step0BoundaryPredicate = (fp) =>
/developer persona|target persona|persona selection|TTHW target/i.test(
/developer\s*persona|target\s*persona|persona\s*selection|TTHW\s*target/i.test(
fp.promptSnippet,
);
@@ -1938,12 +2049,16 @@ export interface PlanSkillCountObservation {
}
/**
* Drive a plan-* skill in plan mode and count distinct review-phase
* AskUserQuestions until a terminal signal fires.
* Drive a plan-* skill in plan mode and count distinct native review-phase
* AskUserQuestions until a terminal signal fires. Each run disables the
* extra outside review in its own gstack config: independent reviewers can
* add valid findings unrelated to the seeded-N cadence band. These counts
* do not assert outside-review dispatch or its approval-question cadence.
*
* Flow:
* 1. Seed the complete fixture request in an isolated git repository's
* PLAN.md and initial CLAUDE.md context, then boot the PTY in that cwd
* PLAN.md and initial CLAUDE.md context, with owned native-only config,
* then boot the PTY in that cwd
* (8s grace + auto-trust dialog). Skills remain registered in user scope.
* 2. Send `slashCommand` alone. The fixture is already in context; sending
* it later can queue it behind the skill's first question while the
@@ -1956,7 +2071,9 @@ export interface PlanSkillCountObservation {
* the auqFingerprint contract).
* - First time we see a fingerprint: push it, classify as Step 0 or
* review-phase based on `boundaryFired`, press `defaultPick` to
* advance.
* advance. Decline only the recognized optional office-hours
* prerequisite by label so a different skill does not rewrite the
* seeded plan before this review begins.
* - After pressing, evaluate `isLastStep0AUQ(fingerprint)`. If true,
* all subsequent AUQs are review-phase.
* - Hard ceiling: if `reviewCount >= reviewCountCeiling`, return
@@ -1994,7 +2111,8 @@ export async function runPlanSkillCounting(opts: {
defaultPick?: number;
/**
* Optional override for the FIRST AUQ observed. Receives the fingerprint;
* returns the option index to press. Subsequent AUQs always use defaultPick.
* returns the option index to press. Subsequent review AUQs use defaultPick;
* only the recognized optional office-hours prerequisite is declined by label.
*
* Skill-specific routing helper: /plan-ceo-review's first AUQ asks "what
* scope?" with options like "branch diff" / "describe inline" / "skip
@@ -2015,14 +2133,14 @@ export async function runPlanSkillCounting(opts: {
const defaultPick = opts.defaultPick ?? 1;
const timeoutMs = opts.timeoutMs ?? 1_500_000;
const fixture = createPlanCountFixture(opts.followUpPrompt);
const fixture = createPlanCountFixture(opts.followUpPrompt, { nativeReviewOnly: true });
let session: ClaudePtySession;
try {
session = await launchClaudePty({
permissionMode: 'plan',
cwd: fixture.cwd,
timeoutMs: timeoutMs + 60_000,
env: opts.env,
env: { ...opts.env, ...fixture.env },
model: opts.model,
seedSkills: true,
});
@@ -2037,7 +2155,6 @@ export async function runPlanSkillCounting(opts: {
let step0Count = 0;
let reviewCount = 0;
let isFirstAUQ = true;
let lastSig = '';
function snapshot(
outcome: PlanSkillCountObservation['outcome'],
@@ -2079,6 +2196,7 @@ export async function runPlanSkillCounting(opts: {
visible,
);
}
if (visible.includes('Unknown command:')) {
return snapshot(
'exited',
@@ -2087,6 +2205,20 @@ export async function runPlanSkillCounting(opts: {
);
}
const frame = classifyPlanCountFrame(visible);
if (frame === 'permission') {
session.send(`${defaultPick}\r`);
await Bun.sleep(1500);
continue;
}
const submissionInput = frame === null ? planCountSubmissionInput(visible) : null;
if (submissionInput !== null) {
session.send(submissionInput);
await Bun.sleep(1500);
continue;
}
// Silent write detection — only fires if no numbered prompt is on
// screen (otherwise the write is gated by a permission/AUQ).
const writeRe = /⏺\s*(?:Write|Edit)\(([^)]+)\)/g;
@@ -2107,14 +2239,14 @@ export async function runPlanSkillCounting(opts: {
// Soft terminal signals — check before AUQ processing so a final
// completion-summary doesn't get misclassified as a bonus AUQ.
if (COMPLETION_SUMMARY_RE.test(visible)) {
if (frame === 'completion_summary') {
return snapshot(
'completion_summary',
`skill emitted completion summary / verdict / status line (step0=${step0Count}, review=${reviewCount})`,
visible,
);
}
if (isPlanReadyVisible(visible)) {
if (frame === 'plan_ready') {
return snapshot(
'plan_ready',
`skill emitted plan-mode "Ready to execute" confirmation (step0=${step0Count}, review=${reviewCount})`,
@@ -2125,46 +2257,21 @@ export async function runPlanSkillCounting(opts: {
// Numbered option list?
if (!isNumberedOptionListVisible(visible)) continue;
// Permission dialog? Auto-grant with defaultPick. Only act on the
// recent tail to avoid re-triggering on stale dialogs in scrollback.
if (isPermissionDialogVisible(visible.slice(-TAIL_SCAN_BYTES))) {
session.send(`${defaultPick}\r`);
await Bun.sleep(1500);
continue;
}
// Parse the active AUQ. Skip same-redraw and empty-prompt cases.
const options = parseNumberedOptions(visible);
if (options.length < 2) continue;
const sig = optionsSignature(options);
if (sig === lastSig) continue;
const promptSnippet = parseQuestionPrompt(visible);
if (promptSnippet === '') continue; // not yet rendered, poll again
lastSig = sig;
const fingerprintHash = auqFingerprint(promptSnippet, options);
if (seen.has(fingerprintHash)) {
// Same content, already counted (TTY redrew with whitespace diff).
continue;
}
seen.add(fingerprintHash);
const fp: AskUserQuestionFingerprint = {
signature: fingerprintHash,
promptSnippet,
options,
observedAtMs: Date.now() - startedAt,
preReview: !boundaryFired,
};
// Dedupe the complete question, not just its answer labels: separate
// findings often reuse the same Add to plan / Defer / Skip menu.
const fp = capturePlanCountQuestion(visible, seen, Date.now() - startedAt, !boundaryFired);
if (!fp) continue;
fingerprints.push(fp);
if (boundaryFired) reviewCount += 1;
else step0Count += 1;
// Press to advance — first AUQ may use the override pick.
const pickIdx =
isFirstAUQ && opts.firstAUQPick ? opts.firstAUQPick(fp) : defaultPick;
const prerequisitePick = planCountPrerequisitePick(fp);
const pickIdx = prerequisitePick ??
(isFirstAUQ && opts.firstAUQPick ? opts.firstAUQPick(fp) : defaultPick);
isFirstAUQ = false;
session.send(`${pickIdx}\r`);
if (prerequisitePick !== null) await selectPtyNumberedOption(session, pickIdx);
else session.send(`${pickIdx}\r`);
// Evaluate boundary AFTER pressing — if THIS AUQ was the last Step 0
// question, all subsequent AUQs go to reviewCount.
+339
View File
@@ -38,6 +38,10 @@ import {
parseQuestionPrompt,
auqFingerprint,
COMPLETION_SUMMARY_RE,
classifyPlanCountFrame,
capturePlanCountQuestion,
planCountPrerequisitePick,
planCountSubmissionInput,
assertReviewReportAtBottom,
ceoStep0Boundary,
engStep0Boundary,
@@ -98,6 +102,24 @@ describe('isPermissionDialogVisible', () => {
expect(isPermissionDialogVisible(sample)).toBe(true);
});
test('recognizes the captured collapsed native overwrite confirmation', () => {
const sample = [
'Doyouwanttooverwritegstack-test-plan-design.md?',
'❯1.Yes',
'2.Yes,andswitchtoacceptedits(auto-approvefileeditsandcommonfilecommands)forthissession',
'3.No',
'Esctocancel·Tabtoamend',
].join('\n');
expect(isPermissionDialogVisible(sample)).toBe(true);
expect(isPermissionDialogVisible(sample.replace('Esctocancel·Tabtoamend', 'Enter to select'))).toBe(false);
});
test('recognizes permission labels whose cursor-positioning spaces disappeared', () => {
expect(isPermissionDialogVisible('Yes,andalwaysallowaccessto/tmp/fixtureforthissession')).toBe(true);
expect(isPermissionDialogVisible('Yes,allowalleditsduringthissession')).toBe(true);
expect(isPermissionDialogVisible('Bashcommandrequirespermission')).toBe(true);
});
test('does NOT match a skill AskUserQuestion list', () => {
const sample = `
D1 — Premise challenge: do users actually want this?
@@ -921,6 +943,57 @@ describe('parseQuestionPrompt', () => {
expect(prompt).not.toContain('❯');
expect(prompt).not.toMatch(/^☐/);
});
test('keeps the captured design scope prompt ahead of long Planning chrome', () => {
// The first failed live attempt fingerprinted only the divider/Planning
// path. Its actual AUQ was later on the active cursor line.
const visible = [
'─'.repeat(120),
`Planning: /tmp/hermetic/.claude/plans/${'long-path-'.repeat(24)}plan.md`,
'─'.repeat(120),
"☐Reviewfocus I've rated this Settings Page UI redesign plan 2/10 on design completeness. Want me to focus on specific areas? ❯1.All7passes(Recommended)",
'2.All7passesbutskipmockups',
].join('\n');
const prompt = parseQuestionPrompt(visible);
expect(prompt).toStartWith('Reviewfocus');
expect(prompt).toContain('design completeness');
expect(prompt).not.toContain('Planning:');
expect(designStep0Boundary({
signature: 'captured-design-scope', promptSnippet: prompt,
options: parseNumberedOptions(visible), observedAtMs: 0, preReview: true,
})).toBe(true);
});
test('keeps the captured devex persona header when cursor spacing collapses', () => {
const visible = [
`Planning: /tmp/hermetic/.claude/plans/${'long-path-'.repeat(24)}plan.md`,
'─'.repeat(120),
'☐Targetpersona D2—WhoistheprimarydeveloperthisSDKtargets? ❯1.AIappbuilder/startupfounder(Recommended)',
'2.Backend/platformengineer',
].join('\n');
const prompt = parseQuestionPrompt(visible);
expect(prompt).toStartWith('Targetpersona');
expect(devexStep0Boundary({
signature: 'captured-devex-persona', promptSnippet: prompt,
options: parseNumberedOptions(visible), observedAtMs: 0, preReview: true,
})).toBe(true);
});
test('retains a multiline question while excluding the preceding CLI divider', () => {
const visible = [
'Planning: /tmp/hermetic/.claude/plans/plan.md',
'─'.repeat(120),
'☐ Review focus',
'This plan is 2/10 on design completeness.',
'Want me to focus on specific areas? ❯1.All 7 passes',
'2.Skip mockups',
].join('\n');
const prompt = parseQuestionPrompt(visible);
expect(prompt).toContain('Review focus');
expect(prompt).toContain('design completeness');
expect(prompt).toContain('specific areas?');
expect(prompt).not.toContain('Planning:');
});
});
describe('auqFingerprint', () => {
@@ -976,6 +1049,156 @@ describe('auqFingerprint', () => {
});
});
describe('capturePlanCountQuestion replay', () => {
test('keeps captured CEO/eng fingerprints stable as later output trims the trailing window', () => {
// Exact prompt/option fields from the 07:30 corrected paid attempts.
// Both counted an answered Step0 question again as a review finding
// once the moving tail omitted the beginning of its prompt.
const captures = [
{
prompt: '☐ RevewMode Which review mode should I use for the remaining sections?',
labels: [
'HOLD SCOPE — make it ┌┐',
'SELECTIVEEXPANSION—│Focus:catcheverylandmineinApproachA│',
'SCOPEREDUCTION—strip│Tests:whatmustbecovered│',
'SCOPEEXPANSION—think│Observability:whatlogs/metricsareneeded│',
],
},
{
prompt: '☐ Scope cut │ D2 — Scope reduction proposal: drop TokenStore and RequestPolicy as standalone classes, inject AuthCache rather than │ exportitglobally.Acceptthisreductionbeforethesection-by-sectionreviewbegins? │ <gstack-qid:plan-eng-review-',
labels: [
'Acceptscopereduction┌───────────────────────────────────────────────────┐',
'Proceedfullscopeas-is│AuthBroker│',
],
},
];
for (const capture of captures) {
const options = capture.labels.map((label, i) => `${i === 0 ? '❯' : ''}${i + 1}.${label}`).join('\n');
const frame = `${capture.prompt}\n${options}`;
const seen = new Set<string>();
const first = capturePlanCountQuestion(frame, seen, 0, true)!;
expect(first).not.toBeNull();
// Leave the original menu within the trailing4KB, but move the
// start of that window into its question text, twice in succession.
const paddingLength = 4096 - options.length - 30;
for (const extra of [0, 15]) {
const advanced = frame + '\n' + '·'.repeat(paddingLength + extra - 1);
expect(advanced.slice(-4096)).not.toContain(capture.prompt);
expect(parseNumberedOptions(advanced)).toEqual(first.options);
expect(parseQuestionPrompt(advanced)).toBe(first.promptSnippet);
expect(auqFingerprint(parseQuestionPrompt(advanced), parseNumberedOptions(advanced))).toBe(first.signature);
expect(capturePlanCountQuestion(advanced, seen, extra + 1, false)).toBeNull();
}
const next = `${frame}\n${'·'.repeat(paddingLength)}\n☐ Next decision Should the revised plan use these same choices?\n${options}`;
const distinct = capturePlanCountQuestion(next, seen, 20, false)!;
expect(distinct).not.toBeNull();
expect(distinct.signature).not.toBe(first.signature);
expect(distinct.preReview).toBe(false);
expect(seen.size).toBe(2);
}
});
test('counts consecutive findings with identical choices and ignores redraws', () => {
const options = '\n❯1.Add to plan\n2.Defer\n3.Skip';
const seen = new Set<string>();
const frames = [
`D5 — SQL: interpolate the request parameter?${options}`,
`D5 — SQL: interpolate the request parameter?${options}`,
`D6 — Tests: no coverage for the webhook?${options}`,
`D6 — Tests: no coverage for the webhook?${options}`,
];
const captured = frames.map((frame, i) => capturePlanCountQuestion(frame, seen, i, false));
expect(captured.map((question) => question !== null)).toEqual([true, false, true, false]);
expect(captured[0]?.signature).not.toBe(captured[2]?.signature);
expect(captured[2]?.promptSnippet).toContain('Tests: no coverage');
});
test('does not consume an incomplete frame before its prompt arrives', () => {
const seen = new Set<string>();
const options = '❯1.Add to plan\n2.Defer';
expect(capturePlanCountQuestion(options, seen, 0, true)).toBeNull();
expect(capturePlanCountQuestion(`D1 — Pick an approach\n${options}`, seen, 1, true)).not.toBeNull();
});
test('answers the captured CEO retry question with a numeric-leading first label', () => {
// The live timeout sat on this question because the first label begins
// with "1retryattempt"; it was incorrectly rejected as a decimal token.
const frame = [
' ☐ Retry spec',
"│ Section 5/6 finding: 'retry-with-backoff fires once, then fails clean' is ambiguous.",
"│ What does 'fires once' mean?",
'❯1.1retryattempt—Stripecalledexactly2timestotal(Recommended)',
'Themostnaturalreading:1originalattempt+1retry=2totalStripecalls.',
'2.Addaclarifyingcommenttotheplan—lettheimplementerdecide',
'3.Theretrymechanismhandlesit—justassertfailureisreturned',
'4.Typesomething.',
'5.Chataboutthis',
'Entertoselect·↑/↓tonavigate·Esctocancel',
].join('\r\r');
const question = capturePlanCountQuestion(frame, new Set(), 0, false);
expect(question?.options.map(({ index }) => index)).toEqual([1, 2, 3, 4, 5]);
expect(question?.options[0]?.label).toBe('1retryattempt—Stripecalledexactly2timestotal(Recommended)');
expect(question?.promptSnippet).toContain('Section 5/6 finding');
expect(question?.promptSnippet).not.toContain('Planning:');
});
test('still ignores decimal numbers inside option labels', () => {
const frame = 'Choose the retry delay\r❯1.1.5 seconds\r2.Wait 2.5 seconds\r3.No retry';
expect(parseNumberedOptions(frame)).toEqual([
{ index: 1, label: '1.5 seconds' },
{ index: 2, label: 'Wait 2.5 seconds' },
{ index: 3, label: 'No retry' },
]);
});
});
describe('planCountPrerequisitePick replay', () => {
test('declines captured office-hours prerequisite menus by label in either order', () => {
// Captured 2026-09-08 CEO/Devex prerequisite surfaces: the default index
// sometimes starts office-hours, changing the seeded review's input.
const captures = [
{
prompt: 'No design doc found for this branch. `/office-hours` produces a structured problem statement, premise challenge, and explored alternatives — it gives this review much sharper input. Run it now, or skip and proceed with standard review?',
labels: ['Skip — proceed with standard review (Recommended)', 'Run /office-hours first'],
},
{
prompt: 'D2 — No design doc found. Run /office-hours first? <gstack-qid:plan-ceo-prereq-office-hours>',
labels: ['Skip — standard review (recommended)', 'Run /office-hours now'],
},
{
prompt: 'D3 — Run /office-hours first to produce a design doc for sharper input?',
labels: ['Skip — proceed with standard review (recommended)', 'Run /office-hours now'],
},
];
for (const { prompt, labels } of captures) {
for (const reversed of [false, true]) {
for (const collapsed of [false, true]) {
const ordered = reversed ? [...labels].reverse() : labels;
const text = ['☐ Prerequisite', prompt, `❯1.${ordered[0]}`, `2.${ordered[1]}`, '3.Type something.', '4.Chat about this'].join('\r');
const frame = collapsed ? text.replace(/ /g, '') : text;
const fp = capturePlanCountQuestion(frame, new Set(), 0, true)!;
expect(fp).not.toBeNull();
expect(planCountPrerequisitePick(fp)).toBe(reversed ? 2 : 1);
expect(planCountPrerequisitePick({ ...fp, preReview: false })).toBeNull();
}
}
}
});
test('keeps existing answers for incomplete, unrelated, and ambiguous menus', () => {
const fp = capturePlanCountQuestion(
'☐ Prerequisite\rNo design doc found. Run /office-hours first?\r❯1.Run /office-hours now\r2.Skip — proceed with standard review',
new Set(), 0, true,
)!;
expect(planCountPrerequisitePick({ ...fp, promptSnippet: 'No design doc found.' })).toBeNull();
expect(planCountPrerequisitePick({ ...fp, promptSnippet: 'Should /office-hours skip the required SDK validation finding?' })).toBeNull();
expect(planCountPrerequisitePick({ ...fp, promptSnippet: 'Want a second opinion from /office-hours?' })).toBeNull();
expect(planCountPrerequisitePick({ ...fp, options: [{ index: 1, label: 'Run /office-hours now' }, { index: 2, label: 'Skip' }] })).toBeNull();
expect(planCountPrerequisitePick({ ...fp, options: [{ index: 1, label: 'Add to plan' }, fp.options[1]] })).toBeNull();
expect(planCountPrerequisitePick({ ...fp, options: [...fp.options, { index: 3, label: 'Skip — standard review' }] })).toBeNull();
});
});
describe('COMPLETION_SUMMARY_RE', () => {
test('matches GSTACK REVIEW REPORT heading', () => {
expect(COMPLETION_SUMMARY_RE.test('## GSTACK REVIEW REPORT')).toBe(true);
@@ -999,6 +1222,110 @@ describe('COMPLETION_SUMMARY_RE', () => {
// VERDICT must be at the start of a line to count.
expect(COMPLETION_SUMMARY_RE.test('the final verdict: undecided')).toBe(false);
});
test('does NOT treat source or proposed diff rows as assistant completion', () => {
for (const line of [
'409 +## GSTACK REVIEW REPORT',
'419 +**VERDICT:** Design Review complete — 8 decisions made.',
'+## GSTACK REVIEW REPORT',
'409→## GSTACK REVIEW REPORT',
'The plan must end with ## GSTACK REVIEW REPORT.',
]) expect(COMPLETION_SUMMARY_RE.test(line)).toBe(false);
});
});
describe('classifyPlanCountFrame replay', () => {
test('waits through proposed Write approval and tool output, then accepts the actual report', () => {
// Sanitized rows and native prompt from the failed design-count attempt.
const proposedDiff = [
'409 +## GSTACK REVIEW REPORT',
'416 +| Design Review | 1 | issues_open | score: 2/10 → 8/10, 8 decisions |',
'419 +**VERDICT:** Design Review complete — 8 decisions made.',
].join('\n');
const permission = [
'Doyouwanttooverwritegstack-test-plan-design.md?',
'❯1.Yes',
'2.Yes,andswitchtoacceptedits(auto-approvefileeditsandcommonfilecommands)forthissession;Yes,and',
'alwaysallowaccessto/tmp/fixtureforthissession',
'3.No',
'Esctocancel·Tabtoamend',
].join('\n');
const frames = [
`${proposedDiff}\n${permission}`,
`${proposedDiff}\n⏺ Updated gstack-test-plan-design.md`,
`${proposedDiff}\n⏺ ## GSTACK REVIEW REPORT\nDesign Review complete — 8 decisions made.`,
];
expect(frames.map(classifyPlanCountFrame)).toEqual(['permission', null, 'completion_summary']);
});
test('a pending native permission beats even an unnumbered report heading', () => {
const visible = '## GSTACK REVIEW REPORT\nDoyouwanttooverwriteplan.md?\n❯1.Yes\n2.No\nEsctocancel·Tabtoamend';
expect(classifyPlanCountFrame(visible)).toBe('permission');
});
test('a later report supersedes the granted menu still in short scrollback', () => {
const permission = 'Doyouwanttooverwriteplan.md?\n❯1.Yes\n2.No\nEsctocancel·Tabtoamend';
expect(classifyPlanCountFrame(permission)).toBe('permission');
expect(classifyPlanCountFrame(`${permission}\n● ## GSTACK REVIEW REPORT`)).toBe('completion_summary');
});
test('an active question after a prior report keeps the counter running', () => {
expect(classifyPlanCountFrame('## GSTACK REVIEW REPORT\nOne more choice\n❯1.Add to plan\n2.Defer')).toBeNull();
});
test('an active AUQ supersedes a granted permission menu in short scrollback', () => {
const permission = 'Doyouwanttooverwriteplan.md?\n❯1.Yes\n2.No\nEsctocancel·Tabtoamend';
const question = '☐ Error handling\nWhich failure path should we test?\n❯1.Timeout\n2.Refusal';
expect(classifyPlanCountFrame(`${permission}\n${question}`)).toBeNull();
});
test('preserves actual report variants and the native plan-ready terminal', () => {
for (const report of [
'## GSTACK REVIEW REPORT', '⏺##GSTACKREVIEWREPORT', '●GSTACKREVIEWREPORT',
'## Completion Summary', '● ## Completion Summary', 'Status: clean', 'Status: issues_open',
'VERDICT: CLEARED — Eng Review passed', '**VERDICT:** Design Review complete.',
]) expect(classifyPlanCountFrame(report)).toBe('completion_summary');
expect(classifyPlanCountFrame('Ready to execute the plan?\n❯1.Yes\n2.No, keep planning')).toBe('plan_ready');
});
});
describe('planCountSubmissionInput replay', () => {
const incomplete = [
'← ☒ Learnings scope ☐ Approach ✔ Submit →',
'Review your answers',
'⚠You have not answered all questions',
' │ ●D1 — Cross-project learnings: Enable searching learnings from your other local projects?',
'→Enable cross-project (Recommended)',
'Ready t submit your answers?',
'❯1.Submit aswers',
'2Cancel',
].join('\r\r');
test('returns to the unanswered tab, then submits only after both answers', () => {
expect(planCountSubmissionInput(incomplete)).toBe('\x1b[Z');
const nextQuestion = [
'← ☒ Learnings scope ☐ Approach ✔ Submit →',
'│ Which approach should this plan use?',
'❯1.Extend the existing dispatcher',
'2.Add a separate handler',
].join('\r\r');
expect(planCountSubmissionInput(`${incomplete}\r${nextQuestion}`)).toBeNull();
const question = capturePlanCountQuestion(nextQuestion, new Set(), 0, true);
expect(question?.promptSnippet).toContain('Which approach');
expect(question?.options).toHaveLength(2);
const answered = incomplete.replace('☐ Approach', '☒ Approach').replace('⚠You have not answered all questions', '');
expect(planCountSubmissionInput(answered)).toBe('\r');
});
test('navigates to the first unanswered tab when more than one remains', () => {
const frame = incomplete.replace('☒ Learnings scope ☐ Approach', '☐ Learnings scope ☐ Approach ☒ Mode');
expect(planCountSubmissionInput(frame)).toBe('\x1b[Z\x1b[Z\x1b[Z');
});
test('does not revisit a stale submit panel when a later single question is active', () => {
expect(planCountSubmissionInput(`${incomplete}\r☐ Retry spec\rRetry once?\r❯1.Yes\r2.No`)).toBeNull();
expect(planCountSubmissionInput('Ready to submit the plan?\n❯1.Submit\n2.Cancel')).toBeNull();
});
});
describe('assertReviewReportAtBottom', () => {
@@ -1140,6 +1467,18 @@ describe('Step0BoundaryPredicate per-skill', () => {
expect(engStep0Boundary(f)).toBe(true);
});
test('recognizes the captured cross-project gate after cursor spacing collapses', () => {
const frame = [
'☐Cross-project gstackcansearchlearningsfromyourotherprojectsonthismachinetofindpatternsthatmightapplytothisreview.Enablecross-projectlearnings?',
'❯1.Enablecross-projectlearnings(Recommended)',
'2.Keeplearningsproject-scopedonly',
].join('\r\r');
const question = capturePlanCountQuestion(frame, new Set(), 0, true)!;
expect(question).not.toBeNull();
expect(engStep0Boundary(question)).toBe(true);
expect(engStep0Boundary(fp('Scopereductionrecommendation:cuttoMVP?', ['Reduce', 'Proceed']))).toBe(true);
});
test('FIRES on scope reduction recommendation', () => {
const f = fp('Scope reduction recommendation: cut to MVP?', ['Reduce', 'Proceed', 'Modify']);
expect(engStep0Boundary(f)).toBe(true);
+37 -3
View File
@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { getHermeticDirs } from './hermetic-env';
/**
* Count evals review a seeded plan, never the checkout that supplies skills.
@@ -9,13 +10,46 @@ import { spawnSync } from 'node:child_process';
* bare slash command starts: a later message can remain queued behind the
* skill's first AskUserQuestion and leave it reviewing the live branch.
*/
export function createPlanCountFixture(prompt: string): {
export function createPlanCountFixture(prompt: string, opts: { nativeReviewOnly?: boolean } = {}): {
cwd: string;
env: Record<string, string>;
cleanup(): void;
} {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-plan-count-'));
const cleanup = () => fs.rmSync(cwd, { recursive: true, force: true });
let stateRoot: string | undefined;
const env: Record<string, string> = {};
const cleanup = () => {
try {
fs.rmSync(cwd, { recursive: true, force: true });
} finally {
if (stateRoot) fs.rmSync(stateRoot, { recursive: true, force: true });
}
};
try {
if (opts.nativeReviewOnly) {
// Seeded-N count bands measure the main review's finding cadence.
// An outside review can legitimately add findings beyond that band;
// these fixtures do not cover its separate approval-question cadence.
// Opt in only from runPlanSkillCounting: mode fixtures keep defaults.
const sharedState = getHermeticDirs().gstackHome;
stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-plan-count-state-'));
for (const entry of fs.readdirSync(sharedState, { withFileTypes: true })) {
// Preserve onboarding seeds, without copying sibling review logs or
// artifacts from the per-process shared hermetic state directory.
if (entry.isFile() && (entry.name === '.activated' ||
/^\..*(?:-seen|-prompted|-shown)$/.test(entry.name) ||
entry.name.startsWith('.feature-prompted-'))) {
fs.copyFileSync(path.join(sharedState, entry.name), path.join(stateRoot, entry.name));
}
}
const config = fs.readFileSync(path.join(sharedState, 'config.yaml'), 'utf8')
.replace(/^codex_reviews:.*(?:\r?\n|$)/gm, '');
fs.writeFileSync(path.join(stateRoot, 'config.yaml'), config + '\ncodex_reviews: disabled\n');
// Config readers prefer STATE_ROOT, while onboarding writers use HOME.
// Both must resolve to owned state, even with explicit caller overrides.
env.GSTACK_HOME = stateRoot;
env.GSTACK_STATE_ROOT = stateRoot;
}
fs.writeFileSync(path.join(cwd, 'PLAN.md'), prompt);
fs.writeFileSync(path.join(cwd, 'CLAUDE.md'), [
'# Plan review fixture',
@@ -45,7 +79,7 @@ export function createPlanCountFixture(prompt: string): {
git(['-c', 'user.name=Plan Count Fixture', '-c', 'user.email=plan-count@example.test',
'-c', 'commit.gpgsign=false', 'commit', '--no-verify', '-m', 'Seed review plan']);
git(['update-ref', 'refs/remotes/origin/main', 'HEAD']);
return { cwd, cleanup };
return { cwd, env, cleanup };
} catch (error) {
cleanup();
throw error;
+8 -8
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/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-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'],
@@ -154,16 +154,16 @@ 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/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/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/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 +
// completion-status resolvers because they affect question cadence and
// terminal output (the regression surface this test catches).
'plan-ceo-finding-count': ['bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-ceo-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/skill-e2e-plan-ceo-finding-count.test.ts'],
'plan-eng-finding-count': ['bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-eng-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/skill-e2e-plan-eng-finding-count.test.ts'],
'plan-design-finding-count': ['bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-design-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/skill-e2e-plan-design-finding-count.test.ts'],
'plan-devex-finding-count': ['bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-devex-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/skill-e2e-plan-devex-finding-count.test.ts'],
'plan-ceo-finding-count': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-ceo-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/skill-e2e-plan-ceo-finding-count.test.ts'],
'plan-eng-finding-count': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-eng-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/skill-e2e-plan-eng-finding-count.test.ts'],
'plan-design-finding-count': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-design-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/skill-e2e-plan-design-finding-count.test.ts'],
'plan-devex-finding-count': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-devex-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/skill-e2e-plan-devex-finding-count.test.ts'],
// Gate-tier reviewCount-floor counterparts. Catch the May 2026 transcript
// bug (model wrote a plan-mode plan and ExitPlanMode'd without firing any
@@ -180,8 +180,8 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
// a model fires one AUQ then batches the rest into a "## Decisions to
// confirm" plan write. runPlanSkillFloorCheck cannot detect that shape
// (it exits on first AUQ); runPlanSkillCounting can.
'plan-eng-multi-finding-batching': ['bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-eng-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/review.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/fixtures/forcing-finding-seeds.ts', 'test/skill-e2e-plan-eng-multi-finding-batching.test.ts'],
'plan-ceo-split-overflow': ['plan-ceo-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'bin/gstack-question-preference', 'test/helpers/claude-pty-runner.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/fixtures/forcing-finding-seeds.ts', 'test/skill-e2e-plan-ceo-split-overflow.test.ts'],
'plan-eng-multi-finding-batching': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-eng-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/review.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/fixtures/forcing-finding-seeds.ts', 'test/skill-e2e-plan-eng-multi-finding-batching.test.ts'],
'plan-ceo-split-overflow': ['bin/gstack-config', 'plan-ceo-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'bin/gstack-question-preference', 'test/helpers/claude-pty-runner.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/fixtures/forcing-finding-seeds.ts', 'test/skill-e2e-plan-ceo-split-overflow.test.ts'],
'brain-privacy-gate': ['bin/gstack-skill-start', 'bin/gstack-skill-end', 'scripts/resolvers/preamble/generate-brain-sync-block.ts', 'scripts/resolvers/preamble.ts', 'bin/gstack-brain-sync', 'bin/gstack-artifacts-init', 'bin/gstack-config', 'test/helpers/agent-sdk-runner.ts', 'test/skill-e2e-brain-privacy-gate.test.ts'],
// /setup-gbrain Path 4 (Remote MCP) — happy + bad-token end-to-end via
+111 -7
View File
@@ -6,6 +6,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { pathToFileURL } from 'node:url';
import { createPlanCountFixture } from './helpers/plan-count-fixture';
import { getHermeticDirs } from './helpers/hermetic-env';
const ROOT = path.resolve(import.meta.dir, '..');
const PROMPT = '# Seeded settings plan\n\nReview each issue separately.\n' +
@@ -30,12 +31,41 @@ describe('plan-count fixtures', () => {
expect(fs.existsSync(path.join(fixture.cwd, 'DESIGN.md'))).toBe(false);
expect(fs.existsSync(path.join(fixture.cwd, 'TODOS.md'))).toBe(false);
expect(fs.existsSync(path.join(fixture.cwd, 'never'))).toBe(false);
expect(fixture.env).toEqual({}); // Mode-routing callers keep normal config.
} finally {
fixture.cleanup();
}
expect(fs.existsSync(fixture.cwd)).toBe(false);
});
test('native count config owns independent state and preserves shared onboarding seeds', () => {
const shared = getHermeticDirs().gstackHome;
const before = fs.readFileSync(path.join(shared, 'config.yaml'), 'utf8');
const markers = fs.readdirSync(shared).filter(name => name.startsWith('.'));
const first = createPlanCountFixture('first native plan', { nativeReviewOnly: true });
const second = createPlanCountFixture('second native plan', { nativeReviewOnly: true });
try {
expect(first.env.GSTACK_HOME).not.toBe(shared);
expect(first.env.GSTACK_HOME).not.toBe(second.env.GSTACK_HOME);
expect(first.env.GSTACK_STATE_ROOT).toBe(first.env.GSTACK_HOME);
expect(fs.readFileSync(path.join(first.env.GSTACK_HOME, 'config.yaml'), 'utf8'))
.toBe(before.replace(/^codex_reviews:.*(?:\r?\n|$)/gm, '') + '\ncodex_reviews: disabled\n');
for (const marker of markers) {
expect(fs.readFileSync(path.join(first.env.GSTACK_HOME, marker), 'utf8'))
.toBe(fs.readFileSync(path.join(shared, marker), 'utf8'));
}
first.cleanup();
first.cleanup();
expect(fs.existsSync(first.env.GSTACK_HOME)).toBe(false);
expect(fs.existsSync(second.env.GSTACK_HOME)).toBe(true);
expect(fs.readFileSync(path.join(shared, 'config.yaml'), 'utf8')).toBe(before);
} finally {
first.cleanup();
second.cleanup();
}
expect(fs.existsSync(second.env.GSTACK_HOME)).toBe(false);
});
test('concurrent fixtures have independent content and cleanup owns only its directory', () => {
const first = createPlanCountFixture('first plan');
const second = createPlanCountFixture('second plan');
@@ -98,10 +128,16 @@ try {
const fakePath = path.join(dir, 'fake-claude');
const workerPath = path.join(dir, 'worker.ts');
const resultPath = path.join(dir, 'result.json');
const hostState = path.join(dir, 'host-state');
const hostConfig = 'codex_reviews: enabled\nexplain_level: expert\n';
fs.mkdirSync(hostState);
fs.writeFileSync(path.join(hostState, 'config.yaml'), hostConfig);
const cases = [
{ name: 'design', skillName: 'plan-design-review', prompt: PROMPT, mode: 'complete' },
{ name: 'ceo', skillName: 'plan-ceo-review', prompt: '# Independent CEO plan\nUnique product context.', mode: 'complete' },
{ name: 'exited', skillName: 'plan-eng-review', prompt: '# Early-exit plan\nStill clean up.', mode: 'exit' },
{ name: 'skip-first', skillName: 'plan-devex-review', prompt: '# Native DX plan', mode: 'prerequisite', skipIndex: 1 },
{ name: 'skip-second', skillName: 'plan-devex-review', prompt: '# Native DX plan', mode: 'prerequisite', skipIndex: 2 },
].map((item) => ({ ...item, record: path.join(dir, `${item.name}.jsonl`) }));
// The fake snapshots its environment BEFORE installing the first stdin
@@ -114,8 +150,16 @@ const record = (event) => fs.appendFileSync(process.env.FIXTURE_RECORD, JSON.str
const skillDir = path.join(process.env.CLAUDE_CONFIG_DIR, 'skills', process.env.FIXTURE_SKILL);
const planPath = path.join(process.cwd(), 'PLAN.md');
const contextPath = path.join(process.cwd(), 'CLAUDE.md');
const config = (key) => {
const result = spawnSync('bash', [process.env.FIXTURE_CONFIG_BIN, 'get', key], { encoding: 'utf8', timeout: 10000 });
if (result.status !== 0) throw new Error('Fixture config failed: ' + result.stderr);
return result.stdout;
};
record({
type: 'startup', pid: process.pid, cwd: process.cwd(), argv: process.argv.slice(2),
stateRoot: process.env.GSTACK_STATE_ROOT, gstackHome: process.env.GSTACK_HOME,
codexReviews: config('codex_reviews'), explainLevel: config('explain_level'),
onboarding: fs.readdirSync(process.env.GSTACK_HOME).filter(name => name.startsWith('.')),
gitRoot: spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 10_000 }).stdout.trim(),
plan: fs.existsSync(planPath) ? fs.readFileSync(planPath, 'utf8') : null,
context: fs.existsSync(contextPath) ? fs.readFileSync(contextPath, 'utf8') : null,
@@ -124,15 +168,40 @@ record({
sections: fs.existsSync(path.join(skillDir, 'sections', 'review-sections.md'))
? fs.readFileSync(path.join(skillDir, 'sections', 'review-sections.md'), 'utf8') : null,
});
fs.writeFileSync(path.join(process.env.GSTACK_HOME, 'child-owned'), 'isolated');
if (process.env.FIXTURE_MODE === 'exit') process.stdout.write('\x1b[?25lSTARTUP_DIAGNOSTIC fixture CLI booted\x1b[?25h\n');
process.stdin.setRawMode?.(true);
let firstInput = true;
let completion;
let question = 0;
let selected = '';
process.stdin.on('data', (data) => {
record({ type: 'input', data: data.toString('utf8') });
if (!firstInput) return;
const input = data.toString('utf8');
record({ type: 'input', data: input });
if (!firstInput) {
if (process.env.FIXTURE_MODE !== 'prerequisite') return;
if (question === 1 && input.includes('\r')) {
record({ type: 'generic-answer', input });
question = 2;
const labels = ['Run /office-hours now', 'Skip — proceed with standard review'];
if (process.env.FIXTURE_SKIP_INDEX === '1') labels.reverse();
process.stdout.write('\r☐ Prerequisite\rNo design doc found. Run /office-hours first?\r❯1.' + labels[0] + '\r2.' + labels[1] + '\r');
return;
}
selected += input.replace(/\r/g, '');
if (input.includes('\r')) {
record({ type: 'prerequisite-answer', selected });
process.stdout.write(selected === process.env.FIXTURE_SKIP_INDEX ? '\nGSTACK REVIEW REPORT\n' : '\nWRONG_PREREQUISITE_CHOICE\n');
}
return;
}
firstInput = false;
if (process.env.FIXTURE_MODE === 'exit') process.exit(7);
if (process.env.FIXTURE_MODE === 'prerequisite') {
question = 1;
process.stdout.write('\r☐ Setup\rWhich fixture setup should be used?\r❯1.First setup\r2.Second setup\r');
return;
}
// Longer than the old 3 s delayed fixture send: record that regression
// even if the helper would otherwise return on our completion marker.
completion = setTimeout(() => process.stdout.write('\nGSTACK REVIEW REPORT\n'), 4100);
@@ -146,8 +215,16 @@ process.stdin.resume();
`);
fs.chmodSync(fakePath, 0o755);
const runnerUrl = pathToFileURL(path.join(ROOT, 'test/helpers/claude-pty-runner.ts')).href;
const hermeticUrl = pathToFileURL(path.join(ROOT, 'test/helpers/hermetic-env.ts')).href;
fs.writeFileSync(workerPath, `
import { runPlanSkillCounting } from ${JSON.stringify(runnerUrl)};
import { getHermeticDirs } from ${JSON.stringify(hermeticUrl)};
import * as fs from 'node:fs';
import * as path from 'node:path';
const shared = getHermeticDirs().gstackHome;
fs.appendFileSync(path.join(shared, 'config.yaml'), 'codex_reviews: enabled\\nexplain_level: beginner\\n');
const sharedBefore = fs.readFileSync(path.join(shared, 'config.yaml'), 'utf8');
const onboarding = fs.readdirSync(shared).filter(name => name.startsWith('.'));
const cases = ${JSON.stringify(cases)};
const results = await Promise.all(cases.map(async (item) => ({
name: item.name,
@@ -158,16 +235,24 @@ const results = await Promise.all(cases.map(async (item) => ({
isLastStep0AUQ: () => false,
reviewCountCeiling: 8,
timeoutMs: 15000,
env: { FIXTURE_RECORD: item.record, FIXTURE_SKILL: item.skillName, FIXTURE_MODE: item.mode },
firstAUQPick: () => 2,
env: {
FIXTURE_RECORD: item.record, FIXTURE_SKILL: item.skillName, FIXTURE_MODE: item.mode,
FIXTURE_SKIP_INDEX: String(item.skipIndex ?? ''), FIXTURE_CONFIG_BIN: ${JSON.stringify(path.join(ROOT, 'bin/gstack-config'))},
GSTACK_HOME: ${JSON.stringify(hostState)}, GSTACK_STATE_ROOT: ${JSON.stringify(hostState)},
},
}),
})));
await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify(results));
await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify({ results, onboarding, sharedBefore,
sharedAfter: fs.readFileSync(path.join(shared, 'config.yaml'), 'utf8'),
sharedHasChildFile: fs.existsSync(path.join(shared, 'child-owned')),
}));
`);
const startedAt = Date.now();
const child = Bun.spawn([process.execPath, workerPath], {
cwd: ROOT,
env: { ...process.env, BROWSE_TERMINAL_BINARY: fakePath, EVALS_HERMETIC: '1' },
env: { ...process.env, BROWSE_TERMINAL_BINARY: fakePath, EVALS_HERMETIC: '1', GSTACK_HOME: hostState, GSTACK_STATE_ROOT: hostState },
stdout: 'pipe',
stderr: 'pipe',
});
@@ -180,14 +265,25 @@ await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify(results));
]);
expect(exitCode, stdout + stderr).toBe(0);
expect(Date.now() - startedAt).toBeLessThan(35_000);
const results = JSON.parse(fs.readFileSync(resultPath, 'utf8'));
const report = JSON.parse(fs.readFileSync(resultPath, 'utf8'));
const results = report.results;
expect(report.sharedAfter).toBe(report.sharedBefore);
expect(report.sharedHasChildFile).toBe(false);
expect(fs.readFileSync(path.join(hostState, 'config.yaml'), 'utf8')).toBe(hostConfig);
expect(fs.readdirSync(hostState)).toEqual(['config.yaml']);
const cwds = new Set<string>();
const stateRoots = new Set<string>();
for (const item of cases) {
const events = fs.readFileSync(item.record, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
const startup = events[0];
expect(startup.type).toBe('startup');
expect(startup.cwd).not.toBe(ROOT);
expect(startup.gitRoot).toBe(startup.cwd);
expect(startup.stateRoot).toBe(startup.gstackHome);
expect(startup.stateRoot).not.toBe(hostState);
expect(startup.codexReviews).toBe('disabled');
expect(startup.explainLevel).toBe('beginner');
expect(startup.onboarding).toEqual(report.onboarding);
expect(startup.plan).toBe(item.prompt);
expect(startup.context).toContain('PLAN.md');
expect(startup.context).toContain(item.prompt);
@@ -197,8 +293,9 @@ await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify(results));
expect(startup.skill).toContain(`name: ${item.skillName}`);
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`);
.toBe(`/${item.skillName}\r` + (item.mode === 'prerequisite' ? `2\r${item.skipIndex}\r` : ''));
expect(fs.existsSync(startup.cwd)).toBe(false);
expect(fs.existsSync(startup.stateRoot)).toBe(false);
expect(() => process.kill(startup.pid, 0)).toThrow();
const result = results.find((result) => result.name === item.name);
expect(result.observation.outcome).toBe(item.mode === 'exit' ? 'exited' : 'completion_summary');
@@ -208,9 +305,16 @@ await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify(results));
expect(result.observation.evidence).not.toContain('\x1b');
}
if (item.mode === 'complete') expect(events.at(-1).type).toBe('closed');
if (item.mode === 'prerequisite') {
expect(events.find(event => event.type === 'prerequisite-answer').selected).toBe(String(item.skipIndex));
expect(result.observation.step0Count).toBe(2);
expect(result.observation.reviewCount).toBe(0);
}
cwds.add(startup.cwd);
stateRoots.add(startup.stateRoot);
}
expect(cwds.size).toBe(cases.length);
expect(stateRoots.size).toBe(cases.length);
} finally {
clearTimeout(killer);
child.kill('SIGKILL');
+107
View File
@@ -0,0 +1,107 @@
/** Free PTY replay of numbered-menu selection with deferred cursor updates. */
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';
describe('PTY numbered-option selection', () => {
test.skipIf(process.platform === 'win32')('waits for the selected option to update before confirming', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-option-replay-'));
const fake = path.join(dir, 'fake-claude');
const worker = path.join(dir, 'worker.ts');
const resultFile = path.join(dir, 'result.json');
const cases = [
{ name: 'coalesced-second', index: 2, coalesced: true, expected: 1 },
{ name: 'selected-second', index: 2, coalesced: false, expected: 2 },
{ name: 'selected-third', index: 3, coalesced: false, expected: 3 },
].map(item => ({ ...item, cwd: path.join(dir, item.name), record: path.join(dir, item.name + '.jsonl') }));
for (const item of cases) fs.mkdirSync(item.cwd);
fs.writeFileSync(fake, `#!${process.execPath}\n` + String.raw`
import * as fs from 'node:fs';
const item = JSON.parse(process.env.OPTION_REPLAY);
const record = (event) => fs.appendFileSync(item.record, JSON.stringify(event) + '\n');
record({type:'startup', pid:process.pid, cwd:process.cwd()});
process.stdin.setRawMode?.(true);
let selected = 1;
let confirmed = false;
process.stdin.on('data', (data) => {
record({type:'input', data:data.toString()});
for (const key of data.toString()) {
if (/^[1-3]$/.test(key)) {
// Model a menu whose selection state commits on a later render.
// Enter in the same input turn sees the previous selected option.
setTimeout(() => {
selected = Number(key);
record({type:'selected', selected});
}, 100);
} else if (key === '\r' && !confirmed) {
confirmed = true;
record({type:'confirmed', selected});
process.stdout.write('\nCONFIRMED:' + selected + '\n');
}
}
});
process.stdout.write('OPTION_MENU_READY\n❯1.First\n2.Second\n3.Third\n');
process.on('SIGINT', () => process.exit(0));
process.stdin.resume();
`);
fs.chmodSync(fake, 0o755);
const runner = pathToFileURL(path.resolve(import.meta.dir, 'helpers/claude-pty-runner.ts')).href;
fs.writeFileSync(worker, `
import { launchClaudePty, selectPtyNumberedOption } from ${JSON.stringify(runner)};
const cases = ${JSON.stringify(cases)};
const results = await Promise.all(cases.map(async item => {
const session = await launchClaudePty({cwd:item.cwd, timeoutMs:5000, env:{OPTION_REPLAY:JSON.stringify(item)}});
try {
await session.waitFor('OPTION_MENU_READY', {timeoutMs:2000, pollMs:20});
if (item.coalesced) session.send(String(item.index) + '\\r');
else await selectPtyNumberedOption(session, item.index);
await session.waitFor('CONFIRMED:', {timeoutMs:2000, pollMs:20});
const selected = Number(session.visibleText().match(/CONFIRMED:([1-3])/)?.[1]);
return {name:item.name, selected};
} finally { await session.close(); }
}));
await Bun.write(${JSON.stringify(resultFile)}, JSON.stringify(results));
`);
const child = Bun.spawn([process.execPath, worker], {
env: { ...process.env, BROWSE_TERMINAL_BINARY: fake, EVALS_HERMETIC: '1' },
stdout: 'pipe', stderr: 'pipe',
});
const killer = setTimeout(() => child.kill('SIGKILL'), 8000);
try {
const [exitCode, stdout, stderr] = await Promise.all([
child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
]);
expect(exitCode, stdout + stderr).toBe(0);
expect(JSON.parse(fs.readFileSync(resultFile, 'utf8'))).toEqual(
cases.map(item => ({ name: item.name, selected: item.expected })),
);
for (const item of cases) {
const events = fs.readFileSync(item.record, 'utf8').trim().split('\n').map(line => JSON.parse(line));
expect(events[0].cwd).toBe(fs.realpathSync(item.cwd));
const inputs = events.filter(event => event.type === 'input');
expect(inputs.map(event => event.data).join('')).toBe(`${item.index}\r`);
const confirmations = events.filter(event => event.type === 'confirmed');
expect(confirmations).toEqual([{ type: 'confirmed', selected: item.expected }]);
if (!item.coalesced) {
expect(inputs.map(event => event.data)).toEqual([String(item.index), '\r']);
const selectionIndex = events.findIndex(event => event.type === 'selected');
expect(selectionIndex).toBeGreaterThan(-1);
expect(selectionIndex).toBeLessThan(events.findIndex(event => event.type === 'confirmed'));
}
expect(() => process.kill(events[0].pid, 0)).toThrow();
}
} finally {
clearTimeout(killer);
child.kill('SIGKILL');
for (const item of cases) {
if (!fs.existsSync(item.record)) continue;
const first = JSON.parse(fs.readFileSync(item.record, 'utf8').split('\n')[0]!);
try { process.kill(first.pid, 'SIGKILL'); } catch { /* already reaped */ }
}
fs.rmSync(dir, { recursive: true, force: true });
}
}, 10_000);
});
+16 -3
View File
@@ -38,7 +38,9 @@ import {
isPlanReadyVisible,
isPermissionDialogVisible,
isNumberedOptionListVisible,
selectPtyNumberedOption,
} from './helpers/claude-pty-runner';
import { autoplanRoutingSetupInput } from './helpers/autoplan-setup-question';
const describeE2E = describeE2ETier('periodic');
@@ -103,6 +105,7 @@ describeE2E('/autoplan chain ordering (periodic)', () => {
const phasePattern = /\*\*Phase\s+(\d+(?:\.\d+)?)\s+complete\.?\*\*/g;
let lastPermSig = '';
const seenSetupQuestions = new Set<string>();
while (Date.now() - start < budgetMs) {
await Bun.sleep(5000);
if (session.exited()) {
@@ -113,9 +116,8 @@ describeE2E('/autoplan chain ordering (periodic)', () => {
const visible = session.visibleSince(since);
// Auto-grant any permission dialog so autoplan can keep moving
// through its phases. The autoplan template auto-decides AskUserQuestions
// it owns; only permission prompts (file/tool grants) need our
// hand-pressing. Classify on tail to avoid stale matches.
// through its phases. The autoplan template auto-decides review
// questions it owns. Classify on tail to avoid stale matches.
const recentTail = visible.slice(-1500);
if (isNumberedOptionListVisible(recentTail) && isPermissionDialogVisible(recentTail)) {
const sig = visible.slice(-500);
@@ -127,6 +129,17 @@ describeE2E('/autoplan chain ordering (periodic)', () => {
}
}
// This new repository also asks once to add gstack routing to
// CLAUDE.md. Answer only that recognized setup prompt; review and
// taste decisions remain autoplan's responsibility. The helper
// deduplicates the complete question before returning an input.
const setupInput = autoplanRoutingSetupInput(visible, seenSetupQuestions);
if (setupInput !== null) {
await selectPtyNumberedOption(session, Number(setupInput.trim()));
await Bun.sleep(2000);
continue;
}
// Re-scan for any phase markers we haven't yet recorded.
phasePattern.lastIndex = 0;
let m: RegExpExecArray | null;
@@ -166,11 +166,7 @@ describeE2E('/plan-ceo-review per-finding AskUserQuestion count (periodic)', ()
if (obs.reviewCount > CEILING_DISTINCT) {
throw new Error(
`BAND FAIL (above ceiling): reviewCount=${obs.reviewCount} > CEILING=${CEILING_DISTINCT}.\n` +
`Possible over-asking regression. Review-phase fingerprints:\n` +
obs.fingerprints
.filter((f) => !f.preReview)
.map((f) => ` - "${f.promptSnippet.slice(0, 80)}"`)
.join('\n'),
`Captured observation:\n${JSON.stringify(obs, null, 2)}`,
);
}
@@ -243,7 +239,8 @@ describeE2E('/plan-ceo-review per-finding AskUserQuestion count (periodic)', ()
}
if (obs.reviewCount > CEILING_PAIRED) {
throw new Error(
`PAIRED CONTROL FAIL: reviewCount=${obs.reviewCount} > CEILING=${CEILING_PAIRED} (over-asking on a 2-finding fixture).`,
`PAIRED CONTROL FAIL: reviewCount=${obs.reviewCount} > CEILING=${CEILING_PAIRED} (over-asking on a 2-finding fixture).\n` +
`Captured observation:\n${JSON.stringify(obs, null, 2)}`,
);
}
} finally {
+28 -45
View File
@@ -36,14 +36,11 @@ import { describeE2ETier } from './helpers/e2e-gate';
import {
launchClaudePty,
isNumberedOptionListVisible,
isPermissionDialogVisible,
parseNumberedOptions,
isPlanReadyVisible,
optionsSignature,
TAIL_SCAN_BYTES,
selectPtyNumberedOption,
type ClaudePtySession,
} from './helpers/claude-pty-runner';
import { findCeoModeOption } from './helpers/ceo-mode-option';
import { hasPostAnswerCeoPosture, nextCeoModeNavigation } from './helpers/ceo-mode-option';
import { createPlanCountFixture } from './helpers/plan-count-fixture';
const describeE2E = describeE2ETier('periodic');
@@ -101,7 +98,7 @@ async function navigateToModeAskUserQuestion(
const budgetMs = opts.budgetMs ?? 420_000;
const start = Date.now();
let priorAnswered = 0;
let lastSeenList: Array<{ index: number; label: string }> = [];
const seenQuestions = new Set<string>();
while (Date.now() - start < budgetMs) {
if (session.exited()) {
@@ -112,44 +109,25 @@ async function navigateToModeAskUserQuestion(
}
await Bun.sleep(2000);
const visible = session.visibleSince(since);
if (!isNumberedOptionListVisible(visible)) continue;
const opts = parseNumberedOptions(visible);
if (opts.length < 2) continue;
// Has the rendered list changed since last poll? If not, we're seeing
// the same prompt and shouldn't double-press.
const sig = optionsSignature(opts);
const lastSig = optionsSignature(lastSeenList);
if (sig === lastSig) continue;
lastSeenList = opts;
// Is THIS the mode AskUserQuestion?
const modeIndex = findCeoModeOption(opts, targetMode);
if (modeIndex !== null) {
return { modeIndex, visibleAtMode: visible };
}
// Permission dialog? Grant with "1" but don't count it against nav budget.
// Classify on the recent tail only — old permission text persists in
// visibleSince and would re-trigger forever.
//
// Note: runPlanSkillObservation has its own permission-dialog filter that
// simply skips classification (since it observes, doesn't drive). This nav
// loop drives the PTY directly via launchClaudePty and so owns its own
// dialog handling — granting with "1" so the workflow advances. Both
// paths share TAIL_SCAN_BYTES as the recent-tail window so tuning stays
// in sync.
if (isPermissionDialogVisible(visible.slice(-TAIL_SCAN_BYTES))) {
session.send('1\r');
const action = nextCeoModeNavigation(visible, targetMode, seenQuestions);
if (action.kind === 'wait') continue;
// Native permission and multi-question Submit menus are controls, not
// review questions, so neither consumes the navigation question budget.
if (action.kind === 'permission' || action.kind === 'submission') {
session.send(action.input);
await Bun.sleep(1500);
continue;
}
if (action.kind === 'mode') {
return { modeIndex: action.index, visibleAtMode: visible };
}
// Not the mode AskUserQuestion — answer with option 1 (recommended) and continue.
if (priorAnswered >= maxNav) {
throw new Error(
`Navigated ${maxNav} prior AskUserQuestions without reaching the mode AskUserQuestion. ` +
`Last list:\n${opts.map(o => ` ${o.index}. ${o.label}`).join('\n')}`,
`Target: ${targetMode}. Last question: ${action.question.promptSnippet}\n` +
`Last list:\n${action.question.options.map(o => ` ${o.index}. ${o.label}`).join('\n')}`,
);
}
priorAnswered++;
@@ -157,7 +135,10 @@ async function navigateToModeAskUserQuestion(
// Give the agent a beat to advance before re-polling.
await Bun.sleep(2000);
}
throw new Error(`Mode AskUserQuestion not reached within ${budgetMs}ms`);
throw new Error(
`Mode AskUserQuestion for "${targetMode}" not reached within ${budgetMs}ms; priorAnswered=${priorAnswered}.\n` +
`--- latest visible navigation (last 3KB) ---\n${session.visibleSince(since).slice(-3000)}`,
);
}
describeE2E('/plan-ceo-review mode routing (gate)', () => {
@@ -178,11 +159,12 @@ describeE2E('/plan-ceo-review mode routing (gate)', () => {
const since = session.mark();
session.send('/plan-ceo-review\r');
const { modeIndex } = await navigateToModeAskUserQuestion(session, since, c.mode);
const { modeIndex, visibleAtMode } = await navigateToModeAskUserQuestion(session, since, c.mode);
// Snapshot the visible buffer at mode-pick time, then send the index.
const sincePick = session.rawOutput().length;
session.send(`${modeIndex}\r`);
// The digit redraws the menu before Enter confirms it. Start after
// confirmation so its mode names cannot count as assistant posture.
await selectPtyNumberedOption(session, modeIndex);
const sincePick = session.mark();
// Wait for downstream evidence: either next AskUserQuestion or plan_ready or
// a posture-distinctive substring shows up.
@@ -194,12 +176,12 @@ describeE2E('/plan-ceo-review mode routing (gate)', () => {
await Bun.sleep(2500);
if (session.exited()) {
throw new Error(
`claude exited (code=${session.exitCode()}) after mode pick.\n` +
`claude exited (code=${session.exitCode()}) after mode "${c.mode}" option ${modeIndex}.\n` +
`Downstream:\n${session.visibleSince(sincePick).slice(-2000)}`,
);
}
downstreamSnapshot = session.visibleSince(sincePick);
if (c.postureRe.test(downstreamSnapshot)) {
if (hasPostAnswerCeoPosture(downstreamSnapshot, c.postureRe)) {
postureMatched = true;
break;
}
@@ -209,7 +191,7 @@ describeE2E('/plan-ceo-review mode routing (gate)', () => {
if (
isPlanReadyVisible(downstreamSnapshot) &&
isNumberedOptionListVisible(downstreamSnapshot) &&
!c.postureRe.test(downstreamSnapshot)
!hasPostAnswerCeoPosture(downstreamSnapshot, c.postureRe)
) {
// Plan-ready AND a follow-up AskUserQuestion are both visible but
// posture text has not appeared yet. Keep polling for a bit.
@@ -217,7 +199,8 @@ describeE2E('/plan-ceo-review mode routing (gate)', () => {
}
if (!postureMatched) {
throw new Error(
`Mode "${c.mode}" routing FAILED: no posture match for ${c.postureRe.source}.\n` +
`Mode "${c.mode}" routing FAILED after sending option ${modeIndex}: no posture match for ${c.postureRe.source}.\n` +
`--- observed mode menu (last 3KB) ---\n${visibleAtMode.slice(-3000)}\n` +
`--- downstream visible since mode pick (last 3KB) ---\n` +
downstreamSnapshot.slice(-3000),
);
@@ -105,7 +105,8 @@ describeE2E('/plan-design-review per-finding AskUserQuestion count (periodic)',
}
if (obs.reviewCount > CEILING) {
throw new Error(
`BAND FAIL (above ceiling): reviewCount=${obs.reviewCount} > CEILING=${CEILING}.`,
`BAND FAIL (above ceiling): reviewCount=${obs.reviewCount} > CEILING=${CEILING}.\n` +
`Captured observation:\n${JSON.stringify(obs, null, 2)}`,
);
}
@@ -100,7 +100,8 @@ describeE2E('/plan-devex-review per-finding AskUserQuestion count (periodic)', (
}
if (obs.reviewCount > CEILING) {
throw new Error(
`BAND FAIL (above ceiling): reviewCount=${obs.reviewCount} > CEILING=${CEILING}.`,
`BAND FAIL (above ceiling): reviewCount=${obs.reviewCount} > CEILING=${CEILING}.\n` +
`Captured observation:\n${JSON.stringify(obs, null, 2)}`,
);
}
@@ -99,7 +99,8 @@ describeE2E('/plan-eng-review per-finding AskUserQuestion count (periodic)', ()
}
if (obs.reviewCount > CEILING) {
throw new Error(
`BAND FAIL (above ceiling): reviewCount=${obs.reviewCount} > CEILING=${CEILING}.`,
`BAND FAIL (above ceiling): reviewCount=${obs.reviewCount} > CEILING=${CEILING}.\n` +
`Captured observation:\n${JSON.stringify(obs, null, 2)}`,
);
}