diff --git a/test/helpers/claude-pty-runner.auq.unit.test.ts b/test/helpers/claude-pty-runner.auq.unit.test.ts new file mode 100644 index 000000000..1016346cd --- /dev/null +++ b/test/helpers/claude-pty-runner.auq.unit.test.ts @@ -0,0 +1,606 @@ +/** + * Deterministic unit tests for AskUserQuestion fingerprinting and native matching (test/helpers/pty/auq.ts). + * Split along the W4 module seams from the former claude-pty-runner.unit.test.ts; + * tests import the public barrel, test/helpers/claude-pty-runner.ts. + */ +import { describe, test, expect } from 'bun:test'; +import { + parseNumberedOptions, + parseQuestionPrompt, + stripAnsi, + auqFingerprint, + classifyPlanCountFrame, + capturePlanCountQuestion, + matchesNativePlanQuestion, + createPlanCountPermissionGuard, + planCountPrerequisitePick, + engStep0Boundary, + planCountQuestionPhase, +} from './claude-pty-runner'; + +describe('pending native question on a damaged option render', () => { + // Exact final B CEO Test scope shape. The native call had been read in + // an in-progress snapshot, but option 2's missing dot prevented input. + const frame = [ + '☐Test scope', + '│Section 6 (Tests) — Theplanhasnotestsforanewpaymentprocessingcodepath.Theexistingintegrationsuitehas', + '│never seen this handlerand cannotcatchregressionsinit.Minimumviabletestplanforminimalpatch:5unittests', + '│(happy path, mal failur, DB timeout, unknowneventtype,unknownuser).Shouldtheplanalsoincludeanintegration', + '│testhittingthefullwebhookstack?', + '❯1.Unittestsonlyfornow(recommended)', + '5 unit tests covering the criticalpaths. No integration stin v1.', + '2Uni tsts + one integration test', + '5 uit tsts + on ed-to-end integrationtestsendiga signe Stripeevent.', + '3.Integrationtestonly', + '4.Typesomething.', + '5. Chataboutthis', + 'Enter to select · ↑/↓ to navigate · Esc to cancel', + '❯1', + ].join('\n'); + const pending = { + sessionId: '66fb6218-4a68-4f1a-a729-6407f14fd6b8', + toolUseId: 'toolu_017DicePqWNVyDsLCd2Y2MCi', answered: false, + questions: [{ header: 'Test scope', question: 'Should the plan also include an integration test hitting the full webhook stack? ', + options: ['Unit tests only for now (recommended)', 'Unit tests + one integration test', 'Integration test only'].map(label => ({ label })) }], + }; + + test('uses lossless pending options after a positively matched native question has rendered', () => { + const seen = new Set(); + const captured = capturePlanCountQuestion(frame, seen, 0, false, pending); + expect(captured?.nativeCall).toBe(pending); + expect(captured?.options).toEqual(pending.questions[0].options.map((o, i) => ({ index: i + 1, label: o.label }))); + expect(capturePlanCountQuestion(frame, seen, 1, false, pending)).toBeNull(); + // A corrected redraw is still the same pending native question. + expect(capturePlanCountQuestion(frame.replace('2Uni tsts', '2.Unit tests'), seen, 2, false, pending)).toBeNull(); + expect(capturePlanCountQuestion(frame.replace('2Uni tsts', '2.Unit tests'), seen, 3, false)).toBeNull(); + expect(seen.has(captured!.signature)).toBe(true); + }); + + test('binds delayed native metadata to the already-answered visible question', () => { + const seen = new Set(); + const clean = frame.replace('2Uni tsts', '2.Unit tests'); + expect(capturePlanCountQuestion(clean, seen, 0, false)).not.toBeNull(); + expect(capturePlanCountQuestion(clean, seen, 1, false, pending)).toBeNull(); + expect(capturePlanCountQuestion(frame, seen, 2, false, pending)).toBeNull(); + }); + + test('requires pending single-question metadata, matching current header, cursor, and navigation footer', () => { + for (const call of [undefined, { ...pending, answered: true }, { ...pending, failed: true }, + { ...pending, questions: [...pending.questions, ...pending.questions] }, + { ...pending, questions: [{ ...pending.questions[0], header: 'Prior decision' }] }, + { ...pending, questions: [{ ...pending.questions[0], question: 'Different issue ' }] }]) { + expect(capturePlanCountQuestion(frame, new Set(), 0, false, call)).toBeNull(); + } + for (const altered of [frame.replace('☐Test scope', 'Test scope'), frame.replace('❯1.', '1.'), + frame.replace('Enter to select · ↑/↓ to navigate · Esc to cancel', ''), + frame + '\n☐Different question\n❯1.Waiting for its choices']) { + expect(capturePlanCountQuestion(altered, new Set(), 0, false, pending)).toBeNull(); + } + }); +}); + +describe('parseQuestionPrompt', () => { + test('keeps the captured boxed learnings header across native CR and blank borders', () => { + // Exact active-menu bytes from the targeted-a engineering batching run. + // Its answered setup AUQ lost the title at the standalone box border, + // leaving every later finding classified as preReview. + const raw = "☐ Learnings\u001b[K\r\u001b[1B\u001b[K\r\u001b[1B│ D1 — Cross-project learnings scope \u001b[K\r\u001b[1B│\u001b[3G\u001b[K\r\r\n│\u001b[3Ggstack\u001b[10Gcan\u001b[14Gsearch\u001b[21Glearnings\u001b[31Gfrom\u001b[36Gyour\u001b[41Gother\u001b[47Gprojects\u001b[56Gon\u001b[59Gthis\u001b[64Gmachine\u001b[72Gto\u001b[75Gfind\u001b[80Gpatterns\u001b[89Gthat\u001b[94Gmight\u001b[100Gapply\u001b[106Ghere.\u001b[112GThis\r\r\n│\u001b[3Gstays\u001b[9Glocal\u001b[15G—\u001b[17Gno\u001b[20Gdata\u001b[25Gleaves\u001b[32Gyour\u001b[37Gmachine.\u001b[46GRecommended\u001b[58Gfor\u001b[62Gsolo\u001b[67Gdevelopers.\u001b[79GSkip\u001b[84Gif\u001b[87Gyou\u001b[91Gwork\u001b[96Gon\u001b[99Gmultiple\u001b[108Gclient\r\r\n│\u001b[3Gcodebases\u001b[13Gwhere\u001b[19Gcross-contamination\u001b[39Gwould\u001b[45Gbe\u001b[48Ga\u001b[50Gconcern.\r\r\n\r\r\n❯\u001b[3G1.\u001b[6GEnable\u001b[13Gcross-project\u001b[27Glearnings\u001b[37G(Recommended)\r\r\n\u001b[6GSearch\u001b[13Glearnings\u001b[23Gfrom\u001b[28Gall\u001b[32Gprojects\u001b[41Gon\u001b[44Gthis\u001b[49Gmachine\u001b[57G—\u001b[59Gsurfaces\u001b[68Gpatterns\u001b[77Gand\u001b[81Gpitfalls\u001b[90Gfrom\u001b[95Gprior\u001b[101Gsessions.\r\r\n\u001b[3G2.\u001b[6GKeep\u001b[11Glearnings\u001b[21Gproject-scoped\u001b[36Gonly\r\r\n\u001b[6GOnly\u001b[11Guse\u001b[15Glearnings\u001b[25Gfrom\u001b[30Gthis\u001b[35Gproject.\u001b[44GSafe\u001b[49Gfor\u001b[53Gmulti-client\u001b[66Genvironments.\r\r\n\u001b[3G3.\u001b[6GType\u001b[11Gsomething.\r\r\n────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\r\r\n\u001b[3G4.\u001b[6GChat\u001b[11Gabout\u001b[17Gthis\r\r\n\r\r\nEnter\u001b[7Gto\u001b[10Gselect\u001b[17G·\u001b[19G↑/↓\u001b[23Gto\u001b[26Gnavigate\u001b[35G·\u001b[37GEsc\u001b[41Gto\u001b[44Gcancel"; + const visible = stripAnsi(raw); + const question = capturePlanCountQuestion(visible, new Set(), 0, true)!; + expect(question.promptSnippet).toStartWith('Learnings D1 — Cross-project learnings scope'); + expect(question.promptSnippet).toContain(''); + expect(engStep0Boundary(question)).toBe(true); + const phase = planCountQuestionPhase(question, false, engStep0Boundary); + expect(phase).toEqual({ preReview: true, reviewStarted: true }); + }); + + test('keeps a long boxed question identity instead of its closing recommendation', () => { + const frame = [ + 'Planning: /tmp/hermetic/.claude/plans/review.md', + '─'.repeat(120), + '☐ Architecture', + '│ D2 — Architecture: custom retry scheduler vs library built-in ', + '│', + ...Array.from({ length: 12 }, (_, i) => `│ Review context line ${i}: the proposed retry behavior and its tradeoffs.`), + '│', + '│ Net: If the library hook is configurable, use the existing implementation.', + '❯1.Use library built-in (Recommended)', + '2.Extract shared retry envelope', + ].join('\r\r\n'); + const seen = new Set(); + const question = capturePlanCountQuestion(frame, seen, 0, false)!; + expect(question.promptSnippet).toStartWith('Architecture D2 — Architecture: custom retry scheduler'); + expect(question.promptSnippet).toContain(''); + expect(question.promptSnippet).not.toContain('Planning:'); + expect(question.promptSnippet.length).toBeLessThanOrEqual(240); + expect(capturePlanCountQuestion(frame + '\n' + '·'.repeat(6000), seen, 1, false)).toBeNull(); + }); + + test('does not reuse an old boxed header for a later unboxed menu', () => { + const visible = [ + '☐ Old setup', + 'D1 — Cross-project learnings scope', + '❯1.Enable', + '2.Skip', + 'Planning: /tmp/hermetic/.claude/plans/review.md', + 'D2 — Choose the retry behavior', + '❯1.Use library built-in', + '2.Extract shared retry envelope', + ].join('\n'); + const prompt = parseQuestionPrompt(visible); + expect(prompt).toBe('D2 — Choose the retry behavior'); + expect(prompt).not.toContain('Old setup'); + }); + + test('captures 1-line prompt above the cursor', () => { + const visible = ` + D1 — Pick a mode + + ❯ 1. HOLD SCOPE + 2. SCOPE EXPANSION + `; + const prompt = parseQuestionPrompt(visible); + expect(prompt).toBe('D1 — Pick a mode'); + }); + + test('captures multi-line prompt above the cursor', () => { + const visible = ` + D2 — Approach selection + + Which architecture should we follow? + + ❯ 1. Bypass existing helper + 2. Reuse existing helper + `; + const prompt = parseQuestionPrompt(visible); + // Multi-line prompts get joined with single spaces. + expect(prompt).toContain('D2 — Approach selection'); + expect(prompt).toContain('Which architecture should we follow?'); + }); + + test('returns "" when no cursor is rendered', () => { + expect(parseQuestionPrompt('Just some prose.\nNo cursor.')).toBe(''); + }); + + test('truncates to 240 chars', () => { + const longPrompt = 'A'.repeat(500); + const visible = `${longPrompt}\n\n ❯ 1. yes\n 2. no`; + expect(parseQuestionPrompt(visible).length).toBeLessThanOrEqual(240); + }); + + test('does not pull text from a previous numbered list above', () => { + const visible = ` + ❯ 1. previous answered question + 2. previous option two + + D2 — A new question text + + ❯ 1. fresh option A + 2. fresh option B + `; + const prompt = parseQuestionPrompt(visible); + // Stops at the previous numbered-list line; should NOT contain "previous answered question". + expect(prompt).toContain('D2 — A new question text'); + expect(prompt).not.toContain('previous answered question'); + }); + + test('normalizes whitespace (collapses runs of spaces and tabs)', () => { + const visible = `D1 — Spaced out + + ❯ 1. yes + 2. no`; + expect(parseQuestionPrompt(visible)).toBe('D1 — Spaced out'); + }); + + test('inline-cursor box-layout: extracts prompt text BEFORE ❯1. on the cursor line', () => { + // Real /plan-ceo-review rendering: divider + ☐ header + prompt text + + // cursor are all on one logical line because TTY cursor-positioning + // escapes collapse the box layout under stripAnsi. + const visible = [ + '──────────────────', + '☐ Review scope What scope do you want me to CEO-review? ❯ 1. The branch\'s diff vs main', + '2. A specific plan file', + '3. An idea inline', + ].join('\n'); + const prompt = parseQuestionPrompt(visible); + // Should extract "Review scope" and the prompt text, dropping the ☐ box-drawing sigil. + expect(prompt).toContain('Review scope'); + expect(prompt).toContain('What scope do you want me to CEO-review?'); + 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:'); + }); + + 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'); + }); + + 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', () => { + test('returns the same fingerprint for identical inputs', () => { + const opts = [ + { index: 1, label: 'A' }, + { index: 2, label: 'B' }, + ]; + expect(auqFingerprint('hello', opts)).toBe(auqFingerprint('hello', opts)); + }); + + test('different prompts with shared option labels produce DIFFERENT fingerprints', () => { + // The collision regression Codex F1 caught: option-label-only fingerprints + // collapsed multiple distinct findings into one when they shared menu shape. + const sharedOpts = [ + { index: 1, label: 'Add to plan' }, + { index: 2, label: 'Defer' }, + { index: 3, label: 'Build now' }, + ]; + const fpFinding1 = auqFingerprint('D5 — Architecture: bypass helper?', sharedOpts); + const fpFinding2 = auqFingerprint('D6 — Tests: zero coverage?', sharedOpts); + expect(fpFinding1).not.toBe(fpFinding2); + }); + + test('same prompt with different options produces DIFFERENT fingerprints', () => { + const prompt = 'D1 — Pick a mode'; + const fpA = auqFingerprint(prompt, [ + { index: 1, label: 'HOLD SCOPE' }, + { index: 2, label: 'SCOPE EXPANSION' }, + ]); + const fpB = auqFingerprint(prompt, [ + { index: 1, label: 'HOLD SCOPE' }, + { index: 2, label: 'SCOPE REDUCTION' }, + ]); + expect(fpA).not.toBe(fpB); + }); + + test('whitespace-only differences in prompt do NOT change the fingerprint', () => { + // Same content, different rendering whitespace (TTY redraw artifact) + // must produce the same fingerprint so dedupe survives reflow. + const opts = [{ index: 1, label: 'A' }, { index: 2, label: 'B' }]; + const fpA = auqFingerprint('Pick a mode', opts); + const fpB = auqFingerprint('Pick a mode', opts); + expect(fpA).toBe(fpB); + }); + + test('empty prompt + same options collide (caller must guard against this)', () => { + // Documents the contract: empty-prompt fingerprints WILL collide if the + // caller fingerprints them. runPlanSkillCounting must skip empty-prompt + // AUQs and re-poll instead. + const opts = [{ index: 1, label: 'A' }]; + expect(auqFingerprint('', opts)).toBe(auqFingerprint('', opts)); + }); +}); + +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? │ `${i === 0 ? '❯' : ''}${i + 1}.${label}`).join('\n'); + const frame = `${capture.prompt}\n${options}`; + const seen = new Set(); + 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(); + 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(); + 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? ', + 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('file permission lifecycle replay', () => { + const permission = (file = 'gstack-test-plan-design.md') => [ + `Do you want to make this edit to ${file}?`, + '❯ 1. Yes', + '2.Yes,andswitchtoacceptedits(auto-approvefileeditsandcommonfilecommands)forthissession;Yes,and', + 'alwaysallowaccessto/tmp/fixtureforthissession', + '3.No', + 'Esctocancel·Tabtoamend', + ].join('\n'); + + test('ignores the granted menu and its redraw until a new request follows file-tool completion', () => { + const guard = createPlanCountPermissionGuard(); + const first = permission(); + expect(guard(first)).toBe('grant'); + expect(guard(first)).toBe('handled'); + const redraw = first + '\n' + permission(); + expect(guard(redraw)).toBe('handled'); + const completed = redraw + '\n●Write(/tmp/fixture/gstack-test-plan-design.md)\n' + + '⎿ Wrote320linesto../fixture/gstack-test-plan-design.md\n' + '·'.repeat(1600); + expect(classifyPlanCountFrame(completed)).toBeNull(); + expect(guard(completed)).toBe('handled'); + expect(guard(completed + '\n' + permission())).toBe('grant'); + }); + + test('singular native Write/Edit results release a fresh identical permission', () => { + for (const result of ['⎿ Added1line,removed1line', '⎿ Wrote1lineto../fixture/plan.md', '⎿ Removed1line', '⎿\u00a0Wrote320linesto../fixture/plan.md']) { + const guard = createPlanCountPermissionGuard(); + const first = permission(); + expect(guard(first)).toBe('grant'); + const completed = first + '\n' + result; + expect(guard(completed)).toBe('handled'); + expect(guard(completed + '\n' + permission())).toBe('grant'); + } + }); + + test('the captured active Edit menu remains a permission behind a long diff repaint', () => { + const visible = permission('gstack-test-plan-ceo.md') + '\n' + + ' 89 +The plan adds StripePaymentWebhookHandler outside WebhookDispatcher.\n'.repeat(40); + expect(visible.length).toBeLessThan(4096); + expect(classifyPlanCountFrame(visible)).toBeNull(); // The old 1.5 KB scan misses it. + const guard = createPlanCountPermissionGuard(); + expect(guard(visible)).toBe('grant'); + expect(guard(visible)).toBe('handled'); + }); + + test('a completed Write invalidates an old menu even if polling missed the original grant', () => { + const visible = permission() + '\n⎿ Wrote320linesto../fixture/gstack-test-plan-design.md'; + expect(createPlanCountPermissionGuard()(visible)).toBe('handled'); + }); + + test('proposed results and tool headers do not release the same pending permission', () => { + const guard = createPlanCountPermissionGuard(); + let visible = permission(); + expect(guard(visible)).toBe('grant'); + for (const line of ['320 +⎿ Wrote320lines', '●Write(/tmp/fixture/plan.md)', '⎿ Tip: use /btw', '⎿ Error: denied']) { + visible += '\n' + line + '\n' + permission(); + expect(guard(visible)).toBe('handled'); + } + }); + + test('a different file and a genuine native file-policy question retain their own input', () => { + const guard = createPlanCountPermissionGuard(); + const first = permission('first.md'); + expect(guard(first)).toBe('grant'); + expect(guard(first + '\n' + permission('FIRST.md'))).toBe('grant'); // Targets remain case-sensitive. + expect(guard(first + '\n' + permission('second.md'))).toBe('grant'); + const question = '\n☐ File policy\nDo you want to create first.md?\n❯1.Yes\n2.No\n' + + 'Enter to select · ↑/↓ to navigate · Esc to cancel'; + expect(guard(first + question)).toBeNull(); + expect(capturePlanCountQuestion(first + question, new Set(), 0, true)?.promptSnippet).toContain('File policy'); + }); +}); + +describe('completed permission cannot become a queued review answer (captured G)', () => { + // Exact captured post-Write frame; the temporary repository path is sanitized. + // The damaged "wat" came from the CLI redraw, not the underlying question. + const captured = " real,specificgaps (Visual Hierachy, Spacing, Color, Typography,Motion)\r22-Itpreservesstrongaccessibilityandresponsivespecsfromtheexistingbehaviordescription\r23 -DESIGN.md exist andsuppliescorrectvaluesforall5gaps\r24\r 25 A 10/10would:\r26-Specifytheexactchangeforeachgap(concretetoken,before→after)\r27 -Add intection state table(loading,empty,error,success,partial)\r28-Storyboardtheuserjourney\r 29- Call out which DESIGN.md tokens each fix applies\r30-Resolveallimplementationambiguitiesbeforeadeveloperpicksitup\r31\r32 **DESIGN.md status:** Presnt and detailed. Spacing scale, clor system, typography,interacton patterns,andc\r omponentbehavior re all specified. This review calbrates against it.\r33\r34 **Exsting design lverage:**\r35 -Button, Field, InlineStatus,ErrorSummary,ConfirmationDialogcomponentsreusedas-is\r36-Focusring:2pxsolid#1d4ed8,offset2px(alreadyspecified)\r37 -8px bae spacingscalealreadyestablished\r38\r39 ---\r40\r41 ## Review Passes\r42\r43*(Populatedprogressivelyasthereviewproceeds)*\r44\r╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌\r Do you wat to create gstack-test-plan-design.md?\r❯ 1. Yes\r2.Yes,andswitchtoacceptedits(auto-approvefileeditsandcommonfilecommands)for this session; Yes,an\r always allow access to /tmp/fixture for this session\r 3.Nohift+tab)\r\rEsc to cancl ·Tab to amend\u000f\u001b[?1000h\u001b[?1002h\u001b[?1003h\u001b[?1006h\rRan1shellcommand\r\r●Scopegate:planmodeactive—auto-selectingB(reviewingPLAN.mdintheworkingdirectory).\r\rSkippingroutinginjection—thisisareviewfixturerepo.Nowrunningthepre-reviewaudit.\r\rRead2 files, ran1 shell command\r\r●Now let me read the review sectons fil as required by theskill.\r\rRead file, ran 1shellcommand\r\r●Nopriorlearnings found. Cross-project config isnot set — will ask once Ihavetheuser'sattention.\r\rPreReview System Audit:\r- DESIGN.md:exists and comprehensive (spacing, typography, color, interactionpatternsallspecified)\r- UIscope:APPUIclassifier—account-settingsformwithProfile+Notificationssections\r- 5 gaps explicily listed in the plan; l have corresponding specin DESIGN.md\r-N rior design reviews inth reviewlog\r- NoTODOS.mdexists\r\rStep0 Assessment:\r\rInitial scor: 5/10. The plan correctly identifies5realgapsandpreservesstrongaccessibilityandresponsivespecs\rinheritedfromtheexistingbehaviordescription.A10/10wouldspecifyHOWeachgapisresolved(concretecomponent\rchanges,tokenvalues,before/afterlayout),notjustnametheproblem.TheDESIGN.mdalreadyhasallcorrectvalues;\rtheplanjustneedstoreferencethem.\r\rWritingtheinitialplanfilenowbeforeaskingStep0D.\r\r●Write(/tmp/fixture/gstack-test-plan-design.md)\r\r✢ Undulatig… (2m 0s ·↓ 5.4 okens)\r ⎿  Tip:Use/btwtoaskaquicksidequestionwithoutinterruptingClaude'scurrentwork\r ● high · /effort\r────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\r❯ \r────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\r ⏸ plan mod on(shift+tab tocycle)·esctointerrupt·←foragents\u001b[?25h\u001b[?25l\r●No prior earnings found.Cross-projectconfigisnotset—willaskonceIhavetheuser'sattention.\r Pre-Review System Audit:\r-DESIGN.md:existsandcomprehensive(spacing,typography,color,interactionpatternsallspecified)\r- UI scope: APP UI classifier — account-settings fomwith Prfile + Notifications sctions\r-5gapsexplicitlylistedintheplan;allhavecorrespondingspecinDESIGN.md\r- Noprior desigreviwsin the reviewlog\r-NoTODOS.mdexists\r\rStep0Assessment:\r\rInitialscore:5/10.Theplancorrectlyidentifies5realgapsandpreservesstrongaccessibilityandresponsivespecs\r inheted from theexisting behavir desription.A 10/10would specify HOWechgapi eolved (ccretecomponent\rchanges,tokenvalues,before/afterlayout),notjustnametheproblem.TheDESIGN.mdalreadyhasallcorrectvalues;\rth plan just neds to referencethem.\r\rWriting theinitial plan lenow before ask Step 0D.\r\r●Write(/tmp/fixture/gstack-test-plan-design.md)\r⎿ Wrote 44 lines"; + + test('a missed grant followed by native Write completion sends no stale answer', () => { + const guard = createPlanCountPermissionGuard(); + expect(classifyPlanCountFrame(captured)).toBeNull(); + expect(guard(captured)).toBe('handled'); + expect(capturePlanCountQuestion(captured, new Set(), 0, true)).toBeNull(); + }); + + test('the same damaged active file permission grants once and never counts as a finding', () => { + const menu = captured.slice(captured.indexOf('Do you wat'), captured.indexOf('Esc to cancl')) + 'Esc to cancl ·Tab to amend'; + const guard = createPlanCountPermissionGuard(); + expect(guard(menu)).toBe('grant'); + expect(guard(menu)).toBe('handled'); + expect(capturePlanCountQuestion(menu, new Set(), 0, false)).toBeNull(); + const completed = menu + '\n⎿ Wrote 44 lines'; + expect(guard(completed)).toBe('handled'); + expect(guard(completed + '\n' + menu)).toBe('grant'); + }); + + test('plain legacy file decisions remain questions without native permission controls', () => { + const frame = 'Do you want to create first.md?\n❯1.Create the reviewed file\n2.Keep the current layout'; + expect(classifyPlanCountFrame(frame)).toBeNull(); + expect(createPlanCountPermissionGuard()(frame)).toBeNull(); + expect(capturePlanCountQuestion(frame, new Set(), 0, false)?.options).toHaveLength(2); + }); + + test('a matching native finding can discuss file permissions without being consumed', () => { + const question = { + header: 'File policy', + question: 'Should we create a file that documents always allow access to the project?', + options: [{ label: 'Create it' }, { label: 'Keep current policy' }], + }; + const pending = { sessionId: 'file-policy', toolUseId: 'file-finding', answered: false, questions: [question] }; + const frame = captured + '\n☐ ' + question.header + '\n' + question.question + + '\n❯1.Create it\n2.Keep current policy\nEnter to select · ↑/↓ to navigate · Esc to cancel'; + expect(classifyPlanCountFrame(frame)).toBeNull(); + expect(createPlanCountPermissionGuard()(frame)).toBeNull(); + expect(capturePlanCountQuestion(frame, new Set(), 0, false, pending)?.nativeCall).toBe(pending); + expect(capturePlanCountQuestion(frame, new Set(), 0, false)?.promptSnippet).toContain('File policy'); + }); +}); + +describe('native question identity outranks permission wording', () => { + const question = { + header: 'File policy', + question: 'D1 — Should we create a file that documents always allow access to the project? ', + options: [{ label: 'Create it' }, { label: 'Keep current policy' }], + }; + const pending = { sessionId: 'file-policy', toolUseId: 'finding', answered: false, questions: [question] }; + const frame = '☐ File policy\n' + question.question + '\n❯1.Create it\n2.Keep current policy\nEnter to select · ↑/↓ to navigte · Esc to cancel'; + test('full native question and every option establish identity despite a damaged footer', () => { + // This is the formerly conflicting pure classifier result. The counting + // loop must consult native identity before taking its permission action. + expect(classifyPlanCountFrame(frame)).toBe('permission'); + expect(matchesNativePlanQuestion(frame, pending)).toBe(true); + const seen = new Set(); + expect(capturePlanCountQuestion(frame, seen, 0, false, pending)?.nativeCall).toBe(pending); + expect(capturePlanCountQuestion(frame, seen, 1, false, pending)).toBeNull(); + }); + test('same header, changed choices, missing identity and an overlaid real permission cannot borrow a native call', () => { + for (const different of [ + frame.replace('Should we create a file', 'Should we delete the file'), + frame.replace('2.Keep current policy', '2.Allow all edits'), + frame.replace(question.question, 'A different question with the same header?'), + frame + '\nDo you want to create actual.md?\n❯1.Yes\n2.Yes, and switch to accept edits (auto-approve file edits and common file commands) for this session (shift+tab)\n3.No\nEsc to cancel · Tab to amend', + ]) expect(matchesNativePlanQuestion(different, pending)).toBe(false); + expect(capturePlanCountQuestion(frame, new Set(), 0, false, { ...pending, failed: true })).toBeNull(); + expect(capturePlanCountQuestion(frame, new Set(), 0, false)).toBeNull(); + }); +}); diff --git a/test/helpers/claude-pty-runner.unit.test.ts b/test/helpers/claude-pty-runner.boundaries.unit.test.ts similarity index 53% rename from test/helpers/claude-pty-runner.unit.test.ts rename to test/helpers/claude-pty-runner.boundaries.unit.test.ts index db92bd2d0..94d22c736 100644 --- a/test/helpers/claude-pty-runner.unit.test.ts +++ b/test/helpers/claude-pty-runner.boundaries.unit.test.ts @@ -1,56 +1,13 @@ /** - * Deterministic unit tests for claude-pty-runner.ts behavior changes. - * - * Free-tier (no EVALS=1 needed). Runs in <1s on every `bun test`. Catches - * harness plumbing bugs before stochastic PTY runs surface them. - * - * Two surface areas tested: - * - * 1. Permission-dialog short-circuit in 'asked' classification: a TTY frame - * that matches BOTH isPermissionDialogVisible AND isNumberedOptionListVisible - * must NOT be classified as a skill question — permission dialogs render - * as numbered lists too, but they're not what we're guarding. - * - * 2. Env passthrough surface: runPlanSkillObservation accepts an `env` - * option and threads it to launchClaudePty. We can't fully exercise the - * spawn pipeline without paying for a PTY session, but we CAN verify the - * option exists in the type signature and that calling without env still - * works (no regression). - * - * The PTY test (skill-e2e-plan-ceo-plan-mode.test.ts) is the integration - * check; this file is the cheap deterministic guard for the harness primitives - * those tests stand on. + * Deterministic unit tests for the per-skill boundary predicates (test/helpers/pty/boundaries.ts). + * Split along the W4 module seams from the former claude-pty-runner.unit.test.ts; + * tests import the public barrel, test/helpers/claude-pty-runner.ts. */ - import { describe, test, expect } from 'bun:test'; -import { readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { pathToFileURL } from 'node:url'; +import { readFileSync } from 'node:fs'; import { - isPermissionDialogVisible, - isNumberedOptionListVisible, - isProseAUQVisible, - isScopeGateQuestionVisible, - isScopeGateAutoSelectVisible, - isPlanReadyVisible, - isAutoDecidedVisible, - parseNumberedOptions, - classifyVisible, - TAIL_SCAN_BYTES, - optionsSignature, - parseQuestionPrompt, - stripAnsi, auqFingerprint, - COMPLETION_SUMMARY_RE, - classifyPlanCountFrame, capturePlanCountQuestion, - matchesNativePlanQuestion, - createPlanCountPermissionGuard, - planCountPrerequisitePick, - planCountSubmissionInput, - assertReviewReportAtBottom, ceoStep0Boundary, engStep0Boundary, engSetupAUQ, @@ -59,1575 +16,9 @@ import { planCountQuestionPhase, nativePlanCallFingerprint, devexStep0Boundary, - type ClaudePtyOptions, type AskUserQuestionFingerprint, } from './claude-pty-runner'; -describe('saved preference annotation', () => { - test('recognizes the explicit preference attribution from the timed-out CEO capture', () => { - const visible = 'Now I have a clear picture of the branch. Let me proceed with the full review. ' + - 'Mode is HOLD SCOPE (auto-decided from plan-tune preference).'; - expect(isAutoDecidedVisible(visible)).toBe(true); - expect(classifyVisible(visible)?.outcome).toBe('auto_decided'); - expect(classifyVisible(visible.replace(/\s+/g, ''))?.outcome).toBe('auto_decided'); - }); - - test('retains the canonical annotation and its precedence over plan-ready', () => { - const visible = 'Auto-decided review mode → HOLD SCOPE (your preference). Change with /plan-tune.\nReady to execute?'; - expect(classifyVisible(visible)?.outcome).toBe('auto_decided'); - }); - - test('does not equate an unrequested choice or plan-tune advice with a saved preference', () => { - for (const visible of [ - 'Mode is HOLD SCOPE (AUTO_DECIDED).', - 'I auto-decided HOLD SCOPE because this is a refactor.', - 'I auto-decided HOLD SCOPE. You can set a plan-tune preference later.', - 'Mode is HOLD SCOPE (not auto-decided from plan-tune preference).', - 'Mode is HOLD SCOPE (will be auto-decided from plan-tune preference).', - ]) expect(isAutoDecidedVisible(visible)).toBe(false); - }); -}); - -describe('mode option rendering', () => { -}); - -describe('isPermissionDialogVisible', () => { - test('matches "Bash command requires permission" prompts', () => { - const sample = ` - Some preamble output - - Bash command \`gstack-config get telemetry\` requires permission to run. - - ❯ 1. Yes - 2. Yes, and always allow - 3. No, abort - `; - expect(isPermissionDialogVisible(sample)).toBe(true); - }); - - test('matches "allow all edits" file-edit prompts', () => { - // Isolated to the "allow all edits" clause only — no overlapping - // "Do you want to proceed?" co-trigger, so this asserts the clause works. - const sample = ` - Edit to ~/.gstack/config.yaml - - ❯ 1. Yes - 2. Yes, allow all edits during this session - 3. No - `; - expect(isPermissionDialogVisible(sample)).toBe(true); - }); - - test('matches the "Do you want to proceed?" file-edit confirmation by itself', () => { - // Separate fixture so weakening this clause is detected by a dedicated test. - const sample = ` - Edit to ~/.gstack/config.yaml - - Do you want to proceed? - - ❯ 1. Yes - 2. No - `; - expect(isPermissionDialogVisible(sample)).toBe(true); - }); - - test('matches workspace-trust "always allow access to" prompt', () => { - const sample = ` - Do you trust the files in this folder? - - ❯ 1. Yes, proceed - 2. Yes, and always allow access to /Users/me/repo - 3. No, exit - `; - 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('the captured paired-CEO Edit grant is permission, not another review finding', () => { - const sample = [ - 'Do youwt to makehis dittogstack-test-plan-ceo-paired.md?', - '❯1.Yes', - '2.Yes,andswitchtoacceptedits(auto-approvefileeditsandcommonfilecommands)forthissession;Yes,and', - 'alwaysallowaccessto/tmp/gstack-paid-shard-EbUl9j/tmp/gstack-e2e-plan-ceo-paired-gkjAd5forthissession', - '(shift+tab)', '3.No', 'Esctocancel·Tabtoamend', - ].join('\r'); - expect(isPermissionDialogVisible(sample)).toBe(true); - expect(classifyPlanCountFrame(sample)).toBe('permission'); - }); - - 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? - - ❯ 1. Yes, validated - 2. No, premise is wrong - 3. Need more info - `; - expect(isPermissionDialogVisible(sample)).toBe(false); - }); - - test('does NOT match a plan-ready confirmation', () => { - const sample = ` - Ready to execute the plan? - - ❯ 1. Yes - 2. No, keep planning - `; - expect(isPermissionDialogVisible(sample)).toBe(false); - }); - - test('does NOT match a skill question that contains the bare phrase "Do you want to proceed?"', () => { - // Co-trigger requirement: "Do you want to proceed?" alone is not enough. - // It must appear with "Edit to " or "Write to " to count as - // a permission dialog. This guards against a skill question like - // "Do you want to proceed with HOLD SCOPE?" being mis-classified. - const sample = ` - Choose your scope mode for this review. - Do you want to proceed? - - ❯ 1. HOLD SCOPE - 2. SCOPE EXPANSION - 3. SELECTIVE EXPANSION - `; - expect(isPermissionDialogVisible(sample)).toBe(false); - }); - - test('does NOT mis-match when adversarial prose includes "Edit to " alongside the bare proceed phrase', () => { - // Adversarial fixture: a skill question whose body legitimately mentions - // "Edit to " in prose AND ends with "Do you want to proceed?". The - // current co-trigger regex would mis-classify this as a permission - // dialog. We DO want this test to fail until the regex is tightened - // further (e.g., proximity constraint, or anchoring "Edit to" to a - // line-start). For now this is documented as a known limitation: a - // skill question that talks about "Edit to" in prose IS still treated - // as a permission dialog. The test asserts the current behavior so a - // future fix can flip it intentionally. - const sample = ` - Plan: I will Edit to ./plan.md to capture the decision. - Do you want to proceed? - - ❯ 1. HOLD SCOPE - 2. SCOPE EXPANSION - `; - // KNOWN LIMITATION: the co-trigger fires here. Documented as a - // post-merge follow-up. Flip this assertion once the regex tightens. - expect(isPermissionDialogVisible(sample)).toBe(true); - }); - - test('matches the captured Autoplan settings-overwrite card as a numbered permission dialog', () => { - const captured = JSON.parse(readFileSync(new URL('../fixtures/autoplan-settings-overwrite.json', import.meta.url), 'utf8')); - expect(isNumberedOptionListVisible(captured.frame.text)).toBe(true); - expect(isPermissionDialogVisible(captured.frame.text)).toBe(true); - }); -}); - -describe('isNumberedOptionListVisible', () => { - test('matches a basic ❯ 1. + 2. cursor list', () => { - const sample = ` - ❯ 1. Option one - 2. Option two - 3. Option three - `; - expect(isNumberedOptionListVisible(sample)).toBe(true); - }); - - test('returns false on a single-option prompt', () => { - const sample = ` - ❯ 1. Only option - `; - expect(isNumberedOptionListVisible(sample)).toBe(false); - }); - - test('returns false when no cursor renders', () => { - const sample = ` - Just some prose with 1. a numbered point and 2. another. - `; - expect(isNumberedOptionListVisible(sample)).toBe(false); - }); - - test('overlaps permission dialogs (this is why D5 short-circuits)', () => { - // The whole point of D5: this string matches BOTH classifiers, so the - // runner must consult isPermissionDialogVisible to disambiguate. - const sample = ` - Bash command \`do-thing\` requires permission to run. - - ❯ 1. Yes - 2. No - `; - expect(isNumberedOptionListVisible(sample)).toBe(true); - expect(isPermissionDialogVisible(sample)).toBe(true); - }); -}); - -describe('scope-gate render detectors', () => { - // The verbatim announcement string from the plan-eng/plan-design SKILL.md - // templates. If the template rewording drifts, THIS fixture fails first — - // before the paid plan-mode smokes silently degrade to vacuous asserts. - const TEMPLATE_ANNOUNCEMENT = - 'Scope gate: plan mode — auto-selected B (reviewing ).'; - - describe('isScopeGateQuestionVisible', () => { - test('matches the clean prose gate render (question + option bodies)', () => { - const sample = ` -What should I review? -A) The current branch diff — the work in progress on this branch. -B) A plan or design doc I'll paste or point you to. -C) A specific file, directory, or path. -Recommendation: A when a branch diff exists, otherwise B. -`; - expect(isScopeGateQuestionVisible(sample)).toBe(true); - }); - - test('matches the native numbered render (no lettered markers)', () => { - const sample = ` - What should I review? - - ❯ 1. The current branch diff — the work in progress on this branch. - 2. A plan or design doc I'll paste or point you to. - 3. A specific file, directory, or path. -`; - expect(isScopeGateQuestionVisible(sample)).toBe(true); - }); - - test('matches the PTY-collapsed render (stripAnsi squished spaces)', () => { - const sample = 'WhatshouldIreview?A)Thecurrentbranchdiff—theworkinprogress'; - expect(isScopeGateQuestionVisible(sample)).toBe(true); - }); - - test('stays false on narration quoting only the question', () => { - const sample = - "Normally I'd ask 'What should I review?' but plan mode is active, so I'm proceeding."; - expect(isScopeGateQuestionVisible(sample)).toBe(false); - }); - - test('stays false on unrelated review prose', () => { - const sample = 'I will review the current branch diff and report findings.'; - expect(isScopeGateQuestionVisible(sample)).toBe(false); - }); - }); - - describe('isScopeGateAutoSelectVisible', () => { - test('matches the verbatim template announcement', () => { - expect(isScopeGateAutoSelectVisible(TEMPLATE_ANNOUNCEMENT)).toBe(true); - }); - - test('matches a real announcement with a concrete target', () => { - const sample = - 'Scope gate: plan mode — auto-selected B (reviewing ~/.claude/plans/my-feature.md). Running the Design Doc Check next.'; - expect(isScopeGateAutoSelectVisible(sample)).toBe(true); - }); - - test('matches the PTY-collapsed announcement', () => { - const sample = 'Scopegate:planmode—auto-selectedB(reviewingPLAN.md).'; - expect(isScopeGateAutoSelectVisible(sample)).toBe(true); - }); - - test('stays false on narration about the behavior', () => { - const sample = "In plan mode I'd auto-select B and review the active plan."; - expect(isScopeGateAutoSelectVisible(sample)).toBe(false); - }); - - test('stays false on a VERBATIM QUOTE of the announcement (negation narration)', () => { - // The exact announcement line sits quoted in the skill context, so a - // model explaining why it is NOT firing it can reproduce it byte-exact - // inside quotes — that must not trip a must-stay-false assert. - const sample = - 'Not in plan mode, so I won\'t announce "Scope gate: plan mode — auto-selected B (reviewing )." and will ask instead.'; - expect(isScopeGateAutoSelectVisible(sample)).toBe(false); - }); - - test('a later real render still matches after an earlier quoted mention', () => { - const sample = - 'Earlier I said I would render "Scope gate: plan mode — auto-selected B (…)" and now:\n' + - 'Scope gate: plan mode — auto-selected B (reviewing PLAN.md).'; - expect(isScopeGateAutoSelectVisible(sample)).toBe(true); - }); - - test('matches tense paraphrases WITH the announcement prefix (auto-selecting / auto-selects)', () => { - expect( - isScopeGateAutoSelectVisible('Scope gate: plan mode — auto-selecting B (reviewing the drafted plan).'), - ).toBe(true); - expect(isScopeGateAutoSelectVisible('Scope gate: plan mode — auto-selects B.')).toBe(true); - }); - - test('stays false on tense paraphrases WITHOUT the announcement prefix', () => { - expect(isScopeGateAutoSelectVisible('Auto-selecting B since we are in plan mode.')).toBe(false); - }); - - test('stays false on AUTO_DECIDE preamble output', () => { - const sample = 'Auto-decided scope question → B (your preference). Change with /plan-tune.'; - expect(isScopeGateAutoSelectVisible(sample)).toBe(false); - }); - - test('stays false on a bare "selected B" without the announcement prefix', () => { - const sample = 'I selected B as the review target.'; - expect(isScopeGateAutoSelectVisible(sample)).toBe(false); - }); - }); -}); - -describe('isProseAUQVisible', () => { - test('matches 4 lettered options A) B) C) D) at line starts (plan-eng prose AUQ shape)', () => { - const sample = ` -What would you like me to review? Options: -A) Point me at an existing design doc or plan file (path). -B) Describe new work you're planning — I'll explore the codebase. -C) You meant /review for the diff already on this branch. -D) Something else (tell me). -Recommendation: A if you have a doc in mind, otherwise B. -❯ -`; - expect(isProseAUQVisible(sample)).toBe(true); - }); - - test('matches 2 lettered options (minimum threshold)', () => { - const sample = ` -A) First option -B) Second option -`; - expect(isProseAUQVisible(sample)).toBe(true); - }); - - test('matches 3 numbered options 1. 2. 3. without ❯ 1. cursor (autoplan prose AUQ shape)', () => { - const sample = ` -What's the task? A few options: - 1. You have a plan idea in mind — describe it. - 2. You want to review an existing plan elsewhere. - 3. You meant a different command — /plan-ceo-review etc. -❯ -`; - expect(isProseAUQVisible(sample)).toBe(true); - }); - - test('returns false when ❯ 1. cursor is present in the recent tail (native UI handled by isNumberedOptionListVisible)', () => { - const sample = ` -❯ 1. First option - 2. Second option - 3. Third option -`; - expect(isProseAUQVisible(sample)).toBe(false); - }); - - test('does NOT suppress numbered-prose detection when ❯ 1. is only in early scrollback (trust dialog)', () => { - // Boot trust dialog rendered ❯ 1. Yes at startup, then a long body of - // model output, then prose-rendered numbered options now. The historic - // ❯ 1. is in the full buffer but NOT in the recent tail. Should detect - // the prose AUQ. - const trustHeader = '❯ 1. Yes, trust\n 2. No\n'; - const filler = 'x'.repeat(5000); // pushes trust dialog out of last 4KB tail - const proseAUQ = `\n 1. Review the docs\n 2. Investigate the code\n 3. Defer to next session\n❯ \n`; - const sample = trustHeader + filler + proseAUQ; - expect(isProseAUQVisible(sample)).toBe(true); - }); - - test('returns false on single lettered option', () => { - const sample = ` -A) Only one option mentioned in passing. -`; - expect(isProseAUQVisible(sample)).toBe(false); - }); - - test('matches 2 numbered options (threshold matches lettered branch — tails miss option 1)', () => { - const sample = ` -1. First note. -2. Second note. -`; - expect(isProseAUQVisible(sample)).toBe(true); - }); - - test('returns false on a single numbered option', () => { - const sample = ` -1. Only one option mentioned. -`; - expect(isProseAUQVisible(sample)).toBe(false); - }); - - test('does not match mid-prose lettered text like "(see option B) above"', () => { - const sample = ` -This refers to (see option B) above and also to point A) earlier. -`; - // The B) and A) markers are mid-line, not at line starts, so they don't count. - expect(isProseAUQVisible(sample)).toBe(false); - }); - - test('matches with leading whitespace and ❯ prefix on options', () => { - const sample = ` - A) Option with whitespace prefix -❯ B) Option with cursor prefix - C) Another option -`; - expect(isProseAUQVisible(sample)).toBe(true); - }); - - test('returns false on plain text with no option markers', () => { - expect(isProseAUQVisible('Just some plain text output from the model.')).toBe(false); - expect(isProseAUQVisible('')).toBe(false); - }); - - // Pattern 3: markdown bold-bullet options — office-hours renders its mode - // question this way under --disallowedTools, with no letter/number marker. - test('matches office-hours markdown bold-bullet mode question (Pattern 3)', () => { - const sample = ` -> Before we dig in — what's your goal with this? -> -> - **Building a startup** (or thinking about it) -> - **Intrapreneurship** — internal project at a company, need to ship fast -> - **Hackathon / demo** — time-boxed, need to impress -> - **Open source / research** — building for a community -> - **Learning** — teaching yourself to code -❯ -`; - expect(isProseAUQVisible(sample)).toBe(true); - }); - - test('bold-bullets require a preceding interrogative — no "?" => false', () => { - // 3+ bold bullets but no question stem: this is a feature list, not an AUQ. - const sample = ` -Here is what shipped: -- **Faster builds** via caching -- **Smaller binaries** through tree-shaking -- **Better errors** with source maps -`; - expect(isProseAUQVisible(sample)).toBe(false); - }); - - test('a question with fewer than 3 bold bullets stays false (guard)', () => { - const sample = ` -Which approach do you prefer? -- **Option one** is simpler -- **Option two** is faster -`; - expect(isProseAUQVisible(sample)).toBe(false); - }); - - test('plain (non-bold) bullets after a question do not trigger Pattern 3', () => { - // Only bold bullets count — plain "- text" prose lists are too common. - const sample = ` -What should we do about this? -- run the tests -- ship the fix -- file a follow-up -`; - expect(isProseAUQVisible(sample)).toBe(false); - }); - - test('Pattern 3 still defers to a live native cursor list (❯ 1.)', () => { - const sample = ` -> What's your goal? -❯ 1. **Building a startup** - 2. **Intrapreneurship** - 3. **Hackathon** -`; - // The ❯1. cursor gate fires first — native list handling owns this. - expect(isProseAUQVisible(sample)).toBe(false); - }); - - // Pattern 4/5: collapsed-form prose AUQ. stripAnsi destroys the newlines + - // inter-word spaces, so a real prose AUQ arrives collapsed and defeats the - // line-anchored Patterns 1-3. These are the dominant Shape-B render mode in - // the plan-design smoke + floor timeouts — verbatim de-spinnered bytes from - // the real failing runs (bdm3sucql.output). - test('matches the real collapsed floor render (colon-delimited, Pattern 4/5)', () => { - const sample = - 'The review is blocked on D1—reply withA, B, r Cabovetocontinue:' + - '- A(recommended): Spec thefull P1AskUserQuestioncopy in this review' + - '-B:LeaveP1copytotheimplementerwithstructuralrequirements' + - 'C: Add a placeholder template to the plan'; - expect(isProseAUQVisible(sample)).toBe(true); - }); - - test('matches the real collapsed plan-mode render (Recommendation + collapsed A)/B), Pattern 4/5)', () => { - const sample = - 'Recommendation:A—writethecopynow.(recommended)A) Writ the fullcopy in thisdesign review— now.' + - '(recommended) Completeness:10/10 B) Leveit to theimplemente — task spec is enough.' + - 'Reply withA (write the copy now)orB(leavetoimplementer)'; - expect(isProseAUQVisible(sample)).toBe(true); - }); - - test('collapsed-form requires BOTH signals — single B) + word "recommendation" stays false', () => { - // Only one punctuated letter marker: the two-signal contract is not met. - const sample = - 'We should consider option B) here. My recommendation is to do it now.'; - expect(isProseAUQVisible(sample)).toBe(false); - }); - - test('collapsed-form requires letter punctuation — comma-only "ReplywithA,B,orC" stays false', () => { - // Reply-instruction present, but the letters carry no ) : or ( punctuation, - // so they could be incidental enumerations in running prose. Stays false. - const sample = 'ReplywithA,B,orC'; - expect(isProseAUQVisible(sample)).toBe(false); - }); - - test('collapsed-form does not regress the existing FP guard (see option B) ... point A))', () => { - // The classic citation FP: a model referencing prior options in prose. - // No reply-instruction / recommendation marker on its own line, so the - // collapsed-form signal does not fire either. - const sample = - 'As noted (see option B) above, and the earlier point A) we discussed, this is fine.'; - expect(isProseAUQVisible(sample)).toBe(false); - }); -}); - -describe('classifyVisible (runtime path through the runner classifier)', () => { - // These tests call the actual classifier so a future contributor who - // reorders branches (e.g. moves the permission short-circuit before - // isPlanReadyVisible) is caught deterministically. - - test('skill question → returns asked', () => { - const visible = ` - D1 — Choose your scope mode - - ❯ 1. HOLD SCOPE - 2. SCOPE EXPANSION - 3. SELECTIVE EXPANSION - 4. SCOPE REDUCTION - `; - const result = classifyVisible(visible); - expect(result?.outcome).toBe('asked'); - }); - - test('permission dialog (Bash) → returns null (skip, keep polling)', () => { - const visible = ` - Bash command \`gstack-update-check\` requires permission to run. - - ❯ 1. Yes - 2. No - `; - expect(isNumberedOptionListVisible(visible)).toBe(true); // pre-filter - expect(classifyVisible(visible)).toBeNull(); // post-filter - }); - - test('plan-ready confirmation → returns plan_ready (wins over asked)', () => { - const visible = ` - Ready to execute the plan? - - ❯ 1. Yes, proceed - 2. No, keep planning - `; - const result = classifyVisible(visible); - expect(result?.outcome).toBe('plan_ready'); - }); - - test('silent write to unsanctioned path → returns silent_write', () => { - const visible = ` - ⏺ Write(src/app/dangerous-write.ts) - ⎿ Wrote 42 lines - `; - const result = classifyVisible(visible); - expect(result?.outcome).toBe('silent_write'); - expect(result?.summary).toContain('src/app/dangerous-write.ts'); - }); - - test('write to sanctioned path (.claude/plans) → returns null (allowed)', () => { - const visible = ` - ⏺ Write(/Users/me/.claude/plans/some-plan.md) - ⎿ Wrote 42 lines - `; - expect(classifyVisible(visible)).toBeNull(); - }); - - test('write while a permission dialog is on screen → returns null (gated, not silent, not asked)', () => { - const visible = ` - ⏺ Write(src/app/edit-with-permission.ts) - - Edit to src/app/edit-with-permission.ts - - Do you want to proceed? - - ❯ 1. Yes - 2. No - `; - // The numbered prompt is a permission dialog (Edit to + Do you want to proceed?); - // silent_write is suppressed because a numbered prompt is visible, AND - // 'asked' is suppressed because the prompt is a permission dialog. - expect(classifyVisible(visible)).toBeNull(); - }); - - test('write while a real skill question is on screen → returns asked (write is captured but not silent)', () => { - const visible = ` - ⏺ Write(src/app/foo.ts) - - D1 — Choose your scope mode - - ❯ 1. HOLD SCOPE - 2. SCOPE EXPANSION - `; - // The numbered prompt is a skill question, not a permission dialog; - // silent_write is suppressed (numbered prompt is visible) and the - // outcome is 'asked' — Step 0 fired. - const result = classifyVisible(visible); - expect(result?.outcome).toBe('asked'); - }); - - test('idle / no signals → returns null', () => { - const visible = ` - Some prose without any classifier signals. - `; - expect(classifyVisible(visible)).toBeNull(); - }); - - test('TAIL_SCAN_BYTES is exported as 1500', () => { - // Shared between runner and routing test; a regression that desyncs the - // recent-tail window would surface here. - expect(TAIL_SCAN_BYTES).toBe(1500); - }); - - // D4-B: strictPlanWrites detector. Catches the transcript bug where the - // model writes findings to the plan file before any AskUserQuestion fires. - test('strictPlanWrites: plan write before any AUQ → wrote_findings_before_asking', () => { - const visible = ` - ⏺ Edit(/Users/me/.claude/plans/some-plan.md) - ⎿ Updated 12 lines - `; - const result = classifyVisible(visible, { strictPlanWrites: true }); - expect(result?.outcome).toBe('wrote_findings_before_asking'); - expect(result?.summary).toContain('.claude/plans/some-plan.md'); - }); - - test('strictPlanWrites: plan write AFTER an AUQ render → not flagged', () => { - // AUQ renders first, then the model writes the plan post-answer. This is - // the legitimate end-of-workflow flow and must NOT trigger the detector. - const visible = ` - D1 — Some scope question - - ❯ 1. Option A - 2. Option B - - ⏺ Edit(/Users/me/.claude/plans/some-plan.md) - ⎿ Updated 12 lines - `; - const result = classifyVisible(visible, { strictPlanWrites: true }); - // Outcome is 'asked' (the numbered list rendered); the post-AUQ plan - // write is ignored by the detector. - expect(result?.outcome).toBe('asked'); - }); - - test('strictPlanWrites: AUQ first then plan write — write_pos > auq_pos → not flagged', () => { - // Same scenario, more explicit ordering: the regex finds the write at a - // position AFTER the numbered list. Detector lets it through. - const visible = [ - 'D1 — Choose your approach', - '', - '❯ 1. Approach A', - ' 2. Approach B', - '', - '⏺ Write(/Users/me/.claude/plans/draft.md)', - '⎿ Wrote 42 lines', - ].join('\n'); - const result = classifyVisible(visible, { strictPlanWrites: true }); - expect(result?.outcome).toBe('asked'); - }); - - test('strictPlanWrites: only a permission dialog visible → plan write still flagged', () => { - // A permission dialog ❯ 1./2. is NOT an AUQ; pre-AUQ plan writes still - // hit the detector even when a permission prompt is on screen. - const visible = ` - ⏺ Edit(/Users/me/.claude/plans/some-plan.md) - - Edit to /Users/me/.claude/plans/some-plan.md - - Do you want to proceed? - - ❯ 1. Yes - 2. No - `; - const result = classifyVisible(visible, { strictPlanWrites: true }); - expect(result?.outcome).toBe('wrote_findings_before_asking'); - }); - - test('strictPlanWrites OFF: plan write before AUQ → returns null (legacy behavior preserved)', () => { - const visible = ` - ⏺ Edit(/Users/me/.claude/plans/some-plan.md) - ⎿ Updated 12 lines - `; - // Without strictPlanWrites, the sanctioned-path list lets this through. - expect(classifyVisible(visible)).toBeNull(); - }); -}); - -describe('parseNumberedOptions', () => { - test('does not combine an old AUQ prompt with the later ordinary test-case list', () => { - // B CEO retry, 2026-09-08: the old prompt cursor slid outside the - // option parser's 4KB window. Its prose fallback then supplied a new - // five-item test list while the prompt parser retained the old AUQ. - const visible = '☐Stripe event types\nWhich event should the handler accept?\n' + - '❯1.Specify one canonical event\n2.Accept all events\n' + '·'.repeat(4200) + '\n' + - 'Minimum required test cases (all must be specified in the plan):\n' + - '1.Happypath:validcanonicalevent,knownuser→userupdated,emailsent\n' + - '2.Email failure:emailthrows→userupdated,errorlogged,HTTP200\n' + - '3.DB timeout: DB throws onuser update →exceptin ropagates, non-200\n' + - '4.Unkown event typ: non-canonical event→ HTTP200,nouserupdate\n' + - '5.Unknown user: valid event, usernotinDB→existingguard→HTTP200\n❯1\n'; - const seen = new Set(); - expect(capturePlanCountQuestion(visible, seen, 0, false)).toBeNull(); - expect(seen.size).toBe(0); - }); - - test('extracts options from a clean cursor list', () => { - const visible = ` - ❯ 1. HOLD SCOPE - 2. SCOPE EXPANSION - `; - const opts = parseNumberedOptions(visible); - expect(opts).toHaveLength(2); - expect(opts[0]).toEqual({ index: 1, label: 'HOLD SCOPE' }); - expect(opts[1]).toEqual({ index: 2, label: 'SCOPE EXPANSION' }); - }); - - test('returns empty array on prose-with-numbers (no cursor)', () => { - expect(parseNumberedOptions('text 1. one 2. two')).toEqual([]); - }); - - test('extracts options when the cursor is INLINE with prompt header (box-layout)', () => { - // Real /plan-ceo-review rendering: the TTY's cursor-positioning escapes - // collapse divider + header + prompt + cursor onto one logical line. - // Subsequent options (2..7) still start their own lines. - const visible = [ - '────────────────────────────────────────', - '☐ Review scope What scope do you want me to CEO-review? ❯ 1. The branch\'s diff vs main', - ' Review the full branch: ~10K LOC.', - '2. A specific plan file or design doc', - ' You point me at a file (path) and I review that.', - '3. An idea you\'ll describe inline', - '4. Cancel — wrong skill', - '5. Type something.', - '────────────────────────────────────────', - '6. Chat about this', - '7. Skip interview and plan immediately', - ].join('\n'); - const opts = parseNumberedOptions(visible); - expect(opts).toHaveLength(7); - expect(opts[0]).toEqual({ index: 1, label: "The branch's diff vs main" }); - expect(opts[1]?.index).toBe(2); - expect(opts[6]?.index).toBe(7); - expect(opts[6]?.label).toBe('Skip interview and plan immediately'); - }); - - test('inline-cursor and start-of-line cursor both produce 7 options for the box-layout case', () => { - // The inline path captures option 1 from the cursor line itself; the - // subsequent-lines path captures 2..7 with the existing optionRe. - const inlineLayout = [ - 'header text ❯ 1. first option', - '2. second', - '3. third', - ].join('\n'); - expect(parseNumberedOptions(inlineLayout)).toEqual([ - { index: 1, label: 'first option' }, - { index: 2, label: 'second' }, - { index: 3, label: 'third' }, - ]); - - const cleanLayout = [ - ' ❯ 1. first option', - ' 2. second', - ' 3. third', - ].join('\n'); - expect(parseNumberedOptions(cleanLayout)).toEqual([ - { index: 1, label: 'first option' }, - { index: 2, label: 'second' }, - { index: 3, label: 'third' }, - ]); - }); -}); - -describe('pending native question on a damaged option render', () => { - // Exact final B CEO Test scope shape. The native call had been read in - // an in-progress snapshot, but option 2's missing dot prevented input. - const frame = [ - '☐Test scope', - '│Section 6 (Tests) — Theplanhasnotestsforanewpaymentprocessingcodepath.Theexistingintegrationsuitehas', - '│never seen this handlerand cannotcatchregressionsinit.Minimumviabletestplanforminimalpatch:5unittests', - '│(happy path, mal failur, DB timeout, unknowneventtype,unknownuser).Shouldtheplanalsoincludeanintegration', - '│testhittingthefullwebhookstack?', - '❯1.Unittestsonlyfornow(recommended)', - '5 unit tests covering the criticalpaths. No integration stin v1.', - '2Uni tsts + one integration test', - '5 uit tsts + on ed-to-end integrationtestsendiga signe Stripeevent.', - '3.Integrationtestonly', - '4.Typesomething.', - '5. Chataboutthis', - 'Enter to select · ↑/↓ to navigate · Esc to cancel', - '❯1', - ].join('\n'); - const pending = { - sessionId: '66fb6218-4a68-4f1a-a729-6407f14fd6b8', - toolUseId: 'toolu_017DicePqWNVyDsLCd2Y2MCi', answered: false, - questions: [{ header: 'Test scope', question: 'Should the plan also include an integration test hitting the full webhook stack? ', - options: ['Unit tests only for now (recommended)', 'Unit tests + one integration test', 'Integration test only'].map(label => ({ label })) }], - }; - - test('uses lossless pending options after a positively matched native question has rendered', () => { - const seen = new Set(); - const captured = capturePlanCountQuestion(frame, seen, 0, false, pending); - expect(captured?.nativeCall).toBe(pending); - expect(captured?.options).toEqual(pending.questions[0].options.map((o, i) => ({ index: i + 1, label: o.label }))); - expect(capturePlanCountQuestion(frame, seen, 1, false, pending)).toBeNull(); - // A corrected redraw is still the same pending native question. - expect(capturePlanCountQuestion(frame.replace('2Uni tsts', '2.Unit tests'), seen, 2, false, pending)).toBeNull(); - expect(capturePlanCountQuestion(frame.replace('2Uni tsts', '2.Unit tests'), seen, 3, false)).toBeNull(); - expect(seen.has(captured!.signature)).toBe(true); - }); - - test('binds delayed native metadata to the already-answered visible question', () => { - const seen = new Set(); - const clean = frame.replace('2Uni tsts', '2.Unit tests'); - expect(capturePlanCountQuestion(clean, seen, 0, false)).not.toBeNull(); - expect(capturePlanCountQuestion(clean, seen, 1, false, pending)).toBeNull(); - expect(capturePlanCountQuestion(frame, seen, 2, false, pending)).toBeNull(); - }); - - test('requires pending single-question metadata, matching current header, cursor, and navigation footer', () => { - for (const call of [undefined, { ...pending, answered: true }, { ...pending, failed: true }, - { ...pending, questions: [...pending.questions, ...pending.questions] }, - { ...pending, questions: [{ ...pending.questions[0], header: 'Prior decision' }] }, - { ...pending, questions: [{ ...pending.questions[0], question: 'Different issue ' }] }]) { - expect(capturePlanCountQuestion(frame, new Set(), 0, false, call)).toBeNull(); - } - for (const altered of [frame.replace('☐Test scope', 'Test scope'), frame.replace('❯1.', '1.'), - frame.replace('Enter to select · ↑/↓ to navigate · Esc to cancel', ''), - frame + '\n☐Different question\n❯1.Waiting for its choices']) { - expect(capturePlanCountQuestion(altered, new Set(), 0, false, pending)).toBeNull(); - } - }); -}); - -describe('runPlanSkillObservation env passthrough surface', () => { - test('ClaudePtyOptions exposes env: Record', () => { - // Type-level guard: this file would fail to compile if the env field - // were removed or its shape regressed. The actual env merge happens in - // launchClaudePty's spawn call (`env: { ...process.env, ...opts.env }`), - // so a regression where `env: opts.env` gets dropped from the - // runPlanSkillObservation -> launchClaudePty handoff is only caught by - // the live PTY test, not here. - const opts: ClaudePtyOptions = { - env: { QUESTION_TUNING: 'false', EXPLAIN_LEVEL: 'default' }, - }; - expect(opts.env).toEqual({ QUESTION_TUNING: 'false', EXPLAIN_LEVEL: 'default' }); - }); -}); - -describe('launchClaudePty model pin', () => { - // Behavioral: a fake CLI records the argv its real PTY launch received. - // Chain mirrors session-runner.ts: opts.model -> EVALS_MODEL -> - // resolveEvalModel('capture'); --model precedes extraArgs so a per-test - // --model wins (last flag wins). Per-runner forwarding of opts.model is - // asserted through the fake driver in claude-pty-runner.runners.unit.test.ts. - test('ClaudePtyOptions exposes model?: string', () => { - const opts: ClaudePtyOptions = { model: 'claude-sonnet-4-6' }; - expect(opts.model).toBe('claude-sonnet-4-6'); - }); - - test.skipIf(process.platform === 'win32')('spawn args pin --model from the fallback chain, before extraArgs, with hermetic MCP gating', () => { - const dir = mkdtempSync(join(tmpdir(), 'pty-model-pin-')); - try { - const fake = join(dir, 'fake-claude'); - writeFileSync(fake, `#!${process.execPath}\nprocess.stdout.write('ARGV=' + JSON.stringify(process.argv.slice(2)) + '\\n');\nsetInterval(() => {}, 1000);\n`, { mode: 0o755 }); - const runner = pathToFileURL(join(import.meta.dir, 'claude-pty-runner.ts')).href; - const worker = join(dir, 'worker.ts'); - writeFileSync(worker, `import { launchClaudePty } from ${JSON.stringify(runner)}; -import { resolveEvalModel } from ${JSON.stringify(pathToFileURL(join(import.meta.dir, '../../lib/eval-model.ts')).href)}; -const argv = async (opts, evalsModel) => { - if (evalsModel === undefined) delete process.env.EVALS_MODEL; else process.env.EVALS_MODEL = evalsModel; - const session = await launchClaudePty({ cwd: ${JSON.stringify(dir)}, timeoutMs: 5000, ...opts }); - try { await session.waitFor(/ARGV=\\[.*\\]/, { timeoutMs: 4000, pollMs: 20 }); return JSON.parse(/ARGV=(\\[.*\\])/.exec(session.visibleText())[1]); } - finally { await session.close(); } -}; -process.stdout.write(JSON.stringify({ capture: resolveEvalModel('capture'), - fallback: await argv({}, undefined), env: await argv({}, 'env-model'), explicit: await argv({ model: 'opts-model' }, 'env-model'), - extra: await argv({ extraArgs: ['--model', 'override'] }, undefined) })); -`); - const run = (hermetic: string) => { - const result = spawnSync(process.execPath, [worker], { cwd: join(import.meta.dir, '../..'), encoding: 'utf8', timeout: 30_000, - env: { ...process.env, BROWSE_TERMINAL_BINARY: fake, EVALS_HERMETIC: hermetic } }); - expect(result.status, result.stderr).toBe(0); - return JSON.parse(result.stdout.trim().split('\n').at(-1)!); - }; - const hermetic = run('1'); - const model = (args: string[]) => args[args.indexOf('--model') + 1]; - expect(model(hermetic.fallback)).toBe(hermetic.capture); - expect(model(hermetic.env)).toBe('env-model'); - expect(model(hermetic.explicit)).toBe('opts-model'); - expect(hermetic.extra.indexOf('--model')).toBeLessThan(hermetic.extra.lastIndexOf('--model')); - expect(hermetic.extra.slice(-2)).toEqual(['--model', 'override']); - expect(hermetic.fallback).toContain('--strict-mcp-config'); - expect(run('0').fallback).not.toContain('--strict-mcp-config'); - } finally { rmSync(dir, { recursive: true, force: true }); } - }, 60_000); -}); - -// ──────────────────────────────────────────────────────────────────────────── -// Per-finding count primitives — Section 3 unit tests #1–#5, #7, #12. -// ──────────────────────────────────────────────────────────────────────────── - -describe('optionsSignature', () => { - test('returns a "|"-joined `index:label` string for a clean list', () => { - const sig = optionsSignature([ - { index: 1, label: 'HOLD SCOPE' }, - { index: 2, label: 'SCOPE EXPANSION' }, - ]); - expect(sig).toBe('1:HOLD SCOPE|2:SCOPE EXPANSION'); - }); - - test('order-independent: shuffled inputs produce the same signature', () => { - // parseNumberedOptions already returns sorted, but defensive sort means - // a future caller that hands us shuffled input still produces a stable - // dedupe signature. - const a = optionsSignature([ - { index: 2, label: 'B' }, - { index: 1, label: 'A' }, - { index: 3, label: 'C' }, - ]); - const b = optionsSignature([ - { index: 1, label: 'A' }, - { index: 2, label: 'B' }, - { index: 3, label: 'C' }, - ]); - expect(a).toBe(b); - }); - - test('empty list returns empty string', () => { - expect(optionsSignature([])).toBe(''); - }); - - test('single-item list returns just that entry', () => { - expect(optionsSignature([{ index: 1, label: 'Only' }])).toBe('1:Only'); - }); -}); - -describe('parseQuestionPrompt', () => { - test('keeps the captured boxed learnings header across native CR and blank borders', () => { - // Exact active-menu bytes from the targeted-a engineering batching run. - // Its answered setup AUQ lost the title at the standalone box border, - // leaving every later finding classified as preReview. - const raw = "☐ Learnings\u001b[K\r\u001b[1B\u001b[K\r\u001b[1B│ D1 — Cross-project learnings scope \u001b[K\r\u001b[1B│\u001b[3G\u001b[K\r\r\n│\u001b[3Ggstack\u001b[10Gcan\u001b[14Gsearch\u001b[21Glearnings\u001b[31Gfrom\u001b[36Gyour\u001b[41Gother\u001b[47Gprojects\u001b[56Gon\u001b[59Gthis\u001b[64Gmachine\u001b[72Gto\u001b[75Gfind\u001b[80Gpatterns\u001b[89Gthat\u001b[94Gmight\u001b[100Gapply\u001b[106Ghere.\u001b[112GThis\r\r\n│\u001b[3Gstays\u001b[9Glocal\u001b[15G—\u001b[17Gno\u001b[20Gdata\u001b[25Gleaves\u001b[32Gyour\u001b[37Gmachine.\u001b[46GRecommended\u001b[58Gfor\u001b[62Gsolo\u001b[67Gdevelopers.\u001b[79GSkip\u001b[84Gif\u001b[87Gyou\u001b[91Gwork\u001b[96Gon\u001b[99Gmultiple\u001b[108Gclient\r\r\n│\u001b[3Gcodebases\u001b[13Gwhere\u001b[19Gcross-contamination\u001b[39Gwould\u001b[45Gbe\u001b[48Ga\u001b[50Gconcern.\r\r\n\r\r\n❯\u001b[3G1.\u001b[6GEnable\u001b[13Gcross-project\u001b[27Glearnings\u001b[37G(Recommended)\r\r\n\u001b[6GSearch\u001b[13Glearnings\u001b[23Gfrom\u001b[28Gall\u001b[32Gprojects\u001b[41Gon\u001b[44Gthis\u001b[49Gmachine\u001b[57G—\u001b[59Gsurfaces\u001b[68Gpatterns\u001b[77Gand\u001b[81Gpitfalls\u001b[90Gfrom\u001b[95Gprior\u001b[101Gsessions.\r\r\n\u001b[3G2.\u001b[6GKeep\u001b[11Glearnings\u001b[21Gproject-scoped\u001b[36Gonly\r\r\n\u001b[6GOnly\u001b[11Guse\u001b[15Glearnings\u001b[25Gfrom\u001b[30Gthis\u001b[35Gproject.\u001b[44GSafe\u001b[49Gfor\u001b[53Gmulti-client\u001b[66Genvironments.\r\r\n\u001b[3G3.\u001b[6GType\u001b[11Gsomething.\r\r\n────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\r\r\n\u001b[3G4.\u001b[6GChat\u001b[11Gabout\u001b[17Gthis\r\r\n\r\r\nEnter\u001b[7Gto\u001b[10Gselect\u001b[17G·\u001b[19G↑/↓\u001b[23Gto\u001b[26Gnavigate\u001b[35G·\u001b[37GEsc\u001b[41Gto\u001b[44Gcancel"; - const visible = stripAnsi(raw); - const question = capturePlanCountQuestion(visible, new Set(), 0, true)!; - expect(question.promptSnippet).toStartWith('Learnings D1 — Cross-project learnings scope'); - expect(question.promptSnippet).toContain(''); - expect(engStep0Boundary(question)).toBe(true); - const phase = planCountQuestionPhase(question, false, engStep0Boundary); - expect(phase).toEqual({ preReview: true, reviewStarted: true }); - }); - - test('keeps a long boxed question identity instead of its closing recommendation', () => { - const frame = [ - 'Planning: /tmp/hermetic/.claude/plans/review.md', - '─'.repeat(120), - '☐ Architecture', - '│ D2 — Architecture: custom retry scheduler vs library built-in ', - '│', - ...Array.from({ length: 12 }, (_, i) => `│ Review context line ${i}: the proposed retry behavior and its tradeoffs.`), - '│', - '│ Net: If the library hook is configurable, use the existing implementation.', - '❯1.Use library built-in (Recommended)', - '2.Extract shared retry envelope', - ].join('\r\r\n'); - const seen = new Set(); - const question = capturePlanCountQuestion(frame, seen, 0, false)!; - expect(question.promptSnippet).toStartWith('Architecture D2 — Architecture: custom retry scheduler'); - expect(question.promptSnippet).toContain(''); - expect(question.promptSnippet).not.toContain('Planning:'); - expect(question.promptSnippet.length).toBeLessThanOrEqual(240); - expect(capturePlanCountQuestion(frame + '\n' + '·'.repeat(6000), seen, 1, false)).toBeNull(); - }); - - test('does not reuse an old boxed header for a later unboxed menu', () => { - const visible = [ - '☐ Old setup', - 'D1 — Cross-project learnings scope', - '❯1.Enable', - '2.Skip', - 'Planning: /tmp/hermetic/.claude/plans/review.md', - 'D2 — Choose the retry behavior', - '❯1.Use library built-in', - '2.Extract shared retry envelope', - ].join('\n'); - const prompt = parseQuestionPrompt(visible); - expect(prompt).toBe('D2 — Choose the retry behavior'); - expect(prompt).not.toContain('Old setup'); - }); - - test('captures 1-line prompt above the cursor', () => { - const visible = ` - D1 — Pick a mode - - ❯ 1. HOLD SCOPE - 2. SCOPE EXPANSION - `; - const prompt = parseQuestionPrompt(visible); - expect(prompt).toBe('D1 — Pick a mode'); - }); - - test('captures multi-line prompt above the cursor', () => { - const visible = ` - D2 — Approach selection - - Which architecture should we follow? - - ❯ 1. Bypass existing helper - 2. Reuse existing helper - `; - const prompt = parseQuestionPrompt(visible); - // Multi-line prompts get joined with single spaces. - expect(prompt).toContain('D2 — Approach selection'); - expect(prompt).toContain('Which architecture should we follow?'); - }); - - test('returns "" when no cursor is rendered', () => { - expect(parseQuestionPrompt('Just some prose.\nNo cursor.')).toBe(''); - }); - - test('truncates to 240 chars', () => { - const longPrompt = 'A'.repeat(500); - const visible = `${longPrompt}\n\n ❯ 1. yes\n 2. no`; - expect(parseQuestionPrompt(visible).length).toBeLessThanOrEqual(240); - }); - - test('does not pull text from a previous numbered list above', () => { - const visible = ` - ❯ 1. previous answered question - 2. previous option two - - D2 — A new question text - - ❯ 1. fresh option A - 2. fresh option B - `; - const prompt = parseQuestionPrompt(visible); - // Stops at the previous numbered-list line; should NOT contain "previous answered question". - expect(prompt).toContain('D2 — A new question text'); - expect(prompt).not.toContain('previous answered question'); - }); - - test('normalizes whitespace (collapses runs of spaces and tabs)', () => { - const visible = `D1 — Spaced out - - ❯ 1. yes - 2. no`; - expect(parseQuestionPrompt(visible)).toBe('D1 — Spaced out'); - }); - - test('inline-cursor box-layout: extracts prompt text BEFORE ❯1. on the cursor line', () => { - // Real /plan-ceo-review rendering: divider + ☐ header + prompt text + - // cursor are all on one logical line because TTY cursor-positioning - // escapes collapse the box layout under stripAnsi. - const visible = [ - '──────────────────', - '☐ Review scope What scope do you want me to CEO-review? ❯ 1. The branch\'s diff vs main', - '2. A specific plan file', - '3. An idea inline', - ].join('\n'); - const prompt = parseQuestionPrompt(visible); - // Should extract "Review scope" and the prompt text, dropping the ☐ box-drawing sigil. - expect(prompt).toContain('Review scope'); - expect(prompt).toContain('What scope do you want me to CEO-review?'); - 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:'); - }); - - 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'); - }); - - 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', () => { - test('returns the same fingerprint for identical inputs', () => { - const opts = [ - { index: 1, label: 'A' }, - { index: 2, label: 'B' }, - ]; - expect(auqFingerprint('hello', opts)).toBe(auqFingerprint('hello', opts)); - }); - - test('different prompts with shared option labels produce DIFFERENT fingerprints', () => { - // The collision regression Codex F1 caught: option-label-only fingerprints - // collapsed multiple distinct findings into one when they shared menu shape. - const sharedOpts = [ - { index: 1, label: 'Add to plan' }, - { index: 2, label: 'Defer' }, - { index: 3, label: 'Build now' }, - ]; - const fpFinding1 = auqFingerprint('D5 — Architecture: bypass helper?', sharedOpts); - const fpFinding2 = auqFingerprint('D6 — Tests: zero coverage?', sharedOpts); - expect(fpFinding1).not.toBe(fpFinding2); - }); - - test('same prompt with different options produces DIFFERENT fingerprints', () => { - const prompt = 'D1 — Pick a mode'; - const fpA = auqFingerprint(prompt, [ - { index: 1, label: 'HOLD SCOPE' }, - { index: 2, label: 'SCOPE EXPANSION' }, - ]); - const fpB = auqFingerprint(prompt, [ - { index: 1, label: 'HOLD SCOPE' }, - { index: 2, label: 'SCOPE REDUCTION' }, - ]); - expect(fpA).not.toBe(fpB); - }); - - test('whitespace-only differences in prompt do NOT change the fingerprint', () => { - // Same content, different rendering whitespace (TTY redraw artifact) - // must produce the same fingerprint so dedupe survives reflow. - const opts = [{ index: 1, label: 'A' }, { index: 2, label: 'B' }]; - const fpA = auqFingerprint('Pick a mode', opts); - const fpB = auqFingerprint('Pick a mode', opts); - expect(fpA).toBe(fpB); - }); - - test('empty prompt + same options collide (caller must guard against this)', () => { - // Documents the contract: empty-prompt fingerprints WILL collide if the - // caller fingerprints them. runPlanSkillCounting must skip empty-prompt - // AUQs and re-poll instead. - const opts = [{ index: 1, label: 'A' }]; - expect(auqFingerprint('', opts)).toBe(auqFingerprint('', opts)); - }); -}); - -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? │ `${i === 0 ? '❯' : ''}${i + 1}.${label}`).join('\n'); - const frame = `${capture.prompt}\n${options}`; - const seen = new Set(); - 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(); - 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(); - 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? ', - 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); - }); - - test('matches Completion Summary heading (ceo + eng)', () => { - expect(COMPLETION_SUMMARY_RE.test('## Completion Summary')).toBe(true); - expect(COMPLETION_SUMMARY_RE.test('## Completion summary')).toBe(true); - }); - - test('matches Status: clean (CEO review-log shape)', () => { - expect(COMPLETION_SUMMARY_RE.test('Status: clean')).toBe(true); - expect(COMPLETION_SUMMARY_RE.test('Status: issues_open')).toBe(true); - }); - - test('matches VERDICT: line', () => { - expect(COMPLETION_SUMMARY_RE.test('VERDICT: CLEARED — Eng Review passed')).toBe(true); - }); - - test('does NOT match prose mentions of "verdict" mid-line', () => { - // 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', () => { - test('uses the captured DevEx panel anchors when the Submit button label is damaged', () => { - const captured = [ - '← ☒ Routing setup ☐ Cross-project ✔ Submit →', - 'Review your answers', - '⚠You have not answere all questions', - ' │ ●D1 — Shouldgstack add skill routingrulestothisproject\'sCLAUDE.md?', - '→dd routing rules (Recmmeded)', - 'Ready to submit your answers?', - '❯1.Sbmi answers', - '2Cancel', - ].join('\r\r'); - expect(planCountSubmissionInput(captured)).toBe('\x1b[Z'); - expect(planCountSubmissionInput(captured.replace('☐ Cross-project', '☒ Cross-project'))).toBe('\r'); - expect(planCountSubmissionInput(captured + '\r☐ Retry spec\rRetry once?\r❯1.Yes\r2.No')).toBeNull(); - expect(planCountSubmissionInput(captured + '\r☐ Proposal\rSend this proposal?\r❯1.Submit proposal\r2.Keep editing')).toBeNull(); - expect(planCountSubmissionInput(captured + '\r☐ Retry spec\rRetry once?\r❯2.No\r3.Other')).toBeNull(); - expect(planCountSubmissionInput(captured.replace('Review your answers', 'Review context'))).toBeNull(); - expect(planCountSubmissionInput(captured.replace('Ready to submit your answers?', 'Read the proposed answers.'))).toBeNull(); - }); - - test('the captured mode Submit panel with a damaged caption and dotless cursor returns to its unanswered tab', () => { - // Exact final active panel from targeted-a's SCOPE EXPANSION retry. - const captured = [ - '← ☒ Routing rule ☐ Design doc ✔ Submit →', - '', - 'Review your answrs', - '⚠ You hvenot answered all questions', - " ● Add gstack skill routing rules tothisproject'sCLAUDE.md?", - '→dd routing rues (Recommnded)', - '', - 'Ready to submit your answers?', - '', - '❯1Submit answers', - ' 2. Cancel', - ].join('\r'); - expect(planCountSubmissionInput(captured)).toBe('\x1b[Z'); - const answered = captured.replace('☐ Design doc', '☒ Design doc').replace('⚠ You hvenot answered all questions', ''); - expect(planCountSubmissionInput(answered)).toBe('\r'); - expect(planCountSubmissionInput(captured + '\r☐ Design doc\rRun office hours?\r❯1Run now\r2.Skip')).toBeNull(); - }); - - 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', () => { - test('passes when REVIEW REPORT is the only/last ## heading', () => { - const content = `# Plan - -## Context -stuff - -## Approach -more stuff - -## GSTACK REVIEW REPORT - -| col | col | -`; - const r = assertReviewReportAtBottom(content); - expect(r.ok).toBe(true); - }); - - test('fails when REVIEW REPORT is missing', () => { - const content = `# Plan - -## Context -stuff -`; - const r = assertReviewReportAtBottom(content); - expect(r.ok).toBe(false); - expect(r.reason).toMatch(/no GSTACK REVIEW REPORT/); - }); - - test('fails when REVIEW REPORT exists but a ## heading follows it', () => { - const content = `# Plan - -## GSTACK REVIEW REPORT - -| col | col | - -## Late Section -oops -`; - const r = assertReviewReportAtBottom(content); - expect(r.ok).toBe(false); - expect(r.reason).toMatch(/trailing ## heading/); - expect(r.trailingHeadings).toEqual(['## Late Section']); - }); - - test('passes when only ### subheadings follow REVIEW REPORT (deeper nesting allowed)', () => { - const content = `## GSTACK REVIEW REPORT - -### Cross-model tension -- F1: resolved -- F2: resolved -`; - const r = assertReviewReportAtBottom(content); - expect(r.ok).toBe(true); - }); - - test('fails with multiple trailing ## headings reported', () => { - const content = `## GSTACK REVIEW REPORT - -## First trailing - -## Second trailing -`; - const r = assertReviewReportAtBottom(content); - expect(r.ok).toBe(false); - expect(r.trailingHeadings).toHaveLength(2); - }); -}); - describe('Step0BoundaryPredicate per-skill', () => { // Helper to build a synthetic fingerprint for predicate tests. function fp(promptSnippet: string, optionLabels: string[]): AskUserQuestionFingerprint { @@ -2067,81 +458,6 @@ describe('Step0BoundaryPredicate per-skill', () => { }); }); - -describe('file permission lifecycle replay', () => { - const permission = (file = 'gstack-test-plan-design.md') => [ - `Do you want to make this edit to ${file}?`, - '❯ 1. Yes', - '2.Yes,andswitchtoacceptedits(auto-approvefileeditsandcommonfilecommands)forthissession;Yes,and', - 'alwaysallowaccessto/tmp/fixtureforthissession', - '3.No', - 'Esctocancel·Tabtoamend', - ].join('\n'); - - test('ignores the granted menu and its redraw until a new request follows file-tool completion', () => { - const guard = createPlanCountPermissionGuard(); - const first = permission(); - expect(guard(first)).toBe('grant'); - expect(guard(first)).toBe('handled'); - const redraw = first + '\n' + permission(); - expect(guard(redraw)).toBe('handled'); - const completed = redraw + '\n●Write(/tmp/fixture/gstack-test-plan-design.md)\n' + - '⎿ Wrote320linesto../fixture/gstack-test-plan-design.md\n' + '·'.repeat(1600); - expect(classifyPlanCountFrame(completed)).toBeNull(); - expect(guard(completed)).toBe('handled'); - expect(guard(completed + '\n' + permission())).toBe('grant'); - }); - - test('singular native Write/Edit results release a fresh identical permission', () => { - for (const result of ['⎿ Added1line,removed1line', '⎿ Wrote1lineto../fixture/plan.md', '⎿ Removed1line', '⎿\u00a0Wrote320linesto../fixture/plan.md']) { - const guard = createPlanCountPermissionGuard(); - const first = permission(); - expect(guard(first)).toBe('grant'); - const completed = first + '\n' + result; - expect(guard(completed)).toBe('handled'); - expect(guard(completed + '\n' + permission())).toBe('grant'); - } - }); - - test('the captured active Edit menu remains a permission behind a long diff repaint', () => { - const visible = permission('gstack-test-plan-ceo.md') + '\n' + - ' 89 +The plan adds StripePaymentWebhookHandler outside WebhookDispatcher.\n'.repeat(40); - expect(visible.length).toBeLessThan(4096); - expect(classifyPlanCountFrame(visible)).toBeNull(); // The old 1.5 KB scan misses it. - const guard = createPlanCountPermissionGuard(); - expect(guard(visible)).toBe('grant'); - expect(guard(visible)).toBe('handled'); - }); - - test('a completed Write invalidates an old menu even if polling missed the original grant', () => { - const visible = permission() + '\n⎿ Wrote320linesto../fixture/gstack-test-plan-design.md'; - expect(createPlanCountPermissionGuard()(visible)).toBe('handled'); - }); - - test('proposed results and tool headers do not release the same pending permission', () => { - const guard = createPlanCountPermissionGuard(); - let visible = permission(); - expect(guard(visible)).toBe('grant'); - for (const line of ['320 +⎿ Wrote320lines', '●Write(/tmp/fixture/plan.md)', '⎿ Tip: use /btw', '⎿ Error: denied']) { - visible += '\n' + line + '\n' + permission(); - expect(guard(visible)).toBe('handled'); - } - }); - - test('a different file and a genuine native file-policy question retain their own input', () => { - const guard = createPlanCountPermissionGuard(); - const first = permission('first.md'); - expect(guard(first)).toBe('grant'); - expect(guard(first + '\n' + permission('FIRST.md'))).toBe('grant'); // Targets remain case-sensitive. - expect(guard(first + '\n' + permission('second.md'))).toBe('grant'); - const question = '\n☐ File policy\nDo you want to create first.md?\n❯1.Yes\n2.No\n' + - 'Enter to select · ↑/↓ to navigate · Esc to cancel'; - expect(guard(first + question)).toBeNull(); - expect(capturePlanCountQuestion(first + question, new Set(), 0, true)?.promptSnippet).toContain('File policy'); - }); -}); - - describe('native Eng setup ordering (captured F)', () => { // Exact native question stems and choice labels: two setup calls followed // by five real findings. Descriptions do not establish phase identity. @@ -2317,7 +633,6 @@ describe('native Eng setup ordering (captured F)', () => { }); }); - describe('native Eng first packet and registry identities', () => { const scope = { header: 'Scope complexity', @@ -2399,7 +714,6 @@ describe('native Eng first packet and registry identities', () => { }); }); - describe('native Eng setup semantics (captured G)', () => { // Exact native question stems, offered actions and answers. Five findings // and both substantive TODO decisions must remain in review coverage. @@ -2617,83 +931,6 @@ describe('native Eng setup semantics (captured G)', () => { }); }); -describe('completed permission cannot become a queued review answer (captured G)', () => { - // Exact captured post-Write frame; the temporary repository path is sanitized. - // The damaged "wat" came from the CLI redraw, not the underlying question. - const captured = " real,specificgaps (Visual Hierachy, Spacing, Color, Typography,Motion)\r22-Itpreservesstrongaccessibilityandresponsivespecsfromtheexistingbehaviordescription\r23 -DESIGN.md exist andsuppliescorrectvaluesforall5gaps\r24\r 25 A 10/10would:\r26-Specifytheexactchangeforeachgap(concretetoken,before→after)\r27 -Add intection state table(loading,empty,error,success,partial)\r28-Storyboardtheuserjourney\r 29- Call out which DESIGN.md tokens each fix applies\r30-Resolveallimplementationambiguitiesbeforeadeveloperpicksitup\r31\r32 **DESIGN.md status:** Presnt and detailed. Spacing scale, clor system, typography,interacton patterns,andc\r omponentbehavior re all specified. This review calbrates against it.\r33\r34 **Exsting design lverage:**\r35 -Button, Field, InlineStatus,ErrorSummary,ConfirmationDialogcomponentsreusedas-is\r36-Focusring:2pxsolid#1d4ed8,offset2px(alreadyspecified)\r37 -8px bae spacingscalealreadyestablished\r38\r39 ---\r40\r41 ## Review Passes\r42\r43*(Populatedprogressivelyasthereviewproceeds)*\r44\r╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌\r Do you wat to create gstack-test-plan-design.md?\r❯ 1. Yes\r2.Yes,andswitchtoacceptedits(auto-approvefileeditsandcommonfilecommands)for this session; Yes,an\r always allow access to /tmp/fixture for this session\r 3.Nohift+tab)\r\rEsc to cancl ·Tab to amend\u000f\u001b[?1000h\u001b[?1002h\u001b[?1003h\u001b[?1006h\rRan1shellcommand\r\r●Scopegate:planmodeactive—auto-selectingB(reviewingPLAN.mdintheworkingdirectory).\r\rSkippingroutinginjection—thisisareviewfixturerepo.Nowrunningthepre-reviewaudit.\r\rRead2 files, ran1 shell command\r\r●Now let me read the review sectons fil as required by theskill.\r\rRead file, ran 1shellcommand\r\r●Nopriorlearnings found. Cross-project config isnot set — will ask once Ihavetheuser'sattention.\r\rPreReview System Audit:\r- DESIGN.md:exists and comprehensive (spacing, typography, color, interactionpatternsallspecified)\r- UIscope:APPUIclassifier—account-settingsformwithProfile+Notificationssections\r- 5 gaps explicily listed in the plan; l have corresponding specin DESIGN.md\r-N rior design reviews inth reviewlog\r- NoTODOS.mdexists\r\rStep0 Assessment:\r\rInitial scor: 5/10. The plan correctly identifies5realgapsandpreservesstrongaccessibilityandresponsivespecs\rinheritedfromtheexistingbehaviordescription.A10/10wouldspecifyHOWeachgapisresolved(concretecomponent\rchanges,tokenvalues,before/afterlayout),notjustnametheproblem.TheDESIGN.mdalreadyhasallcorrectvalues;\rtheplanjustneedstoreferencethem.\r\rWritingtheinitialplanfilenowbeforeaskingStep0D.\r\r●Write(/tmp/fixture/gstack-test-plan-design.md)\r\r✢ Undulatig… (2m 0s ·↓ 5.4 okens)\r ⎿  Tip:Use/btwtoaskaquicksidequestionwithoutinterruptingClaude'scurrentwork\r ● high · /effort\r────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\r❯ \r────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\r ⏸ plan mod on(shift+tab tocycle)·esctointerrupt·←foragents\u001b[?25h\u001b[?25l\r●No prior earnings found.Cross-projectconfigisnotset—willaskonceIhavetheuser'sattention.\r Pre-Review System Audit:\r-DESIGN.md:existsandcomprehensive(spacing,typography,color,interactionpatternsallspecified)\r- UI scope: APP UI classifier — account-settings fomwith Prfile + Notifications sctions\r-5gapsexplicitlylistedintheplan;allhavecorrespondingspecinDESIGN.md\r- Noprior desigreviwsin the reviewlog\r-NoTODOS.mdexists\r\rStep0Assessment:\r\rInitialscore:5/10.Theplancorrectlyidentifies5realgapsandpreservesstrongaccessibilityandresponsivespecs\r inheted from theexisting behavir desription.A 10/10would specify HOWechgapi eolved (ccretecomponent\rchanges,tokenvalues,before/afterlayout),notjustnametheproblem.TheDESIGN.mdalreadyhasallcorrectvalues;\rth plan just neds to referencethem.\r\rWriting theinitial plan lenow before ask Step 0D.\r\r●Write(/tmp/fixture/gstack-test-plan-design.md)\r⎿ Wrote 44 lines"; - - test('a missed grant followed by native Write completion sends no stale answer', () => { - const guard = createPlanCountPermissionGuard(); - expect(classifyPlanCountFrame(captured)).toBeNull(); - expect(guard(captured)).toBe('handled'); - expect(capturePlanCountQuestion(captured, new Set(), 0, true)).toBeNull(); - }); - - test('the same damaged active file permission grants once and never counts as a finding', () => { - const menu = captured.slice(captured.indexOf('Do you wat'), captured.indexOf('Esc to cancl')) + 'Esc to cancl ·Tab to amend'; - const guard = createPlanCountPermissionGuard(); - expect(guard(menu)).toBe('grant'); - expect(guard(menu)).toBe('handled'); - expect(capturePlanCountQuestion(menu, new Set(), 0, false)).toBeNull(); - const completed = menu + '\n⎿ Wrote 44 lines'; - expect(guard(completed)).toBe('handled'); - expect(guard(completed + '\n' + menu)).toBe('grant'); - }); - - test('plain legacy file decisions remain questions without native permission controls', () => { - const frame = 'Do you want to create first.md?\n❯1.Create the reviewed file\n2.Keep the current layout'; - expect(classifyPlanCountFrame(frame)).toBeNull(); - expect(createPlanCountPermissionGuard()(frame)).toBeNull(); - expect(capturePlanCountQuestion(frame, new Set(), 0, false)?.options).toHaveLength(2); - }); - - test('a matching native finding can discuss file permissions without being consumed', () => { - const question = { - header: 'File policy', - question: 'Should we create a file that documents always allow access to the project?', - options: [{ label: 'Create it' }, { label: 'Keep current policy' }], - }; - const pending = { sessionId: 'file-policy', toolUseId: 'file-finding', answered: false, questions: [question] }; - const frame = captured + '\n☐ ' + question.header + '\n' + question.question + - '\n❯1.Create it\n2.Keep current policy\nEnter to select · ↑/↓ to navigate · Esc to cancel'; - expect(classifyPlanCountFrame(frame)).toBeNull(); - expect(createPlanCountPermissionGuard()(frame)).toBeNull(); - expect(capturePlanCountQuestion(frame, new Set(), 0, false, pending)?.nativeCall).toBe(pending); - expect(capturePlanCountQuestion(frame, new Set(), 0, false)?.promptSnippet).toContain('File policy'); - }); -}); - - -describe('native question identity outranks permission wording', () => { - const question = { - header: 'File policy', - question: 'D1 — Should we create a file that documents always allow access to the project? ', - options: [{ label: 'Create it' }, { label: 'Keep current policy' }], - }; - const pending = { sessionId: 'file-policy', toolUseId: 'finding', answered: false, questions: [question] }; - const frame = '☐ File policy\n' + question.question + '\n❯1.Create it\n2.Keep current policy\nEnter to select · ↑/↓ to navigte · Esc to cancel'; - test('full native question and every option establish identity despite a damaged footer', () => { - // This is the formerly conflicting pure classifier result. The counting - // loop must consult native identity before taking its permission action. - expect(classifyPlanCountFrame(frame)).toBe('permission'); - expect(matchesNativePlanQuestion(frame, pending)).toBe(true); - const seen = new Set(); - expect(capturePlanCountQuestion(frame, seen, 0, false, pending)?.nativeCall).toBe(pending); - expect(capturePlanCountQuestion(frame, seen, 1, false, pending)).toBeNull(); - }); - test('same header, changed choices, missing identity and an overlaid real permission cannot borrow a native call', () => { - for (const different of [ - frame.replace('Should we create a file', 'Should we delete the file'), - frame.replace('2.Keep current policy', '2.Allow all edits'), - frame.replace(question.question, 'A different question with the same header?'), - frame + '\nDo you want to create actual.md?\n❯1.Yes\n2.Yes, and switch to accept edits (auto-approve file edits and common file commands) for this session (shift+tab)\n3.No\nEsc to cancel · Tab to amend', - ]) expect(matchesNativePlanQuestion(different, pending)).toBe(false); - expect(capturePlanCountQuestion(frame, new Set(), 0, false, { ...pending, failed: true })).toBeNull(); - expect(capturePlanCountQuestion(frame, new Set(), 0, false)).toBeNull(); - }); -}); - - describe('native file/class complexity gate classification', () => { const rows = [ { @@ -2882,7 +1119,6 @@ describe('native file/class complexity gate classification', () => { }); }); - describe('explicit whole-plan scope complexity premise', () => { const calls = [ { @@ -3198,7 +1434,6 @@ describe('explicit whole-plan scope complexity premise', () => { }); }); - describe('explicit Step 0 complexity gate with size in native choices', () => { const calls = [ { diff --git a/test/helpers/claude-pty-runner.classify.unit.test.ts b/test/helpers/claude-pty-runner.classify.unit.test.ts new file mode 100644 index 000000000..a3eb03f17 --- /dev/null +++ b/test/helpers/claude-pty-runner.classify.unit.test.ts @@ -0,0 +1,802 @@ +/** + * Deterministic unit tests for the frame classifiers (test/helpers/pty/classify.ts). + * Split along the W4 module seams from the former claude-pty-runner.unit.test.ts; + * tests import the public barrel, test/helpers/claude-pty-runner.ts. + */ +import { describe, test, expect } from 'bun:test'; +import { + isNumberedOptionListVisible, + isProseAUQVisible, + isScopeGateQuestionVisible, + isScopeGateAutoSelectVisible, + isPlanReadyVisible, + parseNumberedOptions, + classifyVisible, + TAIL_SCAN_BYTES, + optionsSignature, + stripAnsi, + COMPLETION_SUMMARY_RE, + classifyPlanCountFrame, + capturePlanCountQuestion, + planCountSubmissionInput, +} from './claude-pty-runner'; + +describe('scope-gate render detectors', () => { + // The verbatim announcement string from the plan-eng/plan-design SKILL.md + // templates. If the template rewording drifts, THIS fixture fails first — + // before the paid plan-mode smokes silently degrade to vacuous asserts. + const TEMPLATE_ANNOUNCEMENT = + 'Scope gate: plan mode — auto-selected B (reviewing ).'; + + describe('isScopeGateQuestionVisible', () => { + test('matches the clean prose gate render (question + option bodies)', () => { + const sample = ` +What should I review? +A) The current branch diff — the work in progress on this branch. +B) A plan or design doc I'll paste or point you to. +C) A specific file, directory, or path. +Recommendation: A when a branch diff exists, otherwise B. +`; + expect(isScopeGateQuestionVisible(sample)).toBe(true); + }); + + test('matches the native numbered render (no lettered markers)', () => { + const sample = ` + What should I review? + + ❯ 1. The current branch diff — the work in progress on this branch. + 2. A plan or design doc I'll paste or point you to. + 3. A specific file, directory, or path. +`; + expect(isScopeGateQuestionVisible(sample)).toBe(true); + }); + + test('matches the PTY-collapsed render (stripAnsi squished spaces)', () => { + const sample = 'WhatshouldIreview?A)Thecurrentbranchdiff—theworkinprogress'; + expect(isScopeGateQuestionVisible(sample)).toBe(true); + }); + + test('stays false on narration quoting only the question', () => { + const sample = + "Normally I'd ask 'What should I review?' but plan mode is active, so I'm proceeding."; + expect(isScopeGateQuestionVisible(sample)).toBe(false); + }); + + test('stays false on unrelated review prose', () => { + const sample = 'I will review the current branch diff and report findings.'; + expect(isScopeGateQuestionVisible(sample)).toBe(false); + }); + }); + + describe('isScopeGateAutoSelectVisible', () => { + test('matches the verbatim template announcement', () => { + expect(isScopeGateAutoSelectVisible(TEMPLATE_ANNOUNCEMENT)).toBe(true); + }); + + test('matches a real announcement with a concrete target', () => { + const sample = + 'Scope gate: plan mode — auto-selected B (reviewing ~/.claude/plans/my-feature.md). Running the Design Doc Check next.'; + expect(isScopeGateAutoSelectVisible(sample)).toBe(true); + }); + + test('matches the PTY-collapsed announcement', () => { + const sample = 'Scopegate:planmode—auto-selectedB(reviewingPLAN.md).'; + expect(isScopeGateAutoSelectVisible(sample)).toBe(true); + }); + + test('stays false on narration about the behavior', () => { + const sample = "In plan mode I'd auto-select B and review the active plan."; + expect(isScopeGateAutoSelectVisible(sample)).toBe(false); + }); + + test('stays false on a VERBATIM QUOTE of the announcement (negation narration)', () => { + // The exact announcement line sits quoted in the skill context, so a + // model explaining why it is NOT firing it can reproduce it byte-exact + // inside quotes — that must not trip a must-stay-false assert. + const sample = + 'Not in plan mode, so I won\'t announce "Scope gate: plan mode — auto-selected B (reviewing )." and will ask instead.'; + expect(isScopeGateAutoSelectVisible(sample)).toBe(false); + }); + + test('a later real render still matches after an earlier quoted mention', () => { + const sample = + 'Earlier I said I would render "Scope gate: plan mode — auto-selected B (…)" and now:\n' + + 'Scope gate: plan mode — auto-selected B (reviewing PLAN.md).'; + expect(isScopeGateAutoSelectVisible(sample)).toBe(true); + }); + + test('matches tense paraphrases WITH the announcement prefix (auto-selecting / auto-selects)', () => { + expect( + isScopeGateAutoSelectVisible('Scope gate: plan mode — auto-selecting B (reviewing the drafted plan).'), + ).toBe(true); + expect(isScopeGateAutoSelectVisible('Scope gate: plan mode — auto-selects B.')).toBe(true); + }); + + test('stays false on tense paraphrases WITHOUT the announcement prefix', () => { + expect(isScopeGateAutoSelectVisible('Auto-selecting B since we are in plan mode.')).toBe(false); + }); + + test('stays false on AUTO_DECIDE preamble output', () => { + const sample = 'Auto-decided scope question → B (your preference). Change with /plan-tune.'; + expect(isScopeGateAutoSelectVisible(sample)).toBe(false); + }); + + test('stays false on a bare "selected B" without the announcement prefix', () => { + const sample = 'I selected B as the review target.'; + expect(isScopeGateAutoSelectVisible(sample)).toBe(false); + }); + }); +}); + +describe('isProseAUQVisible', () => { + test('matches 4 lettered options A) B) C) D) at line starts (plan-eng prose AUQ shape)', () => { + const sample = ` +What would you like me to review? Options: +A) Point me at an existing design doc or plan file (path). +B) Describe new work you're planning — I'll explore the codebase. +C) You meant /review for the diff already on this branch. +D) Something else (tell me). +Recommendation: A if you have a doc in mind, otherwise B. +❯ +`; + expect(isProseAUQVisible(sample)).toBe(true); + }); + + test('matches 2 lettered options (minimum threshold)', () => { + const sample = ` +A) First option +B) Second option +`; + expect(isProseAUQVisible(sample)).toBe(true); + }); + + test('matches 3 numbered options 1. 2. 3. without ❯ 1. cursor (autoplan prose AUQ shape)', () => { + const sample = ` +What's the task? A few options: + 1. You have a plan idea in mind — describe it. + 2. You want to review an existing plan elsewhere. + 3. You meant a different command — /plan-ceo-review etc. +❯ +`; + expect(isProseAUQVisible(sample)).toBe(true); + }); + + test('returns false when ❯ 1. cursor is present in the recent tail (native UI handled by isNumberedOptionListVisible)', () => { + const sample = ` +❯ 1. First option + 2. Second option + 3. Third option +`; + expect(isProseAUQVisible(sample)).toBe(false); + }); + + test('does NOT suppress numbered-prose detection when ❯ 1. is only in early scrollback (trust dialog)', () => { + // Boot trust dialog rendered ❯ 1. Yes at startup, then a long body of + // model output, then prose-rendered numbered options now. The historic + // ❯ 1. is in the full buffer but NOT in the recent tail. Should detect + // the prose AUQ. + const trustHeader = '❯ 1. Yes, trust\n 2. No\n'; + const filler = 'x'.repeat(5000); // pushes trust dialog out of last 4KB tail + const proseAUQ = `\n 1. Review the docs\n 2. Investigate the code\n 3. Defer to next session\n❯ \n`; + const sample = trustHeader + filler + proseAUQ; + expect(isProseAUQVisible(sample)).toBe(true); + }); + + test('returns false on single lettered option', () => { + const sample = ` +A) Only one option mentioned in passing. +`; + expect(isProseAUQVisible(sample)).toBe(false); + }); + + test('matches 2 numbered options (threshold matches lettered branch — tails miss option 1)', () => { + const sample = ` +1. First note. +2. Second note. +`; + expect(isProseAUQVisible(sample)).toBe(true); + }); + + test('returns false on a single numbered option', () => { + const sample = ` +1. Only one option mentioned. +`; + expect(isProseAUQVisible(sample)).toBe(false); + }); + + test('does not match mid-prose lettered text like "(see option B) above"', () => { + const sample = ` +This refers to (see option B) above and also to point A) earlier. +`; + // The B) and A) markers are mid-line, not at line starts, so they don't count. + expect(isProseAUQVisible(sample)).toBe(false); + }); + + test('matches with leading whitespace and ❯ prefix on options', () => { + const sample = ` + A) Option with whitespace prefix +❯ B) Option with cursor prefix + C) Another option +`; + expect(isProseAUQVisible(sample)).toBe(true); + }); + + test('returns false on plain text with no option markers', () => { + expect(isProseAUQVisible('Just some plain text output from the model.')).toBe(false); + expect(isProseAUQVisible('')).toBe(false); + }); + + // Pattern 3: markdown bold-bullet options — office-hours renders its mode + // question this way under --disallowedTools, with no letter/number marker. + test('matches office-hours markdown bold-bullet mode question (Pattern 3)', () => { + const sample = ` +> Before we dig in — what's your goal with this? +> +> - **Building a startup** (or thinking about it) +> - **Intrapreneurship** — internal project at a company, need to ship fast +> - **Hackathon / demo** — time-boxed, need to impress +> - **Open source / research** — building for a community +> - **Learning** — teaching yourself to code +❯ +`; + expect(isProseAUQVisible(sample)).toBe(true); + }); + + test('bold-bullets require a preceding interrogative — no "?" => false', () => { + // 3+ bold bullets but no question stem: this is a feature list, not an AUQ. + const sample = ` +Here is what shipped: +- **Faster builds** via caching +- **Smaller binaries** through tree-shaking +- **Better errors** with source maps +`; + expect(isProseAUQVisible(sample)).toBe(false); + }); + + test('a question with fewer than 3 bold bullets stays false (guard)', () => { + const sample = ` +Which approach do you prefer? +- **Option one** is simpler +- **Option two** is faster +`; + expect(isProseAUQVisible(sample)).toBe(false); + }); + + test('plain (non-bold) bullets after a question do not trigger Pattern 3', () => { + // Only bold bullets count — plain "- text" prose lists are too common. + const sample = ` +What should we do about this? +- run the tests +- ship the fix +- file a follow-up +`; + expect(isProseAUQVisible(sample)).toBe(false); + }); + + test('Pattern 3 still defers to a live native cursor list (❯ 1.)', () => { + const sample = ` +> What's your goal? +❯ 1. **Building a startup** + 2. **Intrapreneurship** + 3. **Hackathon** +`; + // The ❯1. cursor gate fires first — native list handling owns this. + expect(isProseAUQVisible(sample)).toBe(false); + }); + + // Pattern 4/5: collapsed-form prose AUQ. stripAnsi destroys the newlines + + // inter-word spaces, so a real prose AUQ arrives collapsed and defeats the + // line-anchored Patterns 1-3. These are the dominant Shape-B render mode in + // the plan-design smoke + floor timeouts — verbatim de-spinnered bytes from + // the real failing runs (bdm3sucql.output). + test('matches the real collapsed floor render (colon-delimited, Pattern 4/5)', () => { + const sample = + 'The review is blocked on D1—reply withA, B, r Cabovetocontinue:' + + '- A(recommended): Spec thefull P1AskUserQuestioncopy in this review' + + '-B:LeaveP1copytotheimplementerwithstructuralrequirements' + + 'C: Add a placeholder template to the plan'; + expect(isProseAUQVisible(sample)).toBe(true); + }); + + test('matches the real collapsed plan-mode render (Recommendation + collapsed A)/B), Pattern 4/5)', () => { + const sample = + 'Recommendation:A—writethecopynow.(recommended)A) Writ the fullcopy in thisdesign review— now.' + + '(recommended) Completeness:10/10 B) Leveit to theimplemente — task spec is enough.' + + 'Reply withA (write the copy now)orB(leavetoimplementer)'; + expect(isProseAUQVisible(sample)).toBe(true); + }); + + test('collapsed-form requires BOTH signals — single B) + word "recommendation" stays false', () => { + // Only one punctuated letter marker: the two-signal contract is not met. + const sample = + 'We should consider option B) here. My recommendation is to do it now.'; + expect(isProseAUQVisible(sample)).toBe(false); + }); + + test('collapsed-form requires letter punctuation — comma-only "ReplywithA,B,orC" stays false', () => { + // Reply-instruction present, but the letters carry no ) : or ( punctuation, + // so they could be incidental enumerations in running prose. Stays false. + const sample = 'ReplywithA,B,orC'; + expect(isProseAUQVisible(sample)).toBe(false); + }); + + test('collapsed-form does not regress the existing FP guard (see option B) ... point A))', () => { + // The classic citation FP: a model referencing prior options in prose. + // No reply-instruction / recommendation marker on its own line, so the + // collapsed-form signal does not fire either. + const sample = + 'As noted (see option B) above, and the earlier point A) we discussed, this is fine.'; + expect(isProseAUQVisible(sample)).toBe(false); + }); +}); + +describe('classifyVisible (runtime path through the runner classifier)', () => { + // These tests call the actual classifier so a future contributor who + // reorders branches (e.g. moves the permission short-circuit before + // isPlanReadyVisible) is caught deterministically. + + test('skill question → returns asked', () => { + const visible = ` + D1 — Choose your scope mode + + ❯ 1. HOLD SCOPE + 2. SCOPE EXPANSION + 3. SELECTIVE EXPANSION + 4. SCOPE REDUCTION + `; + const result = classifyVisible(visible); + expect(result?.outcome).toBe('asked'); + }); + + test('permission dialog (Bash) → returns null (skip, keep polling)', () => { + const visible = ` + Bash command \`gstack-update-check\` requires permission to run. + + ❯ 1. Yes + 2. No + `; + expect(isNumberedOptionListVisible(visible)).toBe(true); // pre-filter + expect(classifyVisible(visible)).toBeNull(); // post-filter + }); + + test('plan-ready confirmation → returns plan_ready (wins over asked)', () => { + const visible = ` + Ready to execute the plan? + + ❯ 1. Yes, proceed + 2. No, keep planning + `; + const result = classifyVisible(visible); + expect(result?.outcome).toBe('plan_ready'); + }); + + test('silent write to unsanctioned path → returns silent_write', () => { + const visible = ` + ⏺ Write(src/app/dangerous-write.ts) + ⎿ Wrote 42 lines + `; + const result = classifyVisible(visible); + expect(result?.outcome).toBe('silent_write'); + expect(result?.summary).toContain('src/app/dangerous-write.ts'); + }); + + test('write to sanctioned path (.claude/plans) → returns null (allowed)', () => { + const visible = ` + ⏺ Write(/Users/me/.claude/plans/some-plan.md) + ⎿ Wrote 42 lines + `; + expect(classifyVisible(visible)).toBeNull(); + }); + + test('write while a permission dialog is on screen → returns null (gated, not silent, not asked)', () => { + const visible = ` + ⏺ Write(src/app/edit-with-permission.ts) + + Edit to src/app/edit-with-permission.ts + + Do you want to proceed? + + ❯ 1. Yes + 2. No + `; + // The numbered prompt is a permission dialog (Edit to + Do you want to proceed?); + // silent_write is suppressed because a numbered prompt is visible, AND + // 'asked' is suppressed because the prompt is a permission dialog. + expect(classifyVisible(visible)).toBeNull(); + }); + + test('write while a real skill question is on screen → returns asked (write is captured but not silent)', () => { + const visible = ` + ⏺ Write(src/app/foo.ts) + + D1 — Choose your scope mode + + ❯ 1. HOLD SCOPE + 2. SCOPE EXPANSION + `; + // The numbered prompt is a skill question, not a permission dialog; + // silent_write is suppressed (numbered prompt is visible) and the + // outcome is 'asked' — Step 0 fired. + const result = classifyVisible(visible); + expect(result?.outcome).toBe('asked'); + }); + + test('idle / no signals → returns null', () => { + const visible = ` + Some prose without any classifier signals. + `; + expect(classifyVisible(visible)).toBeNull(); + }); + + test('TAIL_SCAN_BYTES is exported as 1500', () => { + // Shared between runner and routing test; a regression that desyncs the + // recent-tail window would surface here. + expect(TAIL_SCAN_BYTES).toBe(1500); + }); + + // D4-B: strictPlanWrites detector. Catches the transcript bug where the + // model writes findings to the plan file before any AskUserQuestion fires. + test('strictPlanWrites: plan write before any AUQ → wrote_findings_before_asking', () => { + const visible = ` + ⏺ Edit(/Users/me/.claude/plans/some-plan.md) + ⎿ Updated 12 lines + `; + const result = classifyVisible(visible, { strictPlanWrites: true }); + expect(result?.outcome).toBe('wrote_findings_before_asking'); + expect(result?.summary).toContain('.claude/plans/some-plan.md'); + }); + + test('strictPlanWrites: plan write AFTER an AUQ render → not flagged', () => { + // AUQ renders first, then the model writes the plan post-answer. This is + // the legitimate end-of-workflow flow and must NOT trigger the detector. + const visible = ` + D1 — Some scope question + + ❯ 1. Option A + 2. Option B + + ⏺ Edit(/Users/me/.claude/plans/some-plan.md) + ⎿ Updated 12 lines + `; + const result = classifyVisible(visible, { strictPlanWrites: true }); + // Outcome is 'asked' (the numbered list rendered); the post-AUQ plan + // write is ignored by the detector. + expect(result?.outcome).toBe('asked'); + }); + + test('strictPlanWrites: AUQ first then plan write — write_pos > auq_pos → not flagged', () => { + // Same scenario, more explicit ordering: the regex finds the write at a + // position AFTER the numbered list. Detector lets it through. + const visible = [ + 'D1 — Choose your approach', + '', + '❯ 1. Approach A', + ' 2. Approach B', + '', + '⏺ Write(/Users/me/.claude/plans/draft.md)', + '⎿ Wrote 42 lines', + ].join('\n'); + const result = classifyVisible(visible, { strictPlanWrites: true }); + expect(result?.outcome).toBe('asked'); + }); + + test('strictPlanWrites: only a permission dialog visible → plan write still flagged', () => { + // A permission dialog ❯ 1./2. is NOT an AUQ; pre-AUQ plan writes still + // hit the detector even when a permission prompt is on screen. + const visible = ` + ⏺ Edit(/Users/me/.claude/plans/some-plan.md) + + Edit to /Users/me/.claude/plans/some-plan.md + + Do you want to proceed? + + ❯ 1. Yes + 2. No + `; + const result = classifyVisible(visible, { strictPlanWrites: true }); + expect(result?.outcome).toBe('wrote_findings_before_asking'); + }); + + test('strictPlanWrites OFF: plan write before AUQ → returns null (legacy behavior preserved)', () => { + const visible = ` + ⏺ Edit(/Users/me/.claude/plans/some-plan.md) + ⎿ Updated 12 lines + `; + // Without strictPlanWrites, the sanctioned-path list lets this through. + expect(classifyVisible(visible)).toBeNull(); + }); +}); + +describe('parseNumberedOptions', () => { + test('does not combine an old AUQ prompt with the later ordinary test-case list', () => { + // B CEO retry, 2026-09-08: the old prompt cursor slid outside the + // option parser's 4KB window. Its prose fallback then supplied a new + // five-item test list while the prompt parser retained the old AUQ. + const visible = '☐Stripe event types\nWhich event should the handler accept?\n' + + '❯1.Specify one canonical event\n2.Accept all events\n' + '·'.repeat(4200) + '\n' + + 'Minimum required test cases (all must be specified in the plan):\n' + + '1.Happypath:validcanonicalevent,knownuser→userupdated,emailsent\n' + + '2.Email failure:emailthrows→userupdated,errorlogged,HTTP200\n' + + '3.DB timeout: DB throws onuser update →exceptin ropagates, non-200\n' + + '4.Unkown event typ: non-canonical event→ HTTP200,nouserupdate\n' + + '5.Unknown user: valid event, usernotinDB→existingguard→HTTP200\n❯1\n'; + const seen = new Set(); + expect(capturePlanCountQuestion(visible, seen, 0, false)).toBeNull(); + expect(seen.size).toBe(0); + }); + + test('extracts options from a clean cursor list', () => { + const visible = ` + ❯ 1. HOLD SCOPE + 2. SCOPE EXPANSION + `; + const opts = parseNumberedOptions(visible); + expect(opts).toHaveLength(2); + expect(opts[0]).toEqual({ index: 1, label: 'HOLD SCOPE' }); + expect(opts[1]).toEqual({ index: 2, label: 'SCOPE EXPANSION' }); + }); + + test('returns empty array on prose-with-numbers (no cursor)', () => { + expect(parseNumberedOptions('text 1. one 2. two')).toEqual([]); + }); + + test('extracts options when the cursor is INLINE with prompt header (box-layout)', () => { + // Real /plan-ceo-review rendering: the TTY's cursor-positioning escapes + // collapse divider + header + prompt + cursor onto one logical line. + // Subsequent options (2..7) still start their own lines. + const visible = [ + '────────────────────────────────────────', + '☐ Review scope What scope do you want me to CEO-review? ❯ 1. The branch\'s diff vs main', + ' Review the full branch: ~10K LOC.', + '2. A specific plan file or design doc', + ' You point me at a file (path) and I review that.', + '3. An idea you\'ll describe inline', + '4. Cancel — wrong skill', + '5. Type something.', + '────────────────────────────────────────', + '6. Chat about this', + '7. Skip interview and plan immediately', + ].join('\n'); + const opts = parseNumberedOptions(visible); + expect(opts).toHaveLength(7); + expect(opts[0]).toEqual({ index: 1, label: "The branch's diff vs main" }); + expect(opts[1]?.index).toBe(2); + expect(opts[6]?.index).toBe(7); + expect(opts[6]?.label).toBe('Skip interview and plan immediately'); + }); + + test('inline-cursor and start-of-line cursor both produce 7 options for the box-layout case', () => { + // The inline path captures option 1 from the cursor line itself; the + // subsequent-lines path captures 2..7 with the existing optionRe. + const inlineLayout = [ + 'header text ❯ 1. first option', + '2. second', + '3. third', + ].join('\n'); + expect(parseNumberedOptions(inlineLayout)).toEqual([ + { index: 1, label: 'first option' }, + { index: 2, label: 'second' }, + { index: 3, label: 'third' }, + ]); + + const cleanLayout = [ + ' ❯ 1. first option', + ' 2. second', + ' 3. third', + ].join('\n'); + expect(parseNumberedOptions(cleanLayout)).toEqual([ + { index: 1, label: 'first option' }, + { index: 2, label: 'second' }, + { index: 3, label: 'third' }, + ]); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Per-finding count primitives — Section 3 unit tests #1–#5, #7, #12. +// ──────────────────────────────────────────────────────────────────────────── + +describe('optionsSignature', () => { + test('returns a "|"-joined `index:label` string for a clean list', () => { + const sig = optionsSignature([ + { index: 1, label: 'HOLD SCOPE' }, + { index: 2, label: 'SCOPE EXPANSION' }, + ]); + expect(sig).toBe('1:HOLD SCOPE|2:SCOPE EXPANSION'); + }); + + test('order-independent: shuffled inputs produce the same signature', () => { + // parseNumberedOptions already returns sorted, but defensive sort means + // a future caller that hands us shuffled input still produces a stable + // dedupe signature. + const a = optionsSignature([ + { index: 2, label: 'B' }, + { index: 1, label: 'A' }, + { index: 3, label: 'C' }, + ]); + const b = optionsSignature([ + { index: 1, label: 'A' }, + { index: 2, label: 'B' }, + { index: 3, label: 'C' }, + ]); + expect(a).toBe(b); + }); + + test('empty list returns empty string', () => { + expect(optionsSignature([])).toBe(''); + }); + + test('single-item list returns just that entry', () => { + expect(optionsSignature([{ index: 1, label: 'Only' }])).toBe('1:Only'); + }); +}); + +describe('COMPLETION_SUMMARY_RE', () => { + test('matches GSTACK REVIEW REPORT heading', () => { + expect(COMPLETION_SUMMARY_RE.test('## GSTACK REVIEW REPORT')).toBe(true); + }); + + test('matches Completion Summary heading (ceo + eng)', () => { + expect(COMPLETION_SUMMARY_RE.test('## Completion Summary')).toBe(true); + expect(COMPLETION_SUMMARY_RE.test('## Completion summary')).toBe(true); + }); + + test('matches Status: clean (CEO review-log shape)', () => { + expect(COMPLETION_SUMMARY_RE.test('Status: clean')).toBe(true); + expect(COMPLETION_SUMMARY_RE.test('Status: issues_open')).toBe(true); + }); + + test('matches VERDICT: line', () => { + expect(COMPLETION_SUMMARY_RE.test('VERDICT: CLEARED — Eng Review passed')).toBe(true); + }); + + test('does NOT match prose mentions of "verdict" mid-line', () => { + // 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', () => { + test('uses the captured DevEx panel anchors when the Submit button label is damaged', () => { + const captured = [ + '← ☒ Routing setup ☐ Cross-project ✔ Submit →', + 'Review your answers', + '⚠You have not answere all questions', + ' │ ●D1 — Shouldgstack add skill routingrulestothisproject\'sCLAUDE.md?', + '→dd routing rules (Recmmeded)', + 'Ready to submit your answers?', + '❯1.Sbmi answers', + '2Cancel', + ].join('\r\r'); + expect(planCountSubmissionInput(captured)).toBe('\x1b[Z'); + expect(planCountSubmissionInput(captured.replace('☐ Cross-project', '☒ Cross-project'))).toBe('\r'); + expect(planCountSubmissionInput(captured + '\r☐ Retry spec\rRetry once?\r❯1.Yes\r2.No')).toBeNull(); + expect(planCountSubmissionInput(captured + '\r☐ Proposal\rSend this proposal?\r❯1.Submit proposal\r2.Keep editing')).toBeNull(); + expect(planCountSubmissionInput(captured + '\r☐ Retry spec\rRetry once?\r❯2.No\r3.Other')).toBeNull(); + expect(planCountSubmissionInput(captured.replace('Review your answers', 'Review context'))).toBeNull(); + expect(planCountSubmissionInput(captured.replace('Ready to submit your answers?', 'Read the proposed answers.'))).toBeNull(); + }); + + test('the captured mode Submit panel with a damaged caption and dotless cursor returns to its unanswered tab', () => { + // Exact final active panel from targeted-a's SCOPE EXPANSION retry. + const captured = [ + '← ☒ Routing rule ☐ Design doc ✔ Submit →', + '', + 'Review your answrs', + '⚠ You hvenot answered all questions', + " ● Add gstack skill routing rules tothisproject'sCLAUDE.md?", + '→dd routing rues (Recommnded)', + '', + 'Ready to submit your answers?', + '', + '❯1Submit answers', + ' 2. Cancel', + ].join('\r'); + expect(planCountSubmissionInput(captured)).toBe('\x1b[Z'); + const answered = captured.replace('☐ Design doc', '☒ Design doc').replace('⚠ You hvenot answered all questions', ''); + expect(planCountSubmissionInput(answered)).toBe('\r'); + expect(planCountSubmissionInput(captured + '\r☐ Design doc\rRun office hours?\r❯1Run now\r2.Skip')).toBeNull(); + }); + + 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(); + }); +}); diff --git a/test/helpers/claude-pty-runner.launch.unit.test.ts b/test/helpers/claude-pty-runner.launch.unit.test.ts new file mode 100644 index 000000000..0f6123afe --- /dev/null +++ b/test/helpers/claude-pty-runner.launch.unit.test.ts @@ -0,0 +1,78 @@ +/** + * Deterministic unit tests for the PTY launcher (test/helpers/pty/launch.ts). + * Split along the W4 module seams from the former claude-pty-runner.unit.test.ts; + * tests import the public barrel, test/helpers/claude-pty-runner.ts. + */ +import { describe, test, expect } from 'bun:test'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { + type ClaudePtyOptions, +} from './claude-pty-runner'; + +describe('runPlanSkillObservation env passthrough surface', () => { + test('ClaudePtyOptions exposes env: Record', () => { + // Type-level guard: this file would fail to compile if the env field + // were removed or its shape regressed. The actual env merge happens in + // launchClaudePty's spawn call (`env: { ...process.env, ...opts.env }`), + // so a regression where `env: opts.env` gets dropped from the + // runPlanSkillObservation -> launchClaudePty handoff is only caught by + // the live PTY test, not here. + const opts: ClaudePtyOptions = { + env: { QUESTION_TUNING: 'false', EXPLAIN_LEVEL: 'default' }, + }; + expect(opts.env).toEqual({ QUESTION_TUNING: 'false', EXPLAIN_LEVEL: 'default' }); + }); +}); + +describe('launchClaudePty model pin', () => { + // Behavioral: a fake CLI records the argv its real PTY launch received. + // Chain mirrors session-runner.ts: opts.model -> EVALS_MODEL -> + // resolveEvalModel('capture'); --model precedes extraArgs so a per-test + // --model wins (last flag wins). Per-runner forwarding of opts.model is + // asserted through the fake driver in claude-pty-runner.runners.unit.test.ts. + test('ClaudePtyOptions exposes model?: string', () => { + const opts: ClaudePtyOptions = { model: 'claude-sonnet-4-6' }; + expect(opts.model).toBe('claude-sonnet-4-6'); + }); + + test.skipIf(process.platform === 'win32')('spawn args pin --model from the fallback chain, before extraArgs, with hermetic MCP gating', () => { + const dir = mkdtempSync(join(tmpdir(), 'pty-model-pin-')); + try { + const fake = join(dir, 'fake-claude'); + writeFileSync(fake, `#!${process.execPath}\nprocess.stdout.write('ARGV=' + JSON.stringify(process.argv.slice(2)) + '\\n');\nsetInterval(() => {}, 1000);\n`, { mode: 0o755 }); + const runner = pathToFileURL(join(import.meta.dir, 'claude-pty-runner.ts')).href; + const worker = join(dir, 'worker.ts'); + writeFileSync(worker, `import { launchClaudePty } from ${JSON.stringify(runner)}; +import { resolveEvalModel } from ${JSON.stringify(pathToFileURL(join(import.meta.dir, '../../lib/eval-model.ts')).href)}; +const argv = async (opts, evalsModel) => { + if (evalsModel === undefined) delete process.env.EVALS_MODEL; else process.env.EVALS_MODEL = evalsModel; + const session = await launchClaudePty({ cwd: ${JSON.stringify(dir)}, timeoutMs: 5000, ...opts }); + try { await session.waitFor(/ARGV=\\[.*\\]/, { timeoutMs: 4000, pollMs: 20 }); return JSON.parse(/ARGV=(\\[.*\\])/.exec(session.visibleText())[1]); } + finally { await session.close(); } +}; +process.stdout.write(JSON.stringify({ capture: resolveEvalModel('capture'), + fallback: await argv({}, undefined), env: await argv({}, 'env-model'), explicit: await argv({ model: 'opts-model' }, 'env-model'), + extra: await argv({ extraArgs: ['--model', 'override'] }, undefined) })); +`); + const run = (hermetic: string) => { + const result = spawnSync(process.execPath, [worker], { cwd: join(import.meta.dir, '../..'), encoding: 'utf8', timeout: 30_000, + env: { ...process.env, BROWSE_TERMINAL_BINARY: fake, EVALS_HERMETIC: hermetic } }); + expect(result.status, result.stderr).toBe(0); + return JSON.parse(result.stdout.trim().split('\n').at(-1)!); + }; + const hermetic = run('1'); + const model = (args: string[]) => args[args.indexOf('--model') + 1]; + expect(model(hermetic.fallback)).toBe(hermetic.capture); + expect(model(hermetic.env)).toBe('env-model'); + expect(model(hermetic.explicit)).toBe('opts-model'); + expect(hermetic.extra.indexOf('--model')).toBeLessThan(hermetic.extra.lastIndexOf('--model')); + expect(hermetic.extra.slice(-2)).toEqual(['--model', 'override']); + expect(hermetic.fallback).toContain('--strict-mcp-config'); + expect(run('0').fallback).not.toContain('--strict-mcp-config'); + } finally { rmSync(dir, { recursive: true, force: true }); } + }, 60_000); +}); diff --git a/test/helpers/claude-pty-runner.plan-native.unit.test.ts b/test/helpers/claude-pty-runner.plan-native.unit.test.ts new file mode 100644 index 000000000..cdb6e174b --- /dev/null +++ b/test/helpers/claude-pty-runner.plan-native.unit.test.ts @@ -0,0 +1,78 @@ +/** + * Deterministic unit tests for native plan terminal/report checks (test/helpers/pty/plan-native.ts). + * Split along the W4 module seams from the former claude-pty-runner.unit.test.ts; + * tests import the public barrel, test/helpers/claude-pty-runner.ts. + */ +import { describe, test, expect } from 'bun:test'; +import { + assertReviewReportAtBottom, +} from './claude-pty-runner'; + +describe('assertReviewReportAtBottom', () => { + test('passes when REVIEW REPORT is the only/last ## heading', () => { + const content = `# Plan + +## Context +stuff + +## Approach +more stuff + +## GSTACK REVIEW REPORT + +| col | col | +`; + const r = assertReviewReportAtBottom(content); + expect(r.ok).toBe(true); + }); + + test('fails when REVIEW REPORT is missing', () => { + const content = `# Plan + +## Context +stuff +`; + const r = assertReviewReportAtBottom(content); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/no GSTACK REVIEW REPORT/); + }); + + test('fails when REVIEW REPORT exists but a ## heading follows it', () => { + const content = `# Plan + +## GSTACK REVIEW REPORT + +| col | col | + +## Late Section +oops +`; + const r = assertReviewReportAtBottom(content); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/trailing ## heading/); + expect(r.trailingHeadings).toEqual(['## Late Section']); + }); + + test('passes when only ### subheadings follow REVIEW REPORT (deeper nesting allowed)', () => { + const content = `## GSTACK REVIEW REPORT + +### Cross-model tension +- F1: resolved +- F2: resolved +`; + const r = assertReviewReportAtBottom(content); + expect(r.ok).toBe(true); + }); + + test('fails with multiple trailing ## headings reported', () => { + const content = `## GSTACK REVIEW REPORT + +## First trailing + +## Second trailing +`; + const r = assertReviewReportAtBottom(content); + expect(r.ok).toBe(false); + expect(r.trailingHeadings).toHaveLength(2); + }); +}); diff --git a/test/helpers/claude-pty-runner.screen.unit.test.ts b/test/helpers/claude-pty-runner.screen.unit.test.ts new file mode 100644 index 000000000..12fb268f6 --- /dev/null +++ b/test/helpers/claude-pty-runner.screen.unit.test.ts @@ -0,0 +1,227 @@ +/** + * Deterministic unit tests for the ANSI/TUI screen detectors (test/helpers/pty/screen.ts). + * Split along the W4 module seams from the former claude-pty-runner.unit.test.ts; + * tests import the public barrel, test/helpers/claude-pty-runner.ts. + */ +import { describe, test, expect } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { + isPermissionDialogVisible, + isNumberedOptionListVisible, + isAutoDecidedVisible, + classifyVisible, + classifyPlanCountFrame, +} from './claude-pty-runner'; + +describe('saved preference annotation', () => { + test('recognizes the explicit preference attribution from the timed-out CEO capture', () => { + const visible = 'Now I have a clear picture of the branch. Let me proceed with the full review. ' + + 'Mode is HOLD SCOPE (auto-decided from plan-tune preference).'; + expect(isAutoDecidedVisible(visible)).toBe(true); + expect(classifyVisible(visible)?.outcome).toBe('auto_decided'); + expect(classifyVisible(visible.replace(/\s+/g, ''))?.outcome).toBe('auto_decided'); + }); + + test('retains the canonical annotation and its precedence over plan-ready', () => { + const visible = 'Auto-decided review mode → HOLD SCOPE (your preference). Change with /plan-tune.\nReady to execute?'; + expect(classifyVisible(visible)?.outcome).toBe('auto_decided'); + }); + + test('does not equate an unrequested choice or plan-tune advice with a saved preference', () => { + for (const visible of [ + 'Mode is HOLD SCOPE (AUTO_DECIDED).', + 'I auto-decided HOLD SCOPE because this is a refactor.', + 'I auto-decided HOLD SCOPE. You can set a plan-tune preference later.', + 'Mode is HOLD SCOPE (not auto-decided from plan-tune preference).', + 'Mode is HOLD SCOPE (will be auto-decided from plan-tune preference).', + ]) expect(isAutoDecidedVisible(visible)).toBe(false); + }); +}); + +describe('mode option rendering', () => { +}); + +describe('isPermissionDialogVisible', () => { + test('matches "Bash command requires permission" prompts', () => { + const sample = ` + Some preamble output + + Bash command \`gstack-config get telemetry\` requires permission to run. + + ❯ 1. Yes + 2. Yes, and always allow + 3. No, abort + `; + expect(isPermissionDialogVisible(sample)).toBe(true); + }); + + test('matches "allow all edits" file-edit prompts', () => { + // Isolated to the "allow all edits" clause only — no overlapping + // "Do you want to proceed?" co-trigger, so this asserts the clause works. + const sample = ` + Edit to ~/.gstack/config.yaml + + ❯ 1. Yes + 2. Yes, allow all edits during this session + 3. No + `; + expect(isPermissionDialogVisible(sample)).toBe(true); + }); + + test('matches the "Do you want to proceed?" file-edit confirmation by itself', () => { + // Separate fixture so weakening this clause is detected by a dedicated test. + const sample = ` + Edit to ~/.gstack/config.yaml + + Do you want to proceed? + + ❯ 1. Yes + 2. No + `; + expect(isPermissionDialogVisible(sample)).toBe(true); + }); + + test('matches workspace-trust "always allow access to" prompt', () => { + const sample = ` + Do you trust the files in this folder? + + ❯ 1. Yes, proceed + 2. Yes, and always allow access to /Users/me/repo + 3. No, exit + `; + 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('the captured paired-CEO Edit grant is permission, not another review finding', () => { + const sample = [ + 'Do youwt to makehis dittogstack-test-plan-ceo-paired.md?', + '❯1.Yes', + '2.Yes,andswitchtoacceptedits(auto-approvefileeditsandcommonfilecommands)forthissession;Yes,and', + 'alwaysallowaccessto/tmp/gstack-paid-shard-EbUl9j/tmp/gstack-e2e-plan-ceo-paired-gkjAd5forthissession', + '(shift+tab)', '3.No', 'Esctocancel·Tabtoamend', + ].join('\r'); + expect(isPermissionDialogVisible(sample)).toBe(true); + expect(classifyPlanCountFrame(sample)).toBe('permission'); + }); + + 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? + + ❯ 1. Yes, validated + 2. No, premise is wrong + 3. Need more info + `; + expect(isPermissionDialogVisible(sample)).toBe(false); + }); + + test('does NOT match a plan-ready confirmation', () => { + const sample = ` + Ready to execute the plan? + + ❯ 1. Yes + 2. No, keep planning + `; + expect(isPermissionDialogVisible(sample)).toBe(false); + }); + + test('does NOT match a skill question that contains the bare phrase "Do you want to proceed?"', () => { + // Co-trigger requirement: "Do you want to proceed?" alone is not enough. + // It must appear with "Edit to " or "Write to " to count as + // a permission dialog. This guards against a skill question like + // "Do you want to proceed with HOLD SCOPE?" being mis-classified. + const sample = ` + Choose your scope mode for this review. + Do you want to proceed? + + ❯ 1. HOLD SCOPE + 2. SCOPE EXPANSION + 3. SELECTIVE EXPANSION + `; + expect(isPermissionDialogVisible(sample)).toBe(false); + }); + + test('does NOT mis-match when adversarial prose includes "Edit to " alongside the bare proceed phrase', () => { + // Adversarial fixture: a skill question whose body legitimately mentions + // "Edit to " in prose AND ends with "Do you want to proceed?". The + // current co-trigger regex would mis-classify this as a permission + // dialog. We DO want this test to fail until the regex is tightened + // further (e.g., proximity constraint, or anchoring "Edit to" to a + // line-start). For now this is documented as a known limitation: a + // skill question that talks about "Edit to" in prose IS still treated + // as a permission dialog. The test asserts the current behavior so a + // future fix can flip it intentionally. + const sample = ` + Plan: I will Edit to ./plan.md to capture the decision. + Do you want to proceed? + + ❯ 1. HOLD SCOPE + 2. SCOPE EXPANSION + `; + // KNOWN LIMITATION: the co-trigger fires here. Documented as a + // post-merge follow-up. Flip this assertion once the regex tightens. + expect(isPermissionDialogVisible(sample)).toBe(true); + }); + + test('matches the captured Autoplan settings-overwrite card as a numbered permission dialog', () => { + const captured = JSON.parse(readFileSync(new URL('../fixtures/autoplan-settings-overwrite.json', import.meta.url), 'utf8')); + expect(isNumberedOptionListVisible(captured.frame.text)).toBe(true); + expect(isPermissionDialogVisible(captured.frame.text)).toBe(true); + }); +}); + +describe('isNumberedOptionListVisible', () => { + test('matches a basic ❯ 1. + 2. cursor list', () => { + const sample = ` + ❯ 1. Option one + 2. Option two + 3. Option three + `; + expect(isNumberedOptionListVisible(sample)).toBe(true); + }); + + test('returns false on a single-option prompt', () => { + const sample = ` + ❯ 1. Only option + `; + expect(isNumberedOptionListVisible(sample)).toBe(false); + }); + + test('returns false when no cursor renders', () => { + const sample = ` + Just some prose with 1. a numbered point and 2. another. + `; + expect(isNumberedOptionListVisible(sample)).toBe(false); + }); + + test('overlaps permission dialogs (this is why D5 short-circuits)', () => { + // The whole point of D5: this string matches BOTH classifiers, so the + // runner must consult isPermissionDialogVisible to disambiguate. + const sample = ` + Bash command \`do-thing\` requires permission to run. + + ❯ 1. Yes + 2. No + `; + expect(isNumberedOptionListVisible(sample)).toBe(true); + expect(isPermissionDialogVisible(sample)).toBe(true); + }); +}); diff --git a/test/test-of-test-ratchet.test.ts b/test/test-of-test-ratchet.test.ts index 612238465..703ddcbfd 100644 --- a/test/test-of-test-ratchet.test.ts +++ b/test/test-of-test-ratchet.test.ts @@ -138,8 +138,13 @@ const BASELINE = [ 'test/helpers-unit.test.ts', 'test/helpers/budget-override.test.ts', 'test/helpers/capture-parity-baseline.test.ts', - 'test/helpers/claude-pty-runner.scope-gate-floor.unit.test.ts', + 'test/helpers/claude-pty-runner.auq.unit.test.ts', // W4: moved from claude-pty-runner.unit.test.ts (exempt via its design SKILL.md.tmpl read), split along the pty/ module seams + 'test/helpers/claude-pty-runner.classify.unit.test.ts', // W4: moved from claude-pty-runner.unit.test.ts (exempt via its design SKILL.md.tmpl read), split along the pty/ module seams + 'test/helpers/claude-pty-runner.launch.unit.test.ts', // W4: moved from claude-pty-runner.unit.test.ts (exempt via its design SKILL.md.tmpl read), split along the pty/ module seams + 'test/helpers/claude-pty-runner.plan-native.unit.test.ts', // W4: moved from claude-pty-runner.unit.test.ts (exempt via its design SKILL.md.tmpl read), split along the pty/ module seams 'test/helpers/claude-pty-runner.runners.unit.test.ts', // W4 fake-driver regressions: the PTY harness is itself the subject (plan-mode paid evals stand on it) + 'test/helpers/claude-pty-runner.scope-gate-floor.unit.test.ts', + 'test/helpers/claude-pty-runner.screen.unit.test.ts', // W4: moved from claude-pty-runner.unit.test.ts (exempt via its design SKILL.md.tmpl read), split along the pty/ module seams 'test/helpers/e2e-gate.unit.test.ts', 'test/helpers/eval-store.test.ts', 'test/helpers/hermetic-env.test.ts',