From c73102357cbc3466d6a3c8d3ad0ac7e3177ce62c Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 10 Sep 2026 07:42:26 +0000 Subject: [PATCH] test: handle native review variants and permission redraws --- test/autoplan-repeated-header-ak.test.ts | 91 +++++ test/ceo-finding-brief-ak.test.ts | 129 +++++++ test/ceo-numbered-brief-ak.test.ts | 162 +++++++++ test/design-primary-contract-ak.test.ts | 101 ++++++ test/design-scope-declaration-ak.test.ts | 90 +++++ test/dx-signature-identity-ak.test.ts | 123 +++++++ test/eng-declared-suite-ak.test.ts | 120 +++++++ .../fixtures/autoplan-repeated-header-ak.json | 41 +++ test/fixtures/ceo-finding-brief-ak.json | 317 ++++++++++++++++++ test/fixtures/ceo-numbered-brief-ak.json | 258 ++++++++++++++ test/fixtures/design-primary-contract-ak.json | 62 ++++ .../fixtures/design-scope-declaration-ak.json | 171 ++++++++++ test/fixtures/dx-signature-identity-ak.json | 43 +++ test/fixtures/eng-declared-suite-ak.json | 152 +++++++++ test/fixtures/plan-count-crop-ak.json | 43 +++ test/fixtures/plan-count-quoted-frame-ak.json | 10 + test/helpers/autoplan-artifact-permission.ts | 18 + test/helpers/claude-pty-runner.ts | 57 +++- test/helpers/design-count-review.ts | 46 ++- test/helpers/devex-seed-coverage.ts | 35 +- test/helpers/eng-seeded-coverage.ts | 40 ++- test/helpers/plan-count-file-permission.ts | 29 +- test/helpers/plan-scope-selection.ts | 4 +- test/helpers/touchfiles-data.ts | 82 ++++- test/plan-count-crop-ak.test.ts | 78 +++++ test/plan-count-quoted-frame-ak.test.ts | 82 +++++ 26 files changed, 2341 insertions(+), 43 deletions(-) create mode 100644 test/autoplan-repeated-header-ak.test.ts create mode 100644 test/ceo-finding-brief-ak.test.ts create mode 100644 test/ceo-numbered-brief-ak.test.ts create mode 100644 test/design-primary-contract-ak.test.ts create mode 100644 test/design-scope-declaration-ak.test.ts create mode 100644 test/dx-signature-identity-ak.test.ts create mode 100644 test/eng-declared-suite-ak.test.ts create mode 100644 test/fixtures/autoplan-repeated-header-ak.json create mode 100644 test/fixtures/ceo-finding-brief-ak.json create mode 100644 test/fixtures/ceo-numbered-brief-ak.json create mode 100644 test/fixtures/design-primary-contract-ak.json create mode 100644 test/fixtures/design-scope-declaration-ak.json create mode 100644 test/fixtures/dx-signature-identity-ak.json create mode 100644 test/fixtures/eng-declared-suite-ak.json create mode 100644 test/fixtures/plan-count-crop-ak.json create mode 100644 test/fixtures/plan-count-quoted-frame-ak.json create mode 100644 test/plan-count-crop-ak.test.ts create mode 100644 test/plan-count-quoted-frame-ak.test.ts diff --git a/test/autoplan-repeated-header-ak.test.ts b/test/autoplan-repeated-header-ak.test.ts new file mode 100644 index 000000000..b3c79c8f9 --- /dev/null +++ b/test/autoplan-repeated-header-ak.test.ts @@ -0,0 +1,91 @@ +import { afterEach, expect, test } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import captured from './fixtures/autoplan-repeated-header-ak.json'; +import published from './fixtures/autoplan-edit-prefix-ai.json'; +import { autoplanArtifactPermissionInput, pendingAutoplanArtifactPermissionInput, autoplanArtifactMenuKey } from './helpers/autoplan-artifact-permission'; +import type { NativePublicToolEvent } from './helpers/plan-count-transcript'; +import { E2E_TOUCHFILES, selectTests } from './helpers/touchfiles'; +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, {recursive:true,force:true}); }); +function replay() { + const root = fs.mkdtempSync(path.join(os.tmpdir(),'ap-repeat-ak-')); roots.push(root); + const cwd = path.join(root,path.basename(captured.cwd)), ownedStateRoot = path.join(root,'home','.gstack'); + const file = captured.pending.file.replace(captured.ownedStateRoot,ownedStateRoot); + fs.mkdirSync(cwd,{recursive:true}); fs.mkdirSync(path.dirname(file),{recursive:true}); + fs.writeFileSync(file,captured.events[0]!.input!.content!); + const old = new Date(Date.parse(captured.pending.timestamp)-1000); fs.utimesSync(file,old,old); + const events = structuredClone(captured.events) as NativePublicToolEvent[]; + for (const e of events) if (e.input?.file_path===captured.pending.file) e.input.file_path=file; + const context={cwd,ownedStateRoot,commandStartedAt:Date.parse(events[0]!.timestamp)-1,now:captured.viewportCapturedAt, + viewportCapturedAt:captured.viewportCapturedAt,transcriptStatus:'ready',publicTools:events, + pending:{...captured.pending,source:'pre_tool_use' as const,tool:'Edit' as const,file}}; + const viewport=captured.viewport.replace(/^ …[^\n]+$/m,' …'+path.relative(ownedStateRoot,file)); + return {root,file,context,viewport}; +} +const pick=(r:ReturnType,seen=new Set())=>pendingAutoplanArtifactPermissionInput(r.viewport,r.context,seen); + +test('the exact homogeneous repeated native title prefix preserves the current owned Edit',()=>{ + const r=replay(); expect(pick(r)).toEqual({input:'1\r',signature:r.context.pending.sessionId+':'+r.context.pending.toolUseId,file:r.file}); + expect(autoplanArtifactPermissionInput(r.viewport,r.context,new Set())).toBeNull(); +}); +test('two through seven identical owned titles and harmless blank spacing preserve the same panel',()=>{ + for(const count of [2,3,7]) { + const r=replay(); const panel=r.viewport.slice(r.viewport.indexOf('\n────────────────')+1); + const title=r.viewport.split('\n').find(s=>s.startsWith('● Update('))!; + r.viewport=Array(count).fill(title+'\n').join('\n')+'\n'+panel; + expect(pick(r)?.input).toBe('1\r'); + } +}); +test('foreign, mixed, malformed, quoted and competing prefix panels reject',()=>{ + for(const change of [ + (s:string)=>s.replace(/^● Update\([^\n]+\)/m,'● Update(/tmp/foreign.md)'), + (s:string)=>s.replace(/gstack-autoplan-chain-RWuak5/,'sibling-project'), + (s:string)=>s.replace(/^● Update/m,'● Read'), + (s:string)=>s.replace(/^● Update\(([^\n]+)\)/m,'● Update($1) extra command'), + (s:string)=>s.replace(/^● Update/m,'> ● Update'), + (s:string)=>'Example: current edit\n'+s, + (s:string)=>'```text\n'+s+'\n```', + (s:string)=>s.replace(/^● Update/m,'☐ Current task\n● Update'), + (s:string)=>s.replace(/^● Update/m,'Prior file completed\n● Update'), + (s:string)=>s.replace(' Edit file\n',' Read file\n'), + (s:string)=>s.replace(/^ …[^\n]+$/m,' /tmp/foreign.md'), + (s:string)=>s+'\n'+s, + ]) {const r=replay(); r.viewport=change(r.viewport); expect(pick(r)).toBeNull();} +}); +test('owned native epoch, content, successful predecessor and one-time keys remain mandatory',()=>{ + const r=replay(), result=pick(r)!; + expect(pick(r,new Set([result.signature]))).toBeNull(); + expect(pick(r,new Set([autoplanArtifactMenuKey(r.viewport)]))).toBeNull(); + for(const change of [ + (r:ReturnType)=>{r.context.pending.sessionId='foreign';}, + (r:ReturnType)=>{r.context.pending.file=r.file+'.foreign';}, + (r:ReturnType)=>{r.context.viewportCapturedAt=Date.parse(r.context.pending.timestamp)-1;}, + (r:ReturnType)=>{r.context.publicTools[1]!.isError=true;}, + (r:ReturnType)=>{r.context.publicTools.push({kind:'result',sessionId:r.context.pending.sessionId,toolUseId:r.context.pending.toolUseId,timestamp:new Date(r.context.now).toISOString(),isError:false});}, + (r:ReturnType)=>{r.context.publicTools.push({kind:'use',name:'Write',sessionId:r.context.pending.sessionId,toolUseId:'newer',timestamp:new Date(r.context.now).toISOString(),input:{file_path:r.file}});}, + (r:ReturnType)=>{fs.writeFileSync(r.file,'Foreign contents');}, + (r:ReturnType)=>{r.viewport=r.viewport.replace('❯ 1. Yes','❯ 2. Yes');}, + (r:ReturnType)=>{r.viewport=r.viewport.replace('3. No','3. No; run command');}, + (r:ReturnType)=>{r.viewport=r.viewport.replace('Esc to cancel · Tab to amend','');}, + ]) {const r=replay(); change(r); expect(pick(r)).toBeNull();} +}); +test('published Edit still needs exact old and new bytes with repeated titles',()=>{ + const r=replay(),events=structuredClone(published.events) as NativePublicToolEvent[]; + const edit=events.find(e=>e.kind==='use'&&e.toolUseId===published.pending.toolUseId)!; + const originalFile=edit.input!.file_path as string, file=originalFile.replace(published.ownedStateRoot,r.context.ownedStateRoot); + const cwd=path.join(r.root,path.basename(published.cwd));fs.mkdirSync(cwd,{recursive:true});fs.mkdirSync(path.dirname(file),{recursive:true});fs.writeFileSync(file,published.before); + for(const e of events)if(e.input?.file_path===originalFile)e.input.file_path=file; + const header=published.viewport.lastIndexOf('\n● Update(')+1; + const panel=published.viewport.slice(header).split('\n').slice(2).join('\n').replace(/^ …[^\n]+$/m,' …'+path.relative(r.context.ownedStateRoot,file)); + const title='● Update('+file+')\n\n',viewport=title+title+panel; + const context={cwd,ownedStateRoot:r.context.ownedStateRoot,commandStartedAt:Date.parse(events[0]!.timestamp)-1,now:Date.parse(published.viewportCapturedAt),transcriptStatus:'ready',publicTools:events}; + expect(autoplanArtifactPermissionInput(viewport,context,new Set())?.input).toBe('1\r'); + const before=edit.input!.new_string;edit.input!.new_string='Different replacement';expect(autoplanArtifactPermissionInput(viewport,context,new Set())).toBeNull(); + edit.input!.new_string=before;edit.input!.old_string='Different original';expect(autoplanArtifactPermissionInput(viewport,context,new Set())).toBeNull(); +}); +test('only existing Autoplan owner receives repeated-title regression inputs',()=>{ + for(const file of ['test/autoplan-repeated-header-ak.test.ts','test/fixtures/autoplan-repeated-header-ak.json']) + expect(selectTests([file],E2E_TOUCHFILES,[]).selected).toEqual(['autoplan-chain-pty']); +}); diff --git a/test/ceo-finding-brief-ak.test.ts b/test/ceo-finding-brief-ak.test.ts new file mode 100644 index 000000000..d5cf1d772 --- /dev/null +++ b/test/ceo-finding-brief-ak.test.ts @@ -0,0 +1,129 @@ +import { expect, test } from 'bun:test'; +import { ceoFirstReviewAUQ, ceoStep0Boundary, nativePlanCallFingerprint, planCountQuestionPhase } from './helpers/claude-pty-runner'; +import captured from './fixtures/ceo-finding-brief-ak.json'; +import { E2E_TOUCHFILES } from './helpers/touchfiles-data'; +const call = (index = 4): any => structuredClone(captured.calls[index]); +const fp = (c: any) => nativePlanCallFingerprint(c, 0, true); +function edit(c: any, change: (s: string) => string) { + const q = c.questions[0], answer = c.answers[q.question]; + q.question = change(q.question); c.answers = { [q.question]: answer }; +} +function offered(c: any, change: (o: any, i: number) => void) { + const q = c.questions[0], selected = q.options.findIndex((o: any) => o.label === c.answers[q.question]); + q.options.forEach(change); c.answers = { [q.question]: q.options[selected].label }; +} +test('the completed parenthesized finding with letter-only choices starts current review', () => { + expect(ceoFirstReviewAUQ(fp(call()))).toBe(true); +}); +test('the exact retry phase preserves four setup calls then six substantive choices', () => { + let started = false; + const phases = captured.calls.map(c => { + const phase = planCountQuestionPhase(fp(c), started, ceoStep0Boundary, ceoFirstReviewAUQ); + started = phase.reviewStarted; return phase.preReview; + }); + expect(phases).toEqual([true, true, true, true, false, false, false, false, false, false]); +}); + +test('the finding identity is independent of decision number, separator and optional qid', () => { + for (const change of [ + (s: string) => s.replace(/^D5/, 'D19'), + (s: string) => s.replace(') — ', ') - '), + (s: string) => s.replace('Finding 1.1', 'Finding 9.4'), + (s: string) => s.replace('Finding 1.1', 'Finding 1'), + (s: string) => s.replace(/\s*]+>\s*$/, ''), + (s: string) => s.replace(/\s*]+>\s*$/, '') + '\n', + (s: string) => s.replace('lets any mail failure', 'allows any mail failure'), + ]) { const c = call(); edit(c, change); expect(ceoFirstReviewAUQ(fp(c))).toBe(true); } + for (const label of call().questions[0].options.map((o: any) => o.label)) { + const c = call(); c.answers[c.questions[0].question] = label; + expect(ceoFirstReviewAUQ(fp(c))).toBe(true); + } + const numbered = call(); edit(numbered, s => s.replace(/^Recommendation: A/m, 'Recommendation: 1A')); + offered(numbered, o => { o.label = o.label.replace(/^([A-Z])\)/, '1$1)'); }); + expect(ceoFirstReviewAUQ(fp(numbered))).toBe(true); + const header = call(); header.questions[0].header = 'Finding 1.1'; + expect(ceoFirstReviewAUQ(fp(header))).toBe(true); +}); + +test('competing finding, section, recommendation and offered choice identities are rejected', () => { + for (const mutate of [ + (c: any) => { c.questions[0].header = 'Finding 1'; }, + (c: any) => { c.questions[0].header = 'Finding 9.1'; }, + (c: any) => { c.questions[0].header = 'Approach'; }, + (c: any) => edit(c, s => s.replace('Finding 1.1', 'Finding 1.0')), + (c: any) => edit(c, s => s.replace('Finding 1.1', 'Finding 1.1 and Finding 2.1')), + (c: any) => edit(c, s => s.replace(/^Recommendation: A/m, 'Recommendation: 2A')), + (c: any) => edit(c, s => s.replace(/^Recommendation: A/m, 'Recommendation: Z')), + (c: any) => { c.questions[0].options[0].label = '9A) Foreign issue'; c.answers = { [c.questions[0].question]: c.questions[0].options[0].label }; }, + (c: any) => { c.questions[0].options[1].label = 'A) Same choice letter, different action'; }, + (c: any) => { c.questions[0].options[1].label = c.questions[0].options[0].label; }, + (c: any) => edit(c, s => s + '\n'), + ]) { const c = call(); mutate(c); expect(ceoFirstReviewAUQ(fp(c))).toBe(false); } +}); + +test('a current complete assessment cannot come from source, conditions or a withdrawal', () => { + for (const change of [ + (s: string) => 'Example: ' + s, + (s: string) => '> ' + s, + (s: string) => '```\n' + s + '\n```', + (s: string) => s.replace(/^ELI10: .+$/m, ''), + (s: string) => s.replace(/^ELI10: (.+)$/m, 'ELI10: "$1"'), + (s: string) => s.replace(/^ELI10: /m, 'ELI10: If approved, '), + (s: string) => s.replace(/^ELI10: /m, 'ELI10: The following is a quoted source excerpt. '), + (s: string) => s.replace(/^ELI10: /m, 'ELI10: The following is a hypothetical example. '), + (s: string) => s + '\nThis finding is withdrawn.', + (s: string) => s + '\nFinding 1.1 is rejected.', + (s: string) => s + '\nNo current defect remains.', + (s: string) => s.replace(/^ELI10: .+$/m, 'ELI10: The plan no longer lets mail failures escape the handler. The current named rescue keeps them contained.'), + (s: string) => s.replace(/^ELI10: .+$/m, 'ELI10: The plan used to let mail failures escape the handler. That was the prior behavior.'), + (s: string) => s.replace(/^ELI10: .+$/m, 'ELI10: The plan does not let mail failures escape the handler.'), + (s: string) => s.replace(/^ELI10: .+$/m, 'ELI10: Source excerpt: the plan lets mail failures escape the handler.'), + (s: string) => s.replace(/^ELI10: .+$/m, 'ELI10: Previously, the plan lets mail failures escape the handler.'), + (s: string) => s.replace(/^ELI10: .+$/m, 'ELI10: The plan lets no mail failure escape the handler.'), + (s: string) => s.replace(/^ELI10: .+$/m, 'ELI10: The plan allows mail failures to escape only in a historical quoted example.'), + (s: string) => s.replace(/^ELI10: .+$/m, 'ELI10: Source excerpt. The plan lets mail failures escape the handler.'), + (s: string) => s.replace(/^ELI10: .+$/m, 'ELI10: The plan allows mail failures to never escape the handler.'), + ]) { const c = call(); edit(c, change); expect(ceoFirstReviewAUQ(fp(c))).toBe(false); } + const c = call(); edit(c, s => s + '\nOld note: "Finding 1.1 is rejected."'); + expect(ceoFirstReviewAUQ(fp(c))).toBe(true); +}); + +test('current finding prose must offer an actual remedy, not an advisory or hypothetical action', () => { + for (const description of [ + 'Archive this review for later.', + 'Historical source excerpt: ✅ Rescue named mail exceptions.', + 'The following is a quoted source excerpt. ✅ Rescue named mail exceptions.', + 'If approved: ✅ Rescue named mail exceptions.', + '❌ Rescue named mail exceptions.', + '✅ "Rescue named mail exceptions."', + '✅ If approved, rescue named mail exceptions.', + ]) { + const c = call(); offered(c, (o, i) => { o.label = `${String.fromCharCode(65 + i)}) Consider candidate ${i}`; o.description = description; }); + expect(ceoFirstReviewAUQ(fp(c))).toBe(false); + } +}); + +test('the completed native call, exact offered answer and fingerprint remain mandatory', () => { + for (const mutate of [ + (c: any) => { c.answered = false; }, + (c: any) => { c.failed = true; }, + (c: any) => { c.unansweredQuestionIndices = [0]; }, + (c: any) => { c.sessionId = ''; }, + (c: any) => { c.toolUseId = ''; }, + (c: any) => { c.answers = {}; }, + (c: any) => { c.answers[c.questions[0].question] = 'Unrelated answer'; }, + (c: any) => { c.questions[0].multiSelect = true; }, + (c: any) => { c.questions.push(structuredClone(c.questions[0])); }, + (c: any) => { c.questions[0].options[1].description = ''; }, + ]) { const c = call(); mutate(c); expect(ceoFirstReviewAUQ(fp(c))).toBe(false); } + const f = fp(call()); + expect(ceoFirstReviewAUQ({ ...f, signature: 'foreign:tool' })).toBe(false); + expect(ceoFirstReviewAUQ({ ...f, nativeCall: undefined })).toBe(false); + expect(ceoFirstReviewAUQ({ ...f, options: f.options.slice(1) })).toBe(false); +}); + +test('retry fixture and controls select only the existing CEO count owner', () => { + for (const dependency of ['test/ceo-finding-brief-ak.test.ts', 'test/fixtures/ceo-finding-brief-ak.json']) { + expect(Object.entries(E2E_TOUCHFILES).filter(([, paths]) => paths.includes(dependency)).map(([name]) => name)).toEqual(['plan-ceo-finding-count']); + } +}); diff --git a/test/ceo-numbered-brief-ak.test.ts b/test/ceo-numbered-brief-ak.test.ts new file mode 100644 index 000000000..68aaa9a90 --- /dev/null +++ b/test/ceo-numbered-brief-ak.test.ts @@ -0,0 +1,162 @@ +import { expect, test } from 'bun:test'; +import { ceoFirstReviewAUQ, ceoStep0Boundary, nativePlanCallFingerprint, planCountQuestionPhase } from './helpers/claude-pty-runner'; +import captured from './fixtures/ceo-numbered-brief-ak.json'; +import { E2E_TOUCHFILES } from './helpers/touchfiles-data'; + +const call = (index = 4): any => structuredClone(captured.calls[index]); +const fp = (c: any) => nativePlanCallFingerprint(c, 0, true); +function edit(c: any, change: (s: string) => string) { + const q = c.questions[0], answer = c.answers[q.question]; + q.question = change(q.question); c.answers = { [q.question]: answer }; +} +function offered(c: any, change: (o: any, i: number) => void) { + const q = c.questions[0], selected = q.options.findIndex((o: any) => o.label === c.answers[q.question]); + q.options.forEach(change); c.answers = { [q.question]: q.options[selected].label }; +} + +for (const [index, name] of [[4, 'email ordering'], [5, 'raw SQL'], [6, 'missing automated tests'], [7, 'N+1 read']] as const) { + test(`actual completed ${name} brief starts substantive CEO review`, () => { + expect(ceoFirstReviewAUQ(fp(call(index)))).toBe(true); + }); +} + +test('the complete captured phase retains routing and factual clarification as setup', () => { + let started = false; + const phases = captured.calls.map(c => { + const phase = planCountQuestionPhase(fp(c), started, ceoStep0Boundary, ceoFirstReviewAUQ); + started = phase.reviewStarted; + return phase.preReview; + }); + expect(phases).toEqual([true, true, true, true, false, false, false, false]); + for (const c of captured.calls.slice(0, 4)) expect(ceoFirstReviewAUQ(fp(c))).toBe(false); +}); + +test('issue ownership survives equivalent separators, optional qids and consistent renumbering', () => { + for (let index = 4; index < 8; index++) { + for (const separator of ['—', '–', '-']) { + const c = call(index); edit(c, s => s.replace(/^D\d+ — /, `D12 ${separator} `).replace(/\s*]+>\s*$/, '')); + expect(ceoFirstReviewAUQ(fp(c))).toBe(true); + } + const c = call(index), old = index - 2; + edit(c, s => s.replace(`Issue ${old}:`, 'Issue 19:').replace(new RegExp('\\b' + old + '([A-Z])\\b', 'g'), '19$1')); + offered(c, o => { o.label = o.label.replace(/^\d+/, '19'); }); + expect(ceoFirstReviewAUQ(fp(c))).toBe(true); + c.answers[c.questions[0].question] = c.questions[0].options[2].label; + expect(ceoFirstReviewAUQ(fp(c))).toBe(true); + } +}); + +test('display effort and positive bullet decoration do not change an offered action', () => { + for (let index = 4; index < 8; index++) for (const change of [ + (s: string) => s.replace(/Human [^.]+\. /, 'Human 2 days / CC 30 minutes. '), + (s: string) => s.replace(/Human [^.]+\. /, '').replace(/✅ /g, ''), + (s: string) => s.replace(/✅ /g, '✅ '), + ]) { + const c = call(index); offered(c, o => { o.description = change(o.description); }); + expect(ceoFirstReviewAUQ(fp(c))).toBe(true); + } + const suite = call(6); offered(suite, o => { o.label = o.label.replace('unit + integration', 'unit and integration'); }); + expect(ceoFirstReviewAUQ(fp(suite))).toBe(true); +}); + +test('native completion, the offered answer and exact fingerprint remain mandatory', () => { + for (let index = 4; index < 8; index++) for (const mutate of [ + (c: any) => { c.answered = false; }, + (c: any) => { c.failed = true; }, + (c: any) => { c.unansweredQuestionIndices = [0]; }, + (c: any) => { c.sessionId = ''; }, + (c: any) => { c.toolUseId = ''; }, + (c: any) => { c.answers = {}; }, + (c: any) => { c.answers[c.questions[0].question] = 'Unrelated answer'; }, + (c: any) => { c.questions[0].multiSelect = true; }, + (c: any) => { c.questions.push(structuredClone(c.questions[0])); }, + (c: any) => { c.questions[0].header = 'Approach'; }, + (c: any) => { c.questions[0].options[1].description = ''; }, + (c: any) => { c.questions[0].options[1].label = c.questions[0].options[0].label; }, + (c: any) => edit(c, s => s.replace(/]+>/, '')), + ]) { const c = call(index); mutate(c); expect(ceoFirstReviewAUQ(fp(c))).toBe(false); } + for (let index = 4; index < 8; index++) { + const f = fp(call(index)); + expect(ceoFirstReviewAUQ({ ...f, signature: 'foreign:tool' })).toBe(false); + expect(ceoFirstReviewAUQ({ ...f, nativeCall: undefined })).toBe(false); + expect(ceoFirstReviewAUQ({ ...f, options: f.options.slice(1) })).toBe(false); + } +}); + +test('a current issue cannot borrow another issue identity or recommendation', () => { + for (let index = 4; index < 8; index++) for (const mutate of [ + (c: any) => edit(c, s => s.replace(/Issue \d+:/, 'Issue 99:')), + (c: any) => { c.questions[0].header = 'Finding 99'; }, + (c: any) => { c.questions[0].options[1].label = '99B: Foreign choice'; }, + (c: any) => { c.questions[0].options[1].label = c.questions[0].options[1].label.replace(/B:/, 'A:'); }, + (c: any) => edit(c, s => s.replace(/^Recommendation: \d+[A-Z]/m, 'Recommendation: 99A')), + (c: any) => edit(c, s => s.replace(/^Recommendation: .+$/m, '')), + (c: any) => edit(c, s => s + '\nRecommendation: 99A'), + ]) { const c = call(index); mutate(c); expect(ceoFirstReviewAUQ(fp(c))).toBe(false); } +}); + +test('source, hypothetical and withdrawn assessments do not start current review', () => { + for (let index = 4; index < 8; index++) for (const change of [ + (s: string) => 'Example: ' + s, + (s: string) => '> ' + s, + (s: string) => '```\n' + s + '\n```', + (s: string) => s.replace(/^ELI10: (.+)$/m, 'ELI10: "$1"'), + (s: string) => s.replace(/^ELI10: /m, 'ELI10: If approved, '), + (s: string) => s.replace(/^ELI10: /m, 'ELI10: Suppose '), + (s: string) => s.replace(/^ELI10: /m, 'ELI10: The following is a quoted source excerpt. '), + (s: string) => s.replace(/^ELI10: /m, 'ELI10: The following is a hypothetical example. '), + (s: string) => s.replace(/^ELI10: .+$/m, ''), + (s: string) => s + '\nThis issue has been withdrawn.', + (s: string) => s + `\nIssue ${index - 2} is resolved.`, + (s: string) => s + '\nNo current issue remains.', + ]) { const c = call(index); edit(c, change); expect(ceoFirstReviewAUQ(fp(c))).toBe(false); } + for (let index = 4; index < 8; index++) { + const c = call(index); edit(c, s => s + '\nOld note: "This issue has been withdrawn."'); + expect(ceoFirstReviewAUQ(fp(c))).toBe(true); + } +}); + +test('the new declarative findings still need an asserted current defect', () => { + for (const [index, title] of [ + [5, 'the lookup does not interpolate request.params.userId into a raw SQL fragment.'], + [5, 'the lookup no longer interpolates request.params.userId into a raw SQL fragment.'], + [5, 'the lookup used to interpolate user input into a raw SQL string.'], + [6, 'automated tests are planned for the new payment handler.'], + [7, 'the handler no longer fetches each order in a loop (N+1).'], + [7, 'the handler reads all orders with one query.'], + ] as const) { + const c = call(index); + edit(c, s => s.replace(/^(D\d+ — Issue \d+: ).+$/m, '$1' + title) + .replace(/^ELI10: .+$/m, title.includes('used to') + ? 'ELI10: The previous lookup used to interpolate user input into a raw SQL string. The current lookup uses bound parameters and has no injection risk.' + : 'ELI10: The current implementation satisfies the stated contract.')); + expect(ceoFirstReviewAUQ(fp(c))).toBe(false); + } +}); + +test('only an offered current amendment can supply remedy evidence', () => { + for (let index = 4; index < 8; index++) for (const description of [ + 'Keep this advisory report for reference.', + 'Human ~3h / CC ~15min. ❌ Add bounded error handling.', + 'Human ~3h / CC ~15min. ❌ A prior proposal. Add bounded error handling.', + 'Human ~3h / CC ~15min. ✅ "Add bounded error handling."', + 'Human ~3h / CC ~15min. ✅ If approved, add bounded error handling.', + 'Human ~3h / CC ~15min. ✅ Write the completed report.', + 'Historical source excerpt: ✅ Add bounded error handling.', + 'If approved: ✅ Add bounded error handling.', + 'Hypothetical example: ✅ Add bounded error handling.', + 'The following is a quoted source excerpt. ✅ Add bounded error handling.', + 'The following is a hypothetical example. ✅ Add bounded error handling.', + ]) { + const c = call(index); + offered(c, (o, i) => { o.label = `${index - 2}${String.fromCharCode(65 + i)}: Consider candidate ${i}`; o.description = description; }); + expect(ceoFirstReviewAUQ(fp(c))).toBe(false); + } +}); + +test('only the existing CEO finding-count owner selects this regression fixture', () => { + for (const dependency of ['test/ceo-numbered-brief-ak.test.ts', 'test/fixtures/ceo-numbered-brief-ak.json']) { + expect(Object.entries(E2E_TOUCHFILES).filter(([, paths]) => paths.includes(dependency)).map(([name]) => name)) + .toEqual(['plan-ceo-finding-count']); + } +}); diff --git a/test/design-primary-contract-ak.test.ts b/test/design-primary-contract-ak.test.ts new file mode 100644 index 000000000..b2c83cc0e --- /dev/null +++ b/test/design-primary-contract-ak.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from 'bun:test'; +import fixture from './fixtures/design-primary-contract-ak.json'; +import { isDesignCountFirstReview } from './helpers/design-count-review'; +import type { AskUserQuestionFingerprint } from './helpers/claude-pty-runner'; + +type FP = AskUserQuestionFingerprint; +type Question = NonNullable['questions'][number]; +const original = () => structuredClone(fixture.fingerprint) as unknown as FP; +function edit(change: (q: Question, fp: FP) => void): FP { + const fp = original(), call = fp.nativeCall!, q = call.questions[0]!; + const selected = q.options.findIndex(o => o.label === call.answers![q.question]); + change(q, fp); + call.answers = { [q.question]: q.options[selected]!.label }; + fp.options = q.options.map((o, i) => ({ index: i + 1, label: o.label })); + return fp; +} +function replaceText(q: Question, from: string, to: string): void { + q.question = q.question.replaceAll(from, to); + q.options = q.options.map(o => ({ + ...o, label: o.label.replaceAll(from, to), + description: o.description?.replaceAll(from, to), + })); +} + +describe('current primary-action contract from a completed native issue', () => { + test('the owned Issue 1 is a review decision despite colon-numbered options', () => { + expect(isDesignCountFirstReview(original())).toBe(true); + }); + test.each([ + ['renamed primary control', (q: Question) => replaceText(q, 'Save', 'Submit')], + ['other prescribed color', (q: Question) => replaceText(q, '#1d4ed8', '#234abc')], + ['numeric button count', (q: Question) => replaceText(q, 'are four identical buttons', 'are 4 identical buttons')], + ['explicit all button count', (q: Question) => replaceText(q, 'are four identical buttons', 'are all four identical buttons')], + ['uncounted current equality', (q: Question) => replaceText(q, 'are four identical buttons', 'are identical buttons')], + ['parenthesized option separators', (q: Question) => { + q.options = q.options.map(o => ({ ...o, label: o.label.replace(/^1([ABC]):/, '1$1)') })); + }], + ['current pro/con decline', (q: Question) => { q.options[2]!.description = '✅ No implementation work now. ✅ No visual retesting. ❌ ' + q.options[2]!.description; }], + ['same contract with explicit button noun', (q: Question) => { + q.options[0]!.description = q.options[0]!.description!.replace('neutral ghost.', 'neutral ghost buttons.'); + }], + ])('%s preserves the actual contract', (_, change) => { + expect(isDesignCountFirstReview(edit(change))).toBe(true); + }); + const negatives: Array<[string, (q: Question, fp: FP) => void]> = [ + ['failed call', (_, fp) => { fp.nativeCall!.failed = true; }], + ['unanswered call', (_, fp) => { fp.nativeCall!.answered = false; }], + ['pending question', (_, fp) => { fp.nativeCall!.unansweredQuestionIndices = [0]; }], + ['missing successful answer time', (_, fp) => { delete fp.nativeCall!.answeredAt; }], + ['invalid answer time', (_, fp) => { fp.nativeCall!.answeredAt = 'unknown'; }], + ['unowned signature', (_, fp) => { fp.signature = 'other:call'; }], + ['missing session', (_, fp) => { fp.nativeCall!.sessionId = ''; }], + ['multiple questions', (q, fp) => { fp.nativeCall!.questions.push(structuredClone(q)); }], + ['multiple selections', q => { q.multiSelect = true; }], + ['competing issue header', q => { q.header = 'Issue 2'; }], + ['competing control header', q => { q.header = 'Issue 1: Cancel'; }], + ['competing option identity', q => { q.options[0]!.label = q.options[0]!.label.replace('1A:', '2A:'); }], + ['duplicate options', q => { q.options[1]!.label = q.options[0]!.label; }], + ['historical assessment', q => replaceText(q, 'ELI10: Right now', 'ELI10: Previously')], + ['quoted assessment', q => replaceText(q, 'ELI10: Right now', 'ELI10: "Right now')], + ['conditional assessment', q => replaceText(q, 'ELI10: Right now', 'ELI10: If right now')], + ['negated equality', q => replaceText(q, 'are four identical buttons', 'are not identical buttons')], + ['other equal controls', q => replaceText(q, 'Right now Save, Reset', 'Right now Undo, Reset')], + ['no current assessment', q => { q.question = q.question.replace(/^ELI10:.*\n/m, ''); }], + ['hypothetical issue', q => { q.question += '\nThis issue is hypothetical.'; }], + ['withdrawn issue', q => { q.question += '\nIssue 1 has been withdrawn.'; }], + ['resolved issue', q => { q.question += '\nNo current gap remains.'; }], + ['amendment only quotes source', q => { q.options[0]!.description = '> ' + q.options[0]!.description; }], + ['conditional amendment', q => { q.options[0]!.description = 'If approved later, ' + q.options[0]!.description; }], + ['negated amendment', q => { q.options[0]!.description = 'Do not ' + q.options[0]!.description; }], + ['other primary amendment', q => { q.options[0]!.description = q.options[0]!.description!.replace('Save #', 'Reset #'); }], + ['administrative record action', q => { q.options[0]!.description = 'Record the current review in the plan file.'; }], + ['wrong design authority', q => { q.options[0]!.description = q.options[0]!.description!.replace('DESIGN.md', 'an archived example'); }], + ['no fill prescribed', q => { q.options[0]!.description = q.options[0]!.description!.replace('filled with', 'outlined with'); }], + ['no ghost secondary controls', q => { q.options[0]!.description = q.options[0]!.description!.replace('neutral ghost', 'identical filled'); }], + ['no opposed choice', q => { q.options[2]!.label = '1C: Export settings instead'; }], + ['opposed choice does not retain the gap', q => { q.options[2]!.description = 'The gap is already fixed; file the report.'; }], + ['opposed choice withdraws finding', q => { q.options[2]!.description += ' Issue 1 is withdrawn.'; }], + ['fenced assessment', q => { q.question = q.question.replace(/^(ELI10:.*)$/m, '```text\n$1\n```'); }], + ['competing assessments', q => { q.question += '\nELI10: Save is already the unique primary action; all secondary controls are ghosts.'; }], + ['assessment relabelled as history', q => { q.question = q.question.replace(/^(ELI10:.*)$/m, '$1 Correction: the identical-buttons sentence is a historical example, not the current UI.'); }], + ['primary also styled as secondary', q => { q.options[0]!.description = q.options[0]!.description!.replace('Reset, Cancel, Export neutral ghost.', 'Save, Reset, Cancel, Export neutral ghost.'); }], + ['later style cancellation', q => { q.options[0]!.description += ' Correction: do not apply these tokens; Save remains identical to the other buttons.'; }], + ['historical icon-prefixed decline', q => { q.options[2]!.description = 'Historical source excerpt: ❌ ' + q.options[2]!.description; }], + ['conditional icon-prefixed decline', q => { q.options[2]!.description = 'If approved later: ❌ ' + q.options[2]!.description; }], + ['historical pro/con decline', q => { q.options[2]!.description = '✅ Historical example: no implementation work. ❌ ' + q.options[2]!.description; }], + ['conditional pro/con decline', q => { q.options[2]!.description = '✅ If approved later: no implementation work. ❌ ' + q.options[2]!.description; }], + ['opposed gap later resolved', q => { q.options[2]!.description += ' Correction: this gap is already resolved; no style change is required.'; }], + ]; + test.each(negatives)('%s is not current completed review evidence', (_, change) => { + expect(isDesignCountFirstReview(edit(change))).toBe(false); + }); + test('an unknown answer or mismatched rendered menu cannot supply completion', () => { + const answer = original(); + answer.nativeCall!.answers = { [answer.nativeCall!.questions[0]!.question]: 'not offered' }; + expect(isDesignCountFirstReview(answer)).toBe(false); + const menu = original(); + menu.options[0]!.label = 'different visible choice'; + expect(isDesignCountFirstReview(menu)).toBe(false); + }); +}); diff --git a/test/design-scope-declaration-ak.test.ts b/test/design-scope-declaration-ak.test.ts new file mode 100644 index 000000000..5489c7dc0 --- /dev/null +++ b/test/design-scope-declaration-ak.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from 'bun:test'; +import { nativeSeededPlanSelection } from './helpers/plan-scope-selection'; +import fixture from './fixtures/design-scope-declaration-ak.json'; +import type { PlanCountTranscript, NativePublicToolEvent } from './helpers/plan-count-transcript'; +import { E2E_TOUCHFILES, selectTests } from './helpers/touchfiles'; + +const input = (attempt = 0) => structuredClone(fixture.attempts[attempt]!.projection); +const verdict = (p = input()) => nativeSeededPlanSelection(p.transcript as PlanCountTranscript, p.tools as NativePublicToolEvent[], p.opts); +const declaration = (p: ReturnType) => p.transcript.assistantMessages.find(m => /^(?:I'll proceed with reviewing|Scope gate confirms plan mode)/.test(m.text))!; + +test('both exact owned post-load announcements select the named pasted draft', () => { + for (let attempt = 0; attempt < 2; attempt++) { + const p = input(attempt); + expect(fixture.attempts[attempt]!.rawScopeGateAutoSelectObserved).toBe(false); + expect(verdict(p)).toBe(true); + } +}); + +test('the prior AJ pre-load-only announcement still supplies no selected-plan evidence', () => { + const p = fixture.priorGenuineFailure.projection; + expect(nativeSeededPlanSelection(p.transcript as PlanCountTranscript, p.tools as NativePublicToolEvent[], p.opts)).toBe(false); +}); + +test('target identity and ordinary equivalent current review wording remain bound', () => { + for (let attempt = 0; attempt < 2; attempt++) { + const p = input(attempt); p.opts.seed = p.opts.seed.replace('Marketing landing page', 'Account settings'); + p.transcript.assistantMessages.forEach(m => { m.text = m.text.replaceAll('Marketing landing page', 'Account settings'); }); + expect(verdict(p)).toBe(true); + } + const p = input(); declaration(p).text = declaration(p).text.replace("I'll proceed", 'I will proceed'); expect(verdict(p)).toBe(true); +}); + +test('source, historical, quoted, hypothetical and conditional introductions do not select', () => { + for (let attempt = 0; attempt < 2; attempt++) for (const prefix of [ + '> ', ' ', 'Source excerpt:\n', 'Historical example only.\n', 'The following is hypothetical. ', 'If approved, ', '```\n', '"', + ]) { + const p = input(attempt), m = declaration(p); p.transcript.assistantMessages = [m]; m.text = prefix + m.text; + expect(verdict(p)).toBe(false); + } +}); + +test('a different target or conditional scope announcement cannot borrow the draft name', () => { + for (let attempt = 0; attempt < 2; attempt++) for (const change of [ + (s: string) => s.replaceAll('Marketing landing page', 'Checkout redesign'), + (s: string) => s.replace(/draft(?: plan)?/, 'draft plan if approved'), + ]) { const p = input(attempt), m = declaration(p); p.transcript.assistantMessages = [m]; m.text = change(m.text); expect(verdict(p)).toBe(false); } + for (const prefix of ['Scope gate might confirm plan mode, so', 'Scope gate confirms branch mode, so']) { + const p = input(1); declaration(p).text = declaration(p).text.replace('Scope gate confirms plan mode, so', prefix); expect(verdict(p)).toBe(false); + } +}); + +test('the same successful Skill load and post-command current session remain necessary', () => { + for (let attempt = 0; attempt < 2; attempt++) for (const change of [ + (p: ReturnType) => { p.opts.sessionId = 'foreign'; }, + (p: ReturnType) => { p.tools[1]!.isError = true; }, + (p: ReturnType) => { p.tools[1]!.toolUseId = 'foreign'; }, + (p: ReturnType) => { p.tools[0]!.input!.skill = 'plan-eng-review'; }, + (p: ReturnType) => { p.opts.commandStartedAt = Date.parse(p.tools[1]!.timestamp) + 1; }, + (p: ReturnType) => { declaration(p).timestamp = new Date(Date.parse(p.tools[1]!.timestamp) - 1).toISOString(); p.transcript.assistantMessages = [declaration(p)]; }, + ]) { const p = input(attempt); change(p); expect(verdict(p)).toBe(false); } +}); + +test('same-message and later current withdrawals or replacement targets defeat selection', () => { + for (let attempt = 0; attempt < 2; attempt++) for (const correction of [ + 'Correction: this selection is withdrawn.', + 'The selected target is now the branch diff.', + 'This declaration has been retracted.', + ]) for (const later of [false, true]) { + const p = input(attempt), m = declaration(p); p.transcript.assistantMessages = [m]; + if (later) p.transcript.assistantMessages.push({ ...m, timestamp: new Date(Date.parse(m.timestamp) + 1000).toISOString(), text: correction }); + else m.text += '\n' + correction; + expect(verdict(p)).toBe(false); + } +}); + +test('literal or foreign corrections preserve the actual declaration and a later reselection is current', () => { + for (let attempt = 0; attempt < 2; attempt++) { + const p = input(attempt), m = declaration(p); p.transcript.assistantMessages = [m]; + p.transcript.assistantMessages.push({ ...m, sessionId: 'foreign', text: 'The selected target is now the branch diff.' }); + p.transcript.assistantMessages.push({ ...m, text: '> This selection is withdrawn.' }); expect(verdict(p)).toBe(true); + p.transcript.assistantMessages.push({ ...m, timestamp: new Date(Date.parse(m.timestamp) + 1000).toISOString(), text: 'This selection is withdrawn.' }); expect(verdict(p)).toBe(false); + p.transcript.assistantMessages.push({ ...m, timestamp: new Date(Date.parse(m.timestamp) + 2000).toISOString() }); expect(verdict(p)).toBe(true); + } +}); + +test('the new evidence dependencies select exactly the existing five scope observers', () => { + const expected = selectTests(['test/helpers/plan-scope-selection.ts'], E2E_TOUCHFILES, []).selected; + expect(expected).toHaveLength(5); + for (const path of ['test/design-scope-declaration-ak.test.ts','test/fixtures/design-scope-declaration-ak.json']) expect(selectTests([path], E2E_TOUCHFILES, []).selected).toEqual(expected); +}); diff --git a/test/dx-signature-identity-ak.test.ts b/test/dx-signature-identity-ak.test.ts new file mode 100644 index 000000000..b131a91b5 --- /dev/null +++ b/test/dx-signature-identity-ak.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from 'bun:test'; +import { devexSeedCoverage } from './helpers/devex-seed-coverage'; +import type { NativePlanQuestionCall, PlanCountTranscript } from './helpers/plan-count-transcript'; +import fixture from './fixtures/dx-signature-identity-ak.json'; +import { E2E_TOUCHFILES, matchGlob } from './helpers/touchfiles'; + +function transcript(): PlanCountTranscript { + return { status: 'ready', calls: [structuredClone(fixture.call) as NativePlanQuestionCall], assistantMessages: [] }; +} +function found(t: PlanCountTranscript) { return devexSeedCoverage(t).decisions['reversed-arguments'].length > 0; } +function question(t: PlanCountTranscript, change: (text: string) => string) { + const c = t.calls[0]!, q = c.questions[0]!, selected = c.answers![q.question]!; + q.question = change(q.question); c.answers = { [q.question]: selected }; +} + +describe('DX argument identities in the current decision explanation', () => { + test('the actual completed D5 decision identifies the reversed seed without supplying the other four', () => { + const t = transcript(), result = devexSeedCoverage(t); + expect(found(t)).toBe(true); + expect(result.decisions['reversed-arguments']).toEqual([`${fixture.call.sessionId}:${fixture.call.toolUseId}`]); + expect(result.complete).toBe(false); + expect(result.missing).toHaveLength(4); + }); + test('inline signature formatting and source line-number changes preserve the same current identities', () => { + for (const change of [ + (s: string) => s.replace('lines 5-9', 'lines 12–16'), + (s: string) => s.replaceAll('`run_eval(dataset, evaluator)`', 'run_eval(dataset, evaluator)').replaceAll('`run_batch(evaluator, dataset)`', 'run_batch(evaluator, dataset)'), + (s: string) => s.replace('Journey stage REAL USAGE: ', ''), + ]) { const t = transcript(); question(t, change); expect(found(t)).toBe(true); } + }); + test('same-order, foreign-function, missing-signature and borrowed-body evidence is insufficient', () => { + for (const change of [ + (s: string) => s.replace('run_batch(evaluator, dataset)', 'run_batch(dataset, evaluator)'), + (s: string) => s.replace('run_batch(evaluator, dataset)', 'run_many(evaluator, dataset)'), + (s: string) => s.replace('run_eval(dataset, evaluator)', 'run_score(dataset, evaluator)'), + (s: string) => s.replace(' and `run_batch(evaluator, dataset)`', ''), + (s: string) => s.replace('ELI10: docs/api.md lines 5-9 define', 'ELI10: Another issue is worth discussing. docs/api.md lines 5-9 define'), + (s: string) => s.replace('the two public functions take the same two arguments in opposite positional order. How should the plan fix the signatures?', 'Should the report mention both public functions?'), + ]) { const t = transcript(); question(t, change); expect(found(t)).toBe(false); } + }); + test('quoted, historical, hypothetical and conditional explanations cannot supply current identity', () => { + for (const intro of ['Source excerpt: ', 'If approved: ', 'Historically, ', 'The following is a hypothetical example. ', '`', '> ']) { + const t = transcript(); question(t, s => s.replace('ELI10: ', `ELI10: ${intro}`)); expect(found(t)).toBe(false); + } + for (const prefix of ['Source excerpt:\n', 'If approved:\n', 'Historical example:\n', '```\n']) { + const t = transcript(); question(t, s => s.replace('ELI10:', `${prefix}ELI10:`)); expect(found(t)).toBe(false); + } + for (const phrase of ['used to define', 'would define', 'do not define']) { + const t = transcript(); question(t, s => s.replace('lines 5-9 define', `lines 5-9 ${phrase}`)); expect(found(t)).toBe(false); + } + const t = transcript(); question(t, s => s.replace('on `main`; reviewing', 'on `main`; the following is a quoted source example, not a current finding; reviewing')); + expect(found(t)).toBe(false); + }); + test('same-finding withdrawals and corrected current order defeat the new route', () => { + for (const tail of [ + 'Correction: this finding is withdrawn.', + 'The argument-order issue is already resolved.', + 'These signatures are historical, not current.', + 'There is no argument-order defect.', + 'run_eval and run_batch now use the same positional order.', + ]) { const t = transcript(); question(t, s => `${s}\n\n${tail}`); expect(found(t)).toBe(false); } + }); + test('later literal quotations do not retract the actual decision', () => { + for (const tail of [ + '> Correction: this finding is withdrawn.', + 'Old note: "The argument-order issue is already resolved."', + '```\nThese signatures are historical, not current.\n```', + 'If this fix is accepted, the argument-order issue is already resolved in the proposed API.', + ]) { const t = transcript(); question(t, s => `${s}\n\n${tail}`); expect(found(t)).toBe(true); } + }); + test('the title and each inline signature must be asserted, with both new order and swap guard in one offered action', () => { + for (const change of [ + (s: string) => s.replace('D5 — Journey', 'D5 — `Journey').replace('signatures?\n', 'signatures?`\n'), + (s: string) => s.replace('`run_eval(dataset, evaluator)`', '`run_eval(dataset, evaluator)'), + ]) { const t = transcript(); question(t, change); expect(found(t)).toBe(false); } + for (const change of [ + (s: string) => s.replace('Both become', 'If approved, both become'), + (s: string) => s.replace('Both become', 'Quoted source: Both become'), + (s: string) => s.replace('(dataset, evaluator)', '(evaluator, dataset)'), + (s: string) => s.replace('raise a call-site `TypeError`', 'raise a generic error'), + (s: string) => s.replace('naming the swapped argument and the fix', 'without naming the swapped argument or a fix'), + ]) { + const t = transcript(); t.calls[0]!.questions[0]!.options[0]!.description = change(t.calls[0]!.questions[0]!.options[0]!.description!); + expect(found(t)).toBe(false); + } + }); + test('a competing explanation or direct finding, explanation or offered-action withdrawal gives no credit', () => { + for (const change of [ + (s: string) => s + '\nELI10: The public functions already use the same positional order; this is not a current defect.', + (s: string) => s.replace(/^(ELI10:.*)$/m, '$1 Correction: this explanation is historical source material, not the current API.'), + (s: string) => s + '\nCorrection: this argument-order issue is resolved.', + ]) { const t = transcript(); question(t, change); expect(found(t)).toBe(false); } + const t = transcript(); t.calls[0]!.questions[0]!.options[0]!.description += ' Correction: do not change either signature or add a swap guard.'; + expect(found(t)).toBe(false); + const quoted = transcript(); question(quoted, s => s + '\n```\nELI10: The public functions already use the same positional order.\n```'); + quoted.calls[0]!.questions[0]!.options[0]!.description += '\nOld note: "Correction: do not change either signature or add a swap guard."'; + expect(found(quoted)).toBe(true); + }); + test('the offered corrective option is required, while choosing a genuine alternate or defer remains a decision', () => { + const t = transcript(), c = t.calls[0]!, q = c.questions[0]!; + for (const option of q.options) { c.answers = { [q.question]: option.label }; expect(found(t)).toBe(true); } + q.options = q.options.slice(2); c.answers = { [q.question]: q.options[0]!.label }; + expect(found(t)).toBe(false); + }); + test('pending, failed, stale-answer, repeated identity and mixed-session native records stay invalid', () => { + const mutations: Array<(t: PlanCountTranscript) => void> = [ + t => { t.calls[0]!.answered = false; }, t => { t.calls[0]!.failed = true; }, + t => { t.calls[0]!.unansweredQuestionIndices = [0]; }, t => { t.calls[0]!.answeredAt = 'invalid'; }, + t => { t.calls[0]!.answers = { 'A different question': t.calls[0]!.questions[0]!.options[0]!.label }; }, + t => { t.calls[0]!.questions[0]!.multiSelect = true; }, + ]; + for (const change of mutations) { const t = transcript(); change(t); expect(found(t)).toBe(false); } + const duplicated = transcript(); duplicated.calls.push(structuredClone(duplicated.calls[0]!)); + expect(devexSeedCoverage(duplicated).invalid.length).toBeGreaterThan(0); + const foreign = transcript(); foreign.calls.push({ ...structuredClone(foreign.calls[0]!), sessionId: 'foreign', toolUseId: 'foreign' }); + expect(devexSeedCoverage(foreign).invalid.length).toBeGreaterThan(0); + }); + test('only the DX count owner gains the live regression test and public fixture', () => { + for (const file of ['test/dx-signature-identity-ak.test.ts', 'test/fixtures/dx-signature-identity-ak.json']) { + expect(Object.entries(E2E_TOUCHFILES).filter(([, deps]) => deps.some(p => matchGlob(file, p))).map(([name]) => name)).toEqual(['plan-devex-finding-count']); + } + }); +}); diff --git a/test/eng-declared-suite-ak.test.ts b/test/eng-declared-suite-ak.test.ts new file mode 100644 index 000000000..5b565905b --- /dev/null +++ b/test/eng-declared-suite-ak.test.ts @@ -0,0 +1,120 @@ +import { expect, test } from 'bun:test'; +import fixture from './fixtures/eng-declared-suite-ak.json'; +import { evaluateEngSeedCoverage } from './helpers/eng-seeded-coverage'; +import type { PlanCountTranscript } from './helpers/plan-count-transcript'; +import { E2E_TOUCHFILES, selectTests } from './helpers/touchfiles'; + +const plan = [fixture.required, '## Implementation Tasks\n\n' + fixture.task, fixture.verification, fixture.reviewReport].join('\n\n'); +const { start, end } = fixture.provenance.window; +const native = () => structuredClone(fixture.transcript) as PlanCountTranscript; +const evaluate = (p = plan, t = native()) => evaluateEngSeedCoverage(t, p, start, end); + +test('the required characterization suite binds the current legacy oracle, task and both router paths', () => { + expect(evaluate().regression).toBe('plan'); + expect(evaluate().ok).toBe(true); +}); + +test('presentation and task/router identity vary without weakening the baseline', () => { + for (const p of [ + plan.replaceAll('T7', 'T19'), + plan.replaceAll('routeAuth', 'dispatchAuth'), + plan.replace('before touching it', 'before refactoring it'), + plan.replace('capture current inputs and outputs', 'record current inputs and outputs'), + plan.replaceAll('tests/regression', 'tests/auth-regression'), + plan.replace('success,\nexpired token, bad signature', 'success,\nexpired token, invalid audience'), + plan + '\n## Assessment of T12\nT12 is cancelled.', + plan + '\n## Payment regression suite\nThe regression suite is no longer required.', + plan.replace(fixture.task, fixture.task + '\nOld note: "T7 is cancelled."'), + plan + '\n## Historical note\n"The legacy regression suite is no longer required."', + plan.replace(fixture.task, '- [ ] T6 — tests/renderer — Test display text\n - Verify: renders the literal text "This is a hypothetical example."\n\n' + fixture.task), + ]) { expect(p).not.toBe(plan); expect(evaluate(p).regression).toBe('plan'); } +}); + +test('the declaration and task require current legacy capture on both paths', () => { + for (const p of [ + plan.replace(fixture.required, ''), plan.replace(fixture.task, ''), plan.replace(fixture.verification, ''), + plan.replace('mandatory rule, no approval needed', 'optional future idea'), + plan.replace('before touching it', 'after rewriting it'), + plan.replace('capture current inputs and outputs', 'describe proposed inputs and outputs'), + plan.replace('the same suite against `routeAuth` on both flag settings', 'the same suite against `routeAuth` on the new setting'), + plan.replace('A behavior difference between\npaths is a test failure', 'A behavior difference between\npaths is acceptable'), + plan.replace('suite for legacyAuthFlow(), run on both router paths', 'suite for newAuthFlow(), run on both router paths'), + plan.replace('suite passes on legacy before any refactor', 'suite passes on legacy after the refactor'), + plan.replace('passes on new path before flag enable', 'passes on new path after flag enable'), + ]) { expect(p).not.toBe(plan); expect(evaluate(p).regression).toBeUndefined(); } +}); + +test('the comparison uses the unchanged legacy result before the refactor', () => { + for (const p of [ + plan.replace('on the unmodified code', 'on the modified code'), + plan.replace('against `legacyAuthFlow()` on the unmodified code', 'against `newAuthFlow()` on the unmodified code'), + plan.replace('must pass before any refactor lands', 'may pass after the refactor lands'), + plan.replace('through `routeAuth` with the flag on `new`', 'through `differentRouter` with the flag on `new`'), + plan.replace('with the flag on `new`', 'with the flag on `legacy`'), + plan.replace('zero differences', 'accepted differences'), + plan.replace(/^1\. Run the characterization.+$/m, ''), + plan.replace(/^3\. Run the characterization.+$/m, ''), + plan.replace(/^1\. Run the characterization/m, '4. Run the characterization'), + plan.replace(/^1\. Run the characterization/m, 'If approved:\n1. Run the characterization'), + plan.replace(/^3\. Run the characterization/m, 'If approved:\n3. Run the characterization'), + ]) { expect(p).not.toBe(plan); expect(evaluate(p).regression).toBeUndefined(); } +}); + +test('quoted, proposed and conditional owners cannot provide the current requirement', () => { + for (const p of [ + '# Source\n\n' + plan, + '# Hypothetical example\n\n' + plan, + 'The following is source text only.\n\n' + plan, + plan.replace(fixture.required, '```md\n' + fixture.required + '\n```'), + plan.replace(fixture.task, fixture.task.split('\n').map(s => '> ' + s).join('\n')), + plan.replace(fixture.verification, '```md\n' + fixture.verification + '\n```'), + plan.replace('### REGRESSION', '### Proposed REGRESSION'), + plan.replace('**Add a characterization', '**If approved, add a characterization'), + plan.replace('**Add a characterization', 'If approved:\n**Add a characterization'), + plan.replace(fixture.task, 'If approved:\n' + fixture.task), + plan.replace('## Implementation Tasks', '## Optional Implementation Tasks'), + plan.replace('## Verification (end to end)', '## Quoted Verification (end to end)'), + plan.replace('**Add a characterization', 'The following is a quoted source excerpt.\n**Add a characterization'), + plan.replace('1. Run the characterization', 'The following is a quoted source excerpt.\n1. Run the characterization'), + plan.replace(' - Verify: suite passes', ' If approved:\n - Verify: suite passes'), + ]) { expect(evaluate(p).regression).toBeUndefined(); } +}); + +test('the owned suite, numbered task and baseline may be explicitly withdrawn', () => { + for (const p of [ + plan.replace(fixture.required, fixture.required + '\nThis suite is withdrawn.'), + plan.replace(fixture.task, fixture.task + '\nT7 is cancelled.'), + plan + '\n## Assessment of T7\nT7 is rejected.', + plan + '\n## Final regression suite assessment\nThe regression suite is no longer required.', + plan + '\n## Payment regression suite\nThe legacy regression suite is no longer required.', + plan.replace(fixture.verification, fixture.verification + '\nThis baseline is no longer required.'), + plan.replace(fixture.verification, fixture.verification + '\nSkip the characterization suite.'), + plan.replace(fixture.task, fixture.task + '\nCorrection: this unchanged-code verification is withdrawn.'), + ]) expect(evaluate(p).regression).toBeUndefined(); +}); + +for (const prefix of ['If approved:', 'The following is a quoted source excerpt.']) { + test(`a previous task cannot hide the next task's owning prefix: ${prefix}`, () => { + const p = plan.replace(fixture.task, '- [ ] T6 — tests/setup — Prepare fixtures\n - Verify: setup is ready.\n\n' + prefix + '\n' + fixture.task); + expect(evaluate(p).regression).toBeUndefined(); + }); +} + +test('all four separate owned decisions and the final review report remain required', () => { + expect(evaluate().missing).toEqual([]); + expect(new Set(Object.values(evaluate().decisions)).size).toBe(4); + expect(evaluate(plan.replace(fixture.reviewReport, '')).ok).toBe(false); + for (const mutate of [ + (t: PlanCountTranscript) => { t.calls[0]!.answered = false; }, + (t: PlanCountTranscript) => { t.calls[0]!.sessionId = 'foreign'; }, + (t: PlanCountTranscript) => { t.calls[0]!.answeredAt = new Date(start - 1).toISOString(); }, + (t: PlanCountTranscript) => { t.calls[0]!.answeredAt = new Date(end + 1).toISOString(); }, + (t: PlanCountTranscript) => { t.calls.push(structuredClone(t.calls[0]!)); }, + ]) { const t = native(); mutate(t); expect(evaluate(plan, t).ok).toBe(false); } +}); + +test('the new exact public regression evidence belongs only to the existing Eng count owner', () => { + for (const file of ['test/eng-declared-suite-ak.test.ts', 'test/fixtures/eng-declared-suite-ak.json']) { + expect(selectTests([file], E2E_TOUCHFILES, []).selected).toEqual(['plan-eng-finding-count']); + } +}); diff --git a/test/fixtures/autoplan-repeated-header-ak.json b/test/fixtures/autoplan-repeated-header-ak.json new file mode 100644 index 000000000..a57e0123f --- /dev/null +++ b/test/fixtures/autoplan-repeated-header-ak.json @@ -0,0 +1,41 @@ +{ + "sourceCommit": "04c62ac678bb7bc1a22090f72f7ed51c451c22b9", + "cwd": "/tmp/gstack-paid-shard-R1Epd7/tmp/gstack-autoplan-chain-RWuak5", + "ownedStateRoot": "/tmp/gstack-paid-shard-R1Epd7/tmp/gstack-hermetic-1325468-PiGFgQ/skill-home-ifHsqQ/.gstack", + "pending": { + "source": "pre_tool_use", + "sessionId": "cc8879c8-8129-4a97-8740-0c847bca26ec", + "toolUseId": "toolu_01M4G37jNLHYYwR5pdAsBz2k", + "tool": "Edit", + "file": "/tmp/gstack-paid-shard-R1Epd7/tmp/gstack-hermetic-1325468-PiGFgQ/skill-home-ifHsqQ/.gstack/projects/gstack-autoplan-chain-RWuak5/ceo-plans/2026-09-10-user-dashboard.md", + "timestamp": "2026-09-10T07:02:05.600Z" + }, + "viewport": " \n\u25cf Update(~/.gstack/projects/gstack-autoplan-chain-RWuak5/ceo-plans/2026-09-10-user-dashboard.md)\n \n\u25cf Update(~/.gstack/projects/gstack-autoplan-chain-RWuak5/ceo-plans/2026-09-10-user-dashboard.md)\n \n\u25cf Update(~/.gstack/projects/gstack-autoplan-chain-RWuak5/ceo-plans/2026-09-10-user-dashboard.md)\n \n\u25cf Update(~/.gstack/projects/gstack-autoplan-chain-RWuak5/ceo-plans/2026-09-10-user-dashboard.md)\n \n\u25cf Update(~/.gstack/projects/gstack-autoplan-chain-RWuak5/ceo-plans/2026-09-10-user-dashboard.md)\n \n\u25cf Update(~/.gstack/projects/gstack-autoplan-chain-RWuak5/ceo-plans/2026-09-10-user-dashboard.md)\n \n\u25cf Update(~/.gstack/projects/gstack-autoplan-chain-RWuak5/ceo-plans/2026-09-10-user-dashboard.md)\n \n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n Edit file\n \u20261325468-PiGFgQ/skill-home-ifHsqQ/.gstack/projects/gstack-autoplan-chain-RWuak5/ceo-plans/2026-09-10-user-dashboard.md\n\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\n 20 | Dependency | Used by | Reuse effort (human / CC) | Build-first effort if absent (human / CC) | Status |\n 21 |---|---|---|---|---|\n 22 | Cookie sessions + workspace membership middleware supplying member and workspace IDs | A-1 | included | ~2 days /\n ~30 min | UNVERIFIED |\n 23 -| Activity and notification list methods (latest 20 + cursor, indexed) | A-1 | included | ~1 day / ~20 min | UNVERI\n -FIED | \n 23 +| Activity and notification list methods (latest 20 + cursor, indexed); notification record carries a `read` boolea\n +n | A-1, A-5 | included | ~1 day / ~20 min | UNVERIFIED | \n 24 | Action registry with three actions and server-side eligibility predicates | A-4 | ~2h / ~5 min | ~1 day / ~20 min\n | UNVERIFIED |\n 25 | Member-scoped idempotent bulk-read API accepting a snapshot time | A-5 | included | ~1 day / ~20 min (a new mutat\n ion API, which the plan currently forbids; needs a decision, see A-11) | UNVERIFIED |\n 26 | Typed HTTP client errors (five kinds above) | A-1, A-2 | included | ~4h / ~10 min | UNVERIFIED |\n\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\n Do you want to make this edit to 2026-09-10-user-dashboard.md?\n \u276f 1. Yes\n 2. Yes, and switch to accept edits (auto-approve file edits and common file commands) for this session (shift+tab)\n 3. No\n\n Esc to cancel \u00b7 Tab to amend\n", + "viewportCapturedAt": 1789023827035, + "events": [ + { + "sessionId": "cc8879c8-8129-4a97-8740-0c847bca26ec", + "timestamp": "2026-09-10T06:59:16.372Z", + "toolUseId": "toolu_01R8TPTAoUnmsr2pbcE9cLuY", + "kind": "use", + "name": "Write", + "input": { + "file_path": "/tmp/gstack-paid-shard-R1Epd7/tmp/gstack-hermetic-1325468-PiGFgQ/skill-home-ifHsqQ/.gstack/projects/gstack-autoplan-chain-RWuak5/ceo-plans/2026-09-10-user-dashboard.md", + "content": "---\nstatus: ACTIVE\n---\n# CEO Plan: User Dashboard Page\nGenerated by /plan-ceo-review (via /autoplan) on 2026-09-10\nBranch: main | Mode: SELECTIVE EXPANSION\nRepo: gstack-autoplan-chain-RWuak5 (no remote)\n\n## Glossary\n- **CC**: Claude Code (AI-assisted) implementation time. Effort is shown as `human / CC`.\n- **md breakpoint**: Tailwind default, 768px min-width. \"Below md\" means viewports narrower than 768px (this includes the 640px `sm` step and the base range under it). Test widths used throughout: 375px (below md), 768px (md), 1280px (lg).\n- **Tokens**: the app's existing Tailwind theme (spacing, color, typography scales in the Tailwind config). \"Token-only\" means no literal hex/rgb/px values in dashboard or feedback components.\n- **PanelResult**: the per-panel result envelope defined under A-1 below.\n- **Holdout**: a flag cohort that keeps today's post-login behavior so the dashboard cohort can be compared against it.\n- **Typed client error kinds**: the five existing HTTP client error kinds: `unauthenticated`, `forbidden`, `validation`, `retryable-service`, `network`.\n\n## Verified-dependencies checklist (blocks implementation start, see A-11)\nThe plan's \"existing contracts\" section describes these as already built. This repository contains only README.md and the plan, so none could be verified during review (Final Gate item PC-1). Each row must be confirmed by the implementer before work starts; an absent dependency flips its row to \"build minimal version first\" and its effort to the build-first column.\n\n| Dependency | Used by | Reuse effort (human / CC) | Build-first effort if absent (human / CC) | Status |\n|---|---|---|---|---|\n| Cookie sessions + workspace membership middleware supplying member and workspace IDs | A-1 | included | ~2 days / ~30 min | UNVERIFIED |\n| Activity and notification list methods (latest 20 + cursor, indexed) | A-1 | included | ~1 day / ~20 min | UNVERIFIED |\n| Action registry with three actions and server-side eligibility predicates | A-4 | ~2h / ~5 min | ~1 day / ~20 min | UNVERIFIED |\n| Member-scoped idempotent bulk-read API accepting a snapshot time | A-5 | included | ~1 day / ~20 min (a new mutation API, which the plan currently forbids; needs a decision, see A-11) | UNVERIFIED |\n| Typed HTTP client errors (five kinds above) | A-1, A-2 | included | ~4h / ~10 min | UNVERIFIED |\n| Analytics events: login, action start, action completion, permission error | A-6, A-7 | included | ~1 day / ~20 min; if build-first, use the A-7 baseline fallback | UNVERIFIED |\n| Feature flags with member-cohort assignment | A-7 | included | ~1 day / ~20 min | UNVERIFIED |\n| Tailwind tokens, page shell, button, link, dialog primitive (focus trap, Escape, focus return) | A-2, A-5, A-10 | included | ~1 day / ~20 min | UNVERIFIED |\n| Toast primitive | A-3 | reuse if present | ~1 day / ~15 min (the plan states none exists) | UNVERIFIED |\n| Vitest, RTL, Playwright in CI; fixtures for authenticated member, other workspace, empty lists, service failures | A-9 | included | ~1 day / ~20 min | UNVERIFIED |\n| Request metrics with per-route RPS (for the load figure in A-7 step 2) | A-7 | included | ~4h / ~10 min | UNVERIFIED |\n\n## Vision\n\n### 10x Check\nThe page that already knows your next task. A member lands and the dashboard has picked the item they were going to open: \"Resume: Invoice review (3 waiting)\". No scanning three pages, no choosing. Concrete shape: eligibility-filtered quick actions today (v1), per-action eligible counts and \"new since your last visit\" markers next (deferred: they need new count queries and last-seen persistence, which the v1 plan forbids). v1 lays the rails: one endpoint returning per-panel results, one panel shell, one shared toast primitive, and a rollout with a holdout so the team can prove the page moves login-to-first-completed-task from the measured baseline toward 45s.\n\n### Platonic Ideal\nNot produced in SELECTIVE EXPANSION mode.\n\n## Metrics\n- **Primary metric**: login-to-first-completed-task time, p50 and p90. Definition: elapsed time from the existing `login` analytics event to the first existing `action completion` event for that member in the same session.\n- **Baseline**: TBD. If the analytics dependency is present: pull the production p50 and p90 over the trailing 28 days before the first cohort is enabled and record both here. If the analytics dependency is build-first: no historical baseline exists; the first 7 days of the holdout cohort's p50/p90 become the baseline, and the relative target (dashboard beats holdout) becomes primary while the absolute 45s target is secondary. The plan's 75s figure came from a single internal walkthrough and is never the baseline.\n- **Target**: dashboard-cohort p50 lower than holdout p50 over the same window (primary), and dashboard p50 \u2264 45s (secondary when the baseline is the holdout).\n- **Guardrails** (dashboard cohort vs holdout, same window): completed-task rate may not drop more than 2 percentage points; permission-error rate may not exceed holdout by more than 2 percentage points.\n- **Evaluation window**: each rollout step is evaluated only after both at least 7 days and at least 500 sessions per arm have accrued, whichever is later. Adjust the 500 from current session volume if a 2-point change is not detectable at that count; record the chosen N here.\n- **Operational thresholds**: `GET /api/dashboard` p95 \u2264 2.5s (an SLO and alert threshold, not a request-level abort); any panel error rate \u2264 5%; dashboard 5xx = 0 and panel error rate < 1% in the first 5 minutes after deploy.\n- **Segmentation**: A-6 dashboard events split the primary metric into navigation time (login \u2192 dashboard exposure \u2192 action click) and scan time (action click \u2192 completion).\n\n## Scope Decisions\n\n| # | Proposal | Effort (human / CC) | Decision | Reasoning |\n|---|----------|--------|----------|-----------|\n| E-1 | Server-side eligibility filtering of quick actions | ~2h / ~5 min (reuse) | ACCEPTED (A-4), conditional on predicates existing | Protects the permission-error guardrail |\n| E-2 | Per-action eligible-item counts | ~3 days / ~30 min | DEFERRED | Needs new count queries the plan says do not exist |\n| E-3 | Skeleton geometry matches final rows | ~1h / ~5 min | ACCEPTED (A-2) | Plan already requires skeletons; this makes them shift-free |\n| E-4 | Auto-focus first quick action on load | ~30 min / ~2 min | SKIPPED | Disorienting for screen-reader users; focus stays on the page heading |\n| E-5 | \"New since last visit\" markers | ~3 days / ~30 min | DEFERRED | Needs last-seen persistence, a new mutation API |\n| E-6 | Per-panel Retry that re-applies only that panel's result | ~2h / ~5 min | ACCEPTED (A-1, A-2) | Required for honest partial-failure UX |\n| E-7 | Optional third rollout arm: redirect post-login to the resume-work page with a \"Next up\" strip of eligible quick actions | ~3 days / ~20 min (new strip component + redirect + cohort) | PROVISIONALLY ACCEPTED as taste decision T-1; the user may override at the Final Gate | Tests whether the cheapest alternative hits the metric. The mandatory pure holdout (A-7) does not depend on it |\n| E-8 | Refetch on tab refocus | ~2h / ~5 min | DEFERRED | Not needed for v1; per-panel `fetchedAt` (A-1) already makes it safe to add later |\n| E-9 | Notification deep links to subject item | ~2h / ~5 min | DEFERRED | Notification record shape unverified |\n| E-10 | Per-sub-query timing metrics | ~1h / ~5 min | ACCEPTED (A-8) | Needed to find the slow panel when p95 regresses |\n\n## Accepted Scope (added to this plan)\n\n- **A-1 Aggregate endpoint envelope.** `GET /api/dashboard` reads member and workspace IDs from request context only and accepts no query parameters. It runs three sub-queries in parallel (activity latest, notifications latest, eligible quick actions), each with a 2s timeout. For an authenticated member of the requested workspace it always returns HTTP 200 with `{ activity: PanelResult, notifications: PanelResult, quickActions: PanelResult }` where `PanelResult = { status: \"ok\", data: T[], fetchedAt: string, durationMs: number } | { status: \"error\", error: { kind: \"timeout\" | \"service\" | \"internal\", retryable: boolean }, fetchedAt: string, durationMs: number }` and `fetchedAt` is the server ISO-8601 time that panel's query was issued. Row types: `NotificationRow { id, title, read: boolean, createdAt }`, `ActivityRow { id, summary, actorName, createdAt }`, `QuickAction { id, label, href }`, serialized from the existing repository and registry types; the notification type must carry a `read` flag. Timeout \u2192 `kind: \"timeout\", retryable: true`; repository/DB error \u2192 `kind: \"service\", retryable: true`; undefined or malformed repository result \u2192 `kind: \"internal\", retryable: false`. All three failing still returns 200 with three error panels. No session \u2192 401 from the existing middleware; member of another workspace \u2192 403. One `toPanelResult(settled, panelName, issuedAt)` helper does the mapping; no catch-all handler. There is no request-level time budget; the 2.5s figure is the p95 SLO in Metrics. The `PanelResult` and row types are defined once and shared by server and client. Retry for one panel re-calls the full endpoint and applies only the retried panel's `PanelResult`, leaving the other two panels' state and `fetchedAt` untouched.\n- **A-2 Shared panel shell and page-level states.** One `DashboardPanel` component owns loading, empty, error, and success rendering for all three panels; `QuickActions`, `NotificationsPanel`, and `ActivityFeed` supply only row renderers and copy. Skeletons match the final row count and geometry. Copy table:\n\n | Panel | Empty | Error (retryable) | Error (internal) |\n |---|---|---|---|\n | QuickActions | \"No actions available right now\" | \"Couldn't load actions. Retry\" | \"Something went wrong. We've been notified\" |\n | NotificationsPanel | \"You're all caught up\" | \"Couldn't load notifications. Retry\" | same |\n | ActivityFeed | \"No recent changes\" | \"Couldn't load activity. Retry\" | same |\n\n Retry re-applies only its panel (A-1) and is disabled while in flight. Whole-request failure (typed `network`, `retryable-service`, or `validation` from a malformed body): all three panels render the retryable error state and any panel's Retry re-issues the request. Typed `unauthenticated`: the existing client handler redirects to login preserving the return path. Typed `forbidden` on the GET: the existing forbidden handler renders the app's forbidden page. Reduced-motion disables skeleton shimmer. If T-2 stands: below md, `ActivityFeed` renders inside a disclosure labeled \"Recent changes (N)\" where N is the number of rows returned, collapsed by default, with expanded state exposed to assistive technology.\n- **A-3 Shared toast primitive.** Reuse the app's toast primitive if one exists (checklist row). Otherwise build `ToastRegion` as an app-level shared primitive: mounted once at app root; renders a `role=\"status\"` live region that exists before the first toast fires; renders text nodes only (no HTML); queue capacity 3, overflow drops the oldest toast and logs the drop; supports a dismiss control and an optional action (used for Retry); no slide/fade transitions under `prefers-reduced-motion`. The queue exists because this is the app-level contract for future callers, not a v1 dashboard need; v1 fires at most one toast at a time.\n- **A-4 Eligibility-filtered quick actions.** The endpoint applies the existing server-side eligibility predicates in parallel with the list queries and returns only eligible actions. A throwing predicate omits that action and logs the action id. Zero eligible actions \u2192 `status: \"ok\", data: []` and the empty state.\n- **A-5 Mark all as read.** The control is enabled only when at least one returned notification row has `read === false`; unread count is computed over the returned rows, and the dialog copy states \"Mark all notifications as read? This includes older notifications not shown here.\" The dialog composes the existing dialog primitive. Confirm calls the existing member-scoped idempotent bulk-read API with `snapshot = notifications.fetchedAt` from the envelope, never client time and never another panel's `fetchedAt`, so notifications that arrived after that instant remain unread. The confirm control is disabled while submitting; a double click sends one request. Success: the notifications panel alone re-applies from a fresh call (per A-1) and a success toast fires. Failure: the dialog closes, no local state changes, and an error toast fires with a Retry action that resubmits with the identical snapshot; a CSRF rejection arrives as the typed `forbidden` error during submit and maps to the copy \"Session expired, reload\" with no Retry action. If A-11 finds the bulk-read API absent and a new mutation API is not approved, A-5 is dropped from v1, the notifications panel ships without a Mark all as read control, and T-3 becomes moot.\n- **A-6 Product instrumentation.** Events: `dashboard_viewed {cohort}`, `dashboard_panel_rendered {panel, status, durationMs}`, `dashboard_quick_action_clicked {actionId}`, `dashboard_notification_clicked {notificationId}`, `dashboard_mark_all_read_submitted`, `dashboard_mark_all_read_succeeded`, `dashboard_mark_all_read_failed {errorKind}` where `errorKind` is one of the five typed client error kinds, `dashboard_panel_retry_clicked {panel}`. All carry member id, workspace id, and session id so they join to the existing login, action-start, action-completion, and permission-error events.\n- **A-7 Rollout: baseline, holdout, criteria, rollback.** Sequencing: (0) record the baseline per Metrics (historical if analytics exist, otherwise the holdout's first 7 days); (1) deploy dark with flag `dashboard_v1` off; (2) staging verification: E2E suite, axe at 375/768/1280, handler p95 \u2264 2.5s under 3x the current peak requests-per-second of the busiest of the three replaced pages, read from existing request metrics; (3) enable `dashboard_v1` for 5% of members with a 5% pure holdout cohort `dashboard_holdout` (today's landing behavior, zero build); (4) 25%/25%; (5) 50%/50%; (6) 100% dashboard only if every Metrics target and guardrail holds over the 50/50 evaluation window. Each step waits for the evaluation window (\u2265 7 days and \u2265 N sessions per arm) before promotion. The optional third arm `dashboard_control_redirect` (E-7) is added at step 3 only if T-1 stands at the Final Gate; the holdout comparison never depends on it. Rollback at any step is flag off; nothing to unwind. Post-deploy checks: first 5 minutes dashboard 5xx = 0, panel error rate < 1%, p95 < 2.5s; first hour guardrails stable.\n- **A-8 Operational observability.** Log: one structured line per request with member id, workspace id, per-panel status, per-panel durationMs, plus one warning per panel error with error class and panel name. Metrics: `dashboard.request.duration` histogram, `dashboard.panel.status{panel,status}` counter, `dashboard.panel.duration{panel}` histogram, `dashboard.markallread{outcome}` counter. Alerts: page p95 > 2.5s for 10 minutes; any panel error rate > 5% for 10 minutes; dashboard-cohort permission-error rate exceeding holdout by 2 points. Runbook: panel error spike \u2192 check that panel's dependency; p95 spike \u2192 compare panel durations to find the slow one; guardrail regression \u2192 flag off.\n- **A-9 Test plan.** Vitest unit: `toPanelResult` (ok, timeout, throw, undefined; `fetchedAt` set on every variant), eligibility filtering (filtered set, throwing predicate omitted, zero eligible), toast primitive (live region present, text-only, 4 \u2192 3 overflow, dismiss, action, reduced motion), analytics events fire once with payload. RTL: `DashboardPanel` every state per panel; Retry re-applies only its panel (retry Activity, then assert notifications rows and `notifications.fetchedAt` unchanged); whole-request failure renders three retryable error panels; skeleton row count equals data row count; `MarkAllReadDialog` sends exactly `notifications.fetchedAt` even after an Activity retry produced a newer response, one request on double click, per-outcome toasts, Retry toast resubmits the identical snapshot, `forbidden` during submit shows the session-expired copy, Escape returns focus, zero-unread disabled, notification newer than `fetchedAt` stays unread; if T-2 stands, the Activity disclosure is collapsed by default below md and announces state. Integration: handler all-ok, one-panel-failure still 200, all-fail, 401, other-workspace 403, query params ignored, using existing fixtures plus new \"one panel errors, two succeed\" and \"all three empty\" fixtures. Playwright: login \u2192 dashboard \u2192 quick action lands on target; one-panel-503 degradation; keyboard-only traversal with visible focus on every control; axe zero violations at 375/768/1280. Timeout tests use fake timers.\n- **A-10 Token-only styling.** All dashboard, dialog, and toast styling uses existing Tailwind tokens; no literal hex/rgb/px values, so a future dark-mode plan is a token swap. Touch targets \u2265 44px below md. A CI lint/grep for literal colors in the dashboard and feedback component directories; axe contrast checks in E2E.\n- **A-11 Dependency verification gate.** Before implementation starts, confirm every row of the Verified-dependencies checklist against the target codebase and record status. Any absent dependency switches that row to build-first and re-estimates the plan. For the bulk-read API specifically, an explicit decision to allow a new mutation API is required before A-5 proceeds; if denied, A-5's fallback applies. If a toast primitive is present, A-3 reuses it.\n\n## Taste decisions surfaced to the Final Gate\n- **T-1** Add the optional redirect + \"Next up\" strip arm (E-7) alongside the mandatory holdout. Recommended: yes, it tests the cheapest alternative. Cost if wrong: ~3 days / ~20 min of throwaway experiment UI.\n- **T-2** Panel order QuickActions \u2192 Notifications \u2192 Activity at every breakpoint; below md, ActivityFeed collapses to a \"Recent changes (N)\" disclosure. Recommended: yes. Alternative: the plan's listing order (Activity first).\n- **T-3** Keep the confirmation modal for Mark all as read. Recommended: keep. The independent reviewer recommends inline undo instead; undo would need an un-read API, which does not exist per the plan and which the plan forbids adding. The modal is the only safety mechanism in v1.\n\n## Premise challenge queued for the Final Gate\n- **PC-1** The repository contains only README.md and the plan. Every \"existing contract\" the plan relies on is unverifiable here. If the plan targets this repo as-is, every \"reuse\" row above becomes \"build first\" and total effort roughly triples. A-11 turns this into an implementation gate rather than an assumption.\n\n## Deferred to TODOS.md\n- E-2 Per-action eligible counts (P2, ~3 days / ~30 min). Depends on v1 shipped and measured.\n- E-5 \"New since last visit\" markers (P3, ~3 days / ~30 min). Depends on allowing a new mutation API.\n- E-8 Refetch on tab refocus (P3, ~2h / ~5 min). Per-panel `fetchedAt` keeps mark-all-read correct when added.\n- E-9 Notification deep links (P3, ~2h / ~5 min). Depends on confirming the notification schema.\n" + } + }, + { + "sessionId": "cc8879c8-8129-4a97-8740-0c847bca26ec", + "timestamp": "2026-09-10T06:59:19.075Z", + "toolUseId": "toolu_01R8TPTAoUnmsr2pbcE9cLuY", + "kind": "result", + "content": "The file /tmp/gstack-paid-shard-R1Epd7/tmp/gstack-hermetic-1325468-PiGFgQ/skill-home-ifHsqQ/.gstack/projects/gstack-autoplan-chain-RWuak5/ceo-plans/2026-09-10-user-dashboard.md has been updated successfully. (file state is current in your context \u2014 no need to Read it back)", + "isError": false + } + ], + "provenance": { + "all100PublicToolsProofSHA": "7638fb4cb24cd5b8da3bc984dd5fe5981aad28169c228d87fd9f586f7c165945", + "beforeMatchesLatestSuccessfulWrite": true, + "projection": "Only latest successful same-file Write/result retained here; all100 original public events are separately retained and replayed in context." + } +} diff --git a/test/fixtures/ceo-finding-brief-ak.json b/test/fixtures/ceo-finding-brief-ak.json new file mode 100644 index 000000000..4bb25402d --- /dev/null +++ b/test/fixtures/ceo-finding-brief-ak.json @@ -0,0 +1,317 @@ +{ + "provenance": { + "rootSourceHead": "04c62ac678bb7bc1a22090f72f7ed51c451c22b9", + "publicCallsSha256": "994eaaf948b694066785c3b5a2e7396acdff490a8339756e34aed5ca85497c82", + "scope": "Exact ten completed owned native calls from the naturally failed CEO distinct retry; no retrospective paid credit." + }, + "calls": [ + { + "sessionId": "1f7fabc1-f286-440b-9904-2d37428020dd", + "toolUseId": "toolu_012qz23sgxBENoM5xLQYcrH6", + "questions": [ + { + "question": "D1 \u2014 Add gstack skill routing rules to CLAUDE.md?\nProject/branch/task: main branch, plan-review fixture repo; one-time gstack onboarding prompt fired by skill-start.\nELI10: gstack wants a short \"Skill routing\" section appended to your project's CLAUDE.md so future requests like \"review this diff\" auto-route to the right skill. Note: we're in plan mode, so if you pick A the CLAUDE.md edit and commit are deferred until plan mode exits; I'll record the choice now.\nStakes if we pick wrong: Nothing breaks either way. Skipping means you invoke skills by name manually; adding means one extra section in CLAUDE.md.\nRecommendation: B because this repo is a review fixture with a purpose-built CLAUDE.md, and plan mode blocks the edit right now anyway.\nNote: options differ in kind, not coverage \u2014 no completeness score.\nNet: convenience routing vs keeping a fixture CLAUDE.md untouched.", + "header": "Routing", + "multiSelect": false, + "options": [ + { + "label": "B) No thanks, invoke manually (recommended)", + "description": "\u2705 Keeps the fixture CLAUDE.md exactly as seeded for this review run. \u2705 Nothing to defer past plan mode; records routing_declined in gstack config only. \u274c Future sessions won't auto-route requests to gstack skills without naming them." + }, + { + "label": "A) Add routing rules to CLAUDE.md", + "description": "\u2705 Future \"review this\" / \"ship it\" requests auto-route to the matching gstack skill. \u2705 Standard gstack setup, one 15-line section. \u274c Cannot be applied in plan mode; the CLAUDE.md edit and commit would wait until after this review exits plan mode." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D1 \u2014 Add gstack skill routing rules to CLAUDE.md?\nProject/branch/task: main branch, plan-review fixture repo; one-time gstack onboarding prompt fired by skill-start.\nELI10: gstack wants a short \"Skill routing\" section appended to your project's CLAUDE.md so future requests like \"review this diff\" auto-route to the right skill. Note: we're in plan mode, so if you pick A the CLAUDE.md edit and commit are deferred until plan mode exits; I'll record the choice now.\nStakes if we pick wrong: Nothing breaks either way. Skipping means you invoke skills by name manually; adding means one extra section in CLAUDE.md.\nRecommendation: B because this repo is a review fixture with a purpose-built CLAUDE.md, and plan mode blocks the edit right now anyway.\nNote: options differ in kind, not coverage \u2014 no completeness score.\nNet: convenience routing vs keeping a fixture CLAUDE.md untouched.": "B) No thanks, invoke manually (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T06:52:22.522Z" + }, + { + "sessionId": "1f7fabc1-f286-440b-9904-2d37428020dd", + "toolUseId": "toolu_01RxSxiz2k6HAvuASxyDVgfA", + "questions": [ + { + "question": "D2 \u2014 Enable cross-project learnings search?\nProject/branch/task: main branch; first-run gstack config prompt (cross_project_learnings is unset).\nELI10: gstack keeps a local log of lessons learned per project. It can also search lessons from your other projects on this machine to spot patterns that apply here. Everything stays on disk locally; nothing is uploaded.\nStakes if we pick wrong: Enabling on a machine with multiple client codebases could surface one client's patterns while working on another. Disabling just means fewer prior-learning hits.\nRecommendation: A because this is a local-only lookup and a solo review session; more prior context makes findings sharper.\nNote: options differ in kind, not coverage \u2014 no completeness score.\nNet: richer prior-learning recall vs strict per-project isolation.", + "header": "Learnings", + "multiSelect": false, + "options": [ + { + "label": "A) Enable cross-project learnings (recommended)", + "description": "\u2705 Prior learnings from other local projects surface as \"Prior learning applied\" hints during review. \u2705 Local-only search over ~/.gstack; nothing leaves the machine. \u274c On a multi-client machine, patterns from one codebase could bleed into another's review." + }, + { + "label": "B) Keep learnings project-scoped only", + "description": "\u2705 Strict isolation: only this project's learnings are ever consulted. \u2705 Safe default for consultants working across unrelated client repos. \u274c This fresh project has zero learnings, so the search returns nothing useful today." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D2 \u2014 Enable cross-project learnings search?\nProject/branch/task: main branch; first-run gstack config prompt (cross_project_learnings is unset).\nELI10: gstack keeps a local log of lessons learned per project. It can also search lessons from your other projects on this machine to spot patterns that apply here. Everything stays on disk locally; nothing is uploaded.\nStakes if we pick wrong: Enabling on a machine with multiple client codebases could surface one client's patterns while working on another. Disabling just means fewer prior-learning hits.\nRecommendation: A because this is a local-only lookup and a solo review session; more prior context makes findings sharper.\nNote: options differ in kind, not coverage \u2014 no completeness score.\nNet: richer prior-learning recall vs strict per-project isolation.": "A) Enable cross-project learnings (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T06:52:40.654Z" + }, + { + "sessionId": "1f7fabc1-f286-440b-9904-2d37428020dd", + "toolUseId": "toolu_019FbetNiYLD2JbFo9gSmD4Q", + "questions": [ + { + "question": "D3 \u2014 How should StripePaymentWebhookHandler be wired into the ingress?\nProject/branch/task: main; PLAN.md \"Architecture\" says the new handler bypasses WebhookDispatcher for namespace separation.\nELI10: The plan promises the new handler \"runs inside the unchanged guards\" (signature, dedup, per-user lock, ownership, unknown-user). It also says it bypasses the existing dispatcher. Those two claims only both hold if every guard lives outside the dispatcher, and the repo has no code to confirm that. If any guard lives in the dispatcher, bypassing it silently drops that guard.\nStakes if we pick wrong: A dropped dedup or lock guard means a Stripe retry double-processes a payment or races account deletion; a dropped ownership guard lets one PaymentIntent mark a different user paid.\nRecommendation: B because it makes \"runs inside unchanged guards\" true by construction instead of by assertion, and namespace separation is achieved with a module, not a bypass. Maps to \"explicit over clever\" and \"right-sized diff.\"\nCompleteness: A=4/10, B=9/10, C=10/10\nNet: B gets guard-safety for free with the same diff size as A; C buys a cleaner future at the cost of touching shared code this plan did not intend to touch.", + "header": "Approach", + "multiSelect": false, + "options": [ + { + "label": "B) Dispatcher-registered handler in own namespace (recommended)", + "description": "\u2705 Handler class lives in its own Stripe namespace but is registered with WebhookDispatcher for payment_intent.succeeded, so every guard applies by construction. \u2705 Same diff size as A; nothing in the dispatcher changes (human: ~1 day / CC: ~20 min). \u274c Handler must conform to the dispatcher's handler interface, which the plan has not yet documented." + }, + { + "label": "A) Standalone handler bypassing WebhookDispatcher (as written)", + "description": "\u2705 Smallest possible change: one new class, one ingress hook, no dispatcher contact. \u2705 Namespace isolation is total; no coupling to dispatcher conventions. \u274c Guard coverage depends on an unverified claim that no guard lives in the dispatcher; a wrong guess is a silent security or double-processing hole (human: ~1 day / CC: ~20 min)." + }, + { + "label": "C) Extract guard chain into shared middleware, then register", + "description": "\u2705 Guards become an explicit, reusable stack that both the dispatcher and any future handler consume; the contract is visible in code. \u2705 Best 12-month trajectory for adding more Stripe event types. \u274c Touches shared ingress code and every existing handler's path in a payment-critical system; larger blast radius and rollback surface (human: ~4 days / CC: ~2 hours)." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D3 \u2014 How should StripePaymentWebhookHandler be wired into the ingress?\nProject/branch/task: main; PLAN.md \"Architecture\" says the new handler bypasses WebhookDispatcher for namespace separation.\nELI10: The plan promises the new handler \"runs inside the unchanged guards\" (signature, dedup, per-user lock, ownership, unknown-user). It also says it bypasses the existing dispatcher. Those two claims only both hold if every guard lives outside the dispatcher, and the repo has no code to confirm that. If any guard lives in the dispatcher, bypassing it silently drops that guard.\nStakes if we pick wrong: A dropped dedup or lock guard means a Stripe retry double-processes a payment or races account deletion; a dropped ownership guard lets one PaymentIntent mark a different user paid.\nRecommendation: B because it makes \"runs inside unchanged guards\" true by construction instead of by assertion, and namespace separation is achieved with a module, not a bypass. Maps to \"explicit over clever\" and \"right-sized diff.\"\nCompleteness: A=4/10, B=9/10, C=10/10\nNet: B gets guard-safety for free with the same diff size as A; C buys a cleaner future at the cost of touching shared code this plan did not intend to touch.": "B) Dispatcher-registered handler in own namespace (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T06:54:02.834Z" + }, + { + "sessionId": "1f7fabc1-f286-440b-9904-2d37428020dd", + "toolUseId": "toolu_0175vCKiJ4dTuCn72zmE1pcV", + "questions": [ + { + "question": "D4 \u2014 What is the per-user order fetch loop for?\nProject/branch/task: main; PLAN.md \"Performance\" says each webhook looks up the user, then fetches each order in a loop. Nothing else in the plan consumes orders.\nELI10: The handler's job is to mark the user paid and send an email. The plan also loads every order for that user, one query per order. An implementer hits this at hour 2-3 and has to guess whether orders feed the email body or are leftover from an earlier sketch. Guessing wrong either drops content the email needs or ships a dead N+1 loop into a payment-critical path.\nStakes if we pick wrong: Either the notification email is missing the order summary users expect, or every Stripe webhook does N extra queries for nothing and slows under load.\nRecommendation: A because the only plausible consumer is the notification email, and one batched query covers it; this keeps stated scope while removing the N+1. Maps to \"engineered enough\" and \"handle more edge cases.\"\nCompleteness: A=9/10, B=7/10, C=5/10\nNet: A keeps the orders and fixes the query shape; B removes orders entirely; C leaves the guess to the implementer.", + "header": "Orders", + "multiSelect": false, + "options": [ + { + "label": "A) Orders feed the email; fetch in one query (recommended)", + "description": "\u2705 One indexed query by user ID (with a bounded limit) replaces N per-order queries; email gets the order summary it needs. \u2705 Empty order list is an explicit email variant, not a crash (human: ~2h / CC: ~10min). \u274c Assumes the notification template wants order details; implementer must confirm the template contract at hour 1." + }, + { + "label": "B) Orders are not needed; drop the loop", + "description": "\u2705 Smallest handler: lookup, update, email. No order query at all. \u2705 Removes the N+1 and any order-related failure mode outright. \u274c If the existing email template references orders, the email breaks or ships blank sections; a hidden requirement gets cut silently." + }, + { + "label": "C) Leave it to the implementer to decide at build time", + "description": "\u2705 No decision needed now; matches the plan text as written. \u2705 Zero review time spent on it. \u274c The ambiguity the temporal check exists to catch stays in the plan, and the N+1 remains unresolved until someone notices in production." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D4 \u2014 What is the per-user order fetch loop for?\nProject/branch/task: main; PLAN.md \"Performance\" says each webhook looks up the user, then fetches each order in a loop. Nothing else in the plan consumes orders.\nELI10: The handler's job is to mark the user paid and send an email. The plan also loads every order for that user, one query per order. An implementer hits this at hour 2-3 and has to guess whether orders feed the email body or are leftover from an earlier sketch. Guessing wrong either drops content the email needs or ships a dead N+1 loop into a payment-critical path.\nStakes if we pick wrong: Either the notification email is missing the order summary users expect, or every Stripe webhook does N extra queries for nothing and slows under load.\nRecommendation: A because the only plausible consumer is the notification email, and one batched query covers it; this keeps stated scope while removing the N+1. Maps to \"engineered enough\" and \"handle more edge cases.\"\nCompleteness: A=9/10, B=7/10, C=5/10\nNet: A keeps the orders and fixes the query shape; B removes orders entirely; C leaves the guess to the implementer.": "A) Orders feed the email; fetch in one query (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T06:55:12.980Z" + }, + { + "sessionId": "1f7fabc1-f286-440b-9904-2d37428020dd", + "toolUseId": "toolu_015MMLm3wfu9P4KCoKnpPFfW", + "questions": [ + { + "question": "D5 (Finding 1.1) \u2014 How should the inline notification email behave when the mail client throws?\nProject/branch/task: main; PLAN.md \"Webhook fan-out\": update user AND fire email, both inline, no error handling on the email leg.\nELI10: Today the plan lets any mail failure (timeout, connection refused, provider 5xx) escape the handler. The ingress wrapper turns that into HTTP 500, so Stripe re-sends a payment event whose database write already committed. That is exactly what the plan's own runbook says never to do. If the email call sits inside the DB transaction, a mail outage also rolls back the paid state, and after Stripe's 3-day retry window the user stays unpaid forever.\nStakes if we pick wrong: Lost or delayed access for paying users during any mail outage, duplicate receipt emails on every Stripe retry, and a webhook-failure alert that misreports notification problems as payment problems.\nRecommendation: A because it makes the DB commit the only thing that can fail the webhook, names the exceptions instead of a catch-all, and hands notification failures to the alert and runbook the plan already retains. Maps to \"zero silent failures\" and \"every error has a name.\"\nCompleteness: A=9/10, B=10/10, C=2/10\nNet: A isolates the email with what exists today; B adds automatic re-delivery but depends on a retry mechanism the plan has not shown to be programmatic.", + "header": "Email leg", + "multiSelect": false, + "options": [ + { + "label": "A) Commit first, rescue named mail exceptions, log, return 200 (recommended)", + "description": "\u2705 Order: DB update commits, dedup marker records, then email sends with a bounded timeout; rescue ONLY the mail client's named exception classes (timeout, connection, delivery-rejected), never a bare catch-all, and never around the DB calls. \u2705 On rescue: structured warning with event ID, user ID, PI ID, exception class; the mail client's failure-rate metric and existing on-call alert fire; on-call retries via the existing notification retry procedure; handler returns success so Stripe does not replay. Verified by unit tests per named exception and an integration test with the provider stubbed down (human: ~3h / CC: ~15min). \u274c Delivery of the failed email depends on the runbook's manual retry step until someone acts on the alert." + }, + { + "label": "B) Same as A, plus auto-enqueue the failed notification for retry", + "description": "\u2705 Everything in A, and the rescue block also hands the notification to the existing notification retry procedure automatically, so no human step is needed for transient outages. \u2705 Users get their email within the retry window without on-call involvement. \u274c Assumes the retained \"notification retry procedure\" is callable from code; if it is a manual runbook only, this option needs a new retry job, which is new scope (human: ~1 day / CC: ~30min)." + }, + { + "label": "C) Keep as written: no error handling on the email leg", + "description": "\u2705 Smallest code; matches the current plan text exactly. \u2705 Every mail failure is loud (HTTP 500 and the webhook-failure alert). \u274c Stripe replays committed payments on mail failures, duplicate emails on retry, possible rollback of paid state if the email sits inside the transaction, and the alert misclassifies notification failures as payment failures." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D5 (Finding 1.1) \u2014 How should the inline notification email behave when the mail client throws?\nProject/branch/task: main; PLAN.md \"Webhook fan-out\": update user AND fire email, both inline, no error handling on the email leg.\nELI10: Today the plan lets any mail failure (timeout, connection refused, provider 5xx) escape the handler. The ingress wrapper turns that into HTTP 500, so Stripe re-sends a payment event whose database write already committed. That is exactly what the plan's own runbook says never to do. If the email call sits inside the DB transaction, a mail outage also rolls back the paid state, and after Stripe's 3-day retry window the user stays unpaid forever.\nStakes if we pick wrong: Lost or delayed access for paying users during any mail outage, duplicate receipt emails on every Stripe retry, and a webhook-failure alert that misreports notification problems as payment problems.\nRecommendation: A because it makes the DB commit the only thing that can fail the webhook, names the exceptions instead of a catch-all, and hands notification failures to the alert and runbook the plan already retains. Maps to \"zero silent failures\" and \"every error has a name.\"\nCompleteness: A=9/10, B=10/10, C=2/10\nNet: A isolates the email with what exists today; B adds automatic re-delivery but depends on a retry mechanism the plan has not shown to be programmatic.": "A) Commit first, rescue named mail exceptions, log, return 200 (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T06:56:43.363Z" + }, + { + "sessionId": "1f7fabc1-f286-440b-9904-2d37428020dd", + "toolUseId": "toolu_01AbDgVzg2Hjp7EpUgUvDFGm", + "questions": [ + { + "question": "D6 (Finding 3.1) \u2014 How should the user lookup use request.params.userId?\nProject/branch/task: main; PLAN.md \"Database access\": the handler reads request.params.userId directly into a raw SQL fragment.\nELI10: The user ID comes from Stripe metadata that the plan says is forwarded unchanged, may contain any punctuation or Unicode, and is never SQL-sanitized. Pasting it into SQL text lets whoever controls that metadata write part of your query. A valid Stripe signature only proves Stripe relayed it, not that it is safe. Binding it as a query parameter makes the database treat it as data no matter what it contains.\nStakes if we pick wrong: Arbitrary read or write against the users table from a payment-critical path, plus hard-to-reproduce failures on legitimate IDs containing quotes.\nRecommendation: A because parameter binding is the only remedy that works for opaque Unicode identifiers where format validation is off the table by contract. Maps to \"security is not optional\" and \"explicit over clever.\"\nCompleteness: A=10/10, B=6/10, C=0/10\nNet: A removes the injection class entirely; B shrinks it but keeps string-built SQL in the path.", + "header": "SQL lookup", + "multiSelect": false, + "options": [ + { + "label": "A) Parameterized query via the existing DB client (recommended)", + "description": "\u2705 Lookup, order fetch, and update all bind userId as a parameter; no string interpolation anywhere in the handler. \u2705 Tests feed hostile IDs (quote, semicolon, comment marker, Unicode, 4k-char string) and assert a plain no-row or found result with no error and no side effect; the DB trace still records user ID and event ID (human: ~1h / CC: ~10min). \u274c Slightly more verbose than a one-line raw fragment." + }, + { + "label": "B) Keep raw SQL, escape/quote the string with the client's quoting helper", + "description": "\u2705 Small textual change to the fragment as written. \u2705 Blocks the common quote-based payloads. \u274c Still builds SQL from an external string; escaping bugs, encoding tricks, and helper misuse remain an injection surface, and the plan has no format constraint to lean on." + }, + { + "label": "C) Keep as written: raw fragment, no change", + "description": "\u2705 Zero additional work. \u2705 Matches the plan text exactly. \u274c Direct SQL injection from Stripe metadata into a write-capable handler; contradicts the plan's own statement that the string is not SQL-safe." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D6 (Finding 3.1) \u2014 How should the user lookup use request.params.userId?\nProject/branch/task: main; PLAN.md \"Database access\": the handler reads request.params.userId directly into a raw SQL fragment.\nELI10: The user ID comes from Stripe metadata that the plan says is forwarded unchanged, may contain any punctuation or Unicode, and is never SQL-sanitized. Pasting it into SQL text lets whoever controls that metadata write part of your query. A valid Stripe signature only proves Stripe relayed it, not that it is safe. Binding it as a query parameter makes the database treat it as data no matter what it contains.\nStakes if we pick wrong: Arbitrary read or write against the users table from a payment-critical path, plus hard-to-reproduce failures on legitimate IDs containing quotes.\nRecommendation: A because parameter binding is the only remedy that works for opaque Unicode identifiers where format validation is off the table by contract. Maps to \"security is not optional\" and \"explicit over clever.\"\nCompleteness: A=10/10, B=6/10, C=0/10\nNet: A removes the injection class entirely; B shrinks it but keeps string-built SQL in the path.": "A) Parameterized query via the existing DB client (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T06:58:10.258Z" + }, + { + "sessionId": "1f7fabc1-f286-440b-9904-2d37428020dd", + "toolUseId": "toolu_01QRHpiwdKU8tqFupvRAwpUq", + "questions": [ + { + "question": "D7 (Finding 4.1) \u2014 Accept a possible duplicate email when the process crashes between DB commit and the dedup marker write?\nProject/branch/task: main; retained contract: the event-ID guard records completion only after the database transaction commits, and this plan does not replace that guard.\nELI10: The paid state commits first, then the guard writes \"event done.\" If the process dies in that tiny gap, Stripe retries, the update re-applies the same values (harmless), and the email goes out a second time. Money and access are correct either way; the user might get two receipts. Fixing it means changing the retained dedup guard or the mail client, which this plan says it leaves alone.\nStakes if we pick wrong: Accepting means a rare duplicate email visible in traces. Fixing means touching a shared, unchanged-by-contract component in a payment path.\nRecommendation: A because payment correctness is already guaranteed by idempotent assignment, the window is a process crash in a sub-second gap, the duplicate is visible in the mail trace by event ID, and no stated requirement promises exactly-once email. Maps to \"right-sized diff\" and \"deployments are not atomic.\"\nNote: options differ in kind, not coverage \u2014 no completeness score.\nNet: accept a rare visible duplicate vs expand into a retained guard for exactly-once email.", + "header": "Dup email", + "multiSelect": false, + "options": [ + { + "label": "A) Accept as residual risk; document it in the plan (recommended)", + "description": "\u2705 No change to retained components; the plan records the exact schedule, why payment state is safe, and how to spot it (two mail sends for one event ID). \u2705 Zero extra code in a payment-critical path. \u274c A user can receive two notification emails after a crash in a sub-second window." + }, + { + "label": "B) Record the dedup marker inside the update transaction", + "description": "\u2705 Marker and paid state commit atomically, so a retry after crash never re-runs the handler; exactly-once handler execution. \u2705 Removes the duplicate email path completely. \u274c Modifies the retained event guard the plan says it does not replace; affects every handler behind it; needs its own review and rollout (human: ~1 day / CC: ~30min)." + }, + { + "label": "C) Key the email send on the event ID so the mail client dedupes", + "description": "\u2705 Leaves the guard alone; the send call carries the Stripe event ID as an idempotency key. \u2705 Also protects against runbook re-sends. \u274c Only works if the shared mail client or provider supports idempotency keys, which the plan does not state; otherwise this is new mail-client scope." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D7 (Finding 4.1) \u2014 Accept a possible duplicate email when the process crashes between DB commit and the dedup marker write?\nProject/branch/task: main; retained contract: the event-ID guard records completion only after the database transaction commits, and this plan does not replace that guard.\nELI10: The paid state commits first, then the guard writes \"event done.\" If the process dies in that tiny gap, Stripe retries, the update re-applies the same values (harmless), and the email goes out a second time. Money and access are correct either way; the user might get two receipts. Fixing it means changing the retained dedup guard or the mail client, which this plan says it leaves alone.\nStakes if we pick wrong: Accepting means a rare duplicate email visible in traces. Fixing means touching a shared, unchanged-by-contract component in a payment path.\nRecommendation: A because payment correctness is already guaranteed by idempotent assignment, the window is a process crash in a sub-second gap, the duplicate is visible in the mail trace by event ID, and no stated requirement promises exactly-once email. Maps to \"right-sized diff\" and \"deployments are not atomic.\"\nNote: options differ in kind, not coverage \u2014 no completeness score.\nNet: accept a rare visible duplicate vs expand into a retained guard for exactly-once email.": "A) Accept as residual risk; document it in the plan (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T06:59:36.640Z" + }, + { + "sessionId": "1f7fabc1-f286-440b-9904-2d37428020dd", + "toolUseId": "toolu_01PAGHq4vUvCPUCf5XRFgXME", + "questions": [ + { + "question": "D8 (Finding 5.1) \u2014 Rename the handler class to name the event it handles?\nProject/branch/task: main; PLAN.md \"Architecture\" names the class StripePaymentWebhookHandler; the retained ingress forwards only payment_intent.succeeded to it.\nELI10: The class name promises \"all Stripe payment webhooks,\" but the code handles exactly one event. When someone adds a refund or dispute handler next quarter, a name tied to the event makes the dispatcher registration table read like a map of what happens on each event. This is taste, not correctness; nothing breaks either way.\nStakes if we pick wrong: Mild confusion for the next engineer, or a rename churn nobody asked for.\nRecommendation: A \u2014 this is a taste call, no strong preference either way; A is the default because it matches the D3 namespace decision and the one-event contract.\nNote: options differ in kind, not coverage \u2014 no completeness score.\nNet: event-named class under the Stripe namespace vs the broader name as written.", + "header": "Class name", + "multiSelect": false, + "options": [ + { + "label": "A) Stripe::PaymentIntentSucceededHandler (recommended)", + "description": "\u2705 Name states the exact event type the dispatcher routes to it, mirroring Stripe's own event name. \u2705 Pairs naturally with the D3 namespace and with future sibling handlers (human: ~10min / CC: ~1min). \u274c Longer; departs from the name used throughout the current plan text." + }, + { + "label": "B) Keep StripePaymentWebhookHandler as written", + "description": "\u2705 Matches the plan and any discussion already had about it; zero churn. \u2705 Leaves room to route more payment events to the same class later. \u274c Name overstates scope today; a reader assumes it handles failures and refunds too." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D8 (Finding 5.1) \u2014 Rename the handler class to name the event it handles?\nProject/branch/task: main; PLAN.md \"Architecture\" names the class StripePaymentWebhookHandler; the retained ingress forwards only payment_intent.succeeded to it.\nELI10: The class name promises \"all Stripe payment webhooks,\" but the code handles exactly one event. When someone adds a refund or dispute handler next quarter, a name tied to the event makes the dispatcher registration table read like a map of what happens on each event. This is taste, not correctness; nothing breaks either way.\nStakes if we pick wrong: Mild confusion for the next engineer, or a rename churn nobody asked for.\nRecommendation: A \u2014 this is a taste call, no strong preference either way; A is the default because it matches the D3 namespace decision and the one-event contract.\nNote: options differ in kind, not coverage \u2014 no completeness score.\nNet: event-named class under the Stripe namespace vs the broader name as written.": "A) Stripe::PaymentIntentSucceededHandler (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T07:01:05.117Z" + }, + { + "sessionId": "1f7fabc1-f286-440b-9904-2d37428020dd", + "toolUseId": "toolu_01WPGAsZjxjQARcj7foP3NAA", + "questions": [ + { + "question": "D9 (Finding 6.1) \u2014 Replace \"Tests: None planned\" with the automated test set above?\nProject/branch/task: main; PLAN.md \"Tests\": none planned, rely on the existing integration suite. The retained contracts confirm the rollout checklist is manual verification, not regression coverage.\nELI10: A new class behind a feature flag is invisible to an existing test suite; nothing in it knows the class exists. The staging replay in the rollout checklist is one human running one happy-path event once. Every failure path this review mapped (mail down, DB down, hostile ID, template bug, deletion race, flag off) would ship untested. With AI-assisted coding the full set costs minutes, not the day it used to.\nStakes if we pick wrong: A regression in a payment handler is found by a paying customer or by on-call, and the runbook is exercised for real instead of in CI.\nRecommendation: A because well-tested code is non-negotiable in your stated preferences, the tests are already specified with exact assertions, and the marginal cost over B is minutes. Maps to \"I'd rather have too many tests than too few.\"\nCompleteness: A=10/10, B=6/10, C=1/10\nNet: the full table vs only the two already-approved slices vs manual staging replay only.", + "header": "Tests", + "multiSelect": false, + "options": [ + { + "label": "A) Full automated set: unit + integration + signed-fixture replay (recommended)", + "description": "\u2705 Every row in the Section 6 table lands: registration, happy path, empty orders, per-class mail rescue, hostile IDs, DB failure before commit, template error, deletion race, flag on/off, plus one signed payment_intent.succeeded fixture replayed through ingress with the mail provider stubbed down (human: ~1 day / CC: ~20min). \u2705 The 2am-Friday test exists in CI, so the runbook's promise is proven on every commit. \u274c Needs a captured signed fixture and a mail-provider stub in the test harness if none exist yet." + }, + { + "label": "B) Only the D5 and D6 tests already approved", + "description": "\u2705 Covers the two highest-severity fixes (mail rescue per class, bound params with hostile IDs). \u2705 Smallest test diff that still guards the review's critical gaps (human: ~3h / CC: ~10min). \u274c Registration, happy path, empty orders, DB-failure sequencing, deletion race, and flag behavior ship on faith; a wiring mistake under D3 would not be caught." + }, + { + "label": "C) Keep as written: no automated tests, manual staging replay only", + "description": "\u2705 Zero test code; matches the plan text. \u2705 The rollout checklist still requires one staging replay before broad enable. \u274c Contradicts the D5 and D6 approvals, which included tests, and leaves every failure path unverified by anything repeatable." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D9 (Finding 6.1) \u2014 Replace \"Tests: None planned\" with the automated test set above?\nProject/branch/task: main; PLAN.md \"Tests\": none planned, rely on the existing integration suite. The retained contracts confirm the rollout checklist is manual verification, not regression coverage.\nELI10: A new class behind a feature flag is invisible to an existing test suite; nothing in it knows the class exists. The staging replay in the rollout checklist is one human running one happy-path event once. Every failure path this review mapped (mail down, DB down, hostile ID, template bug, deletion race, flag off) would ship untested. With AI-assisted coding the full set costs minutes, not the day it used to.\nStakes if we pick wrong: A regression in a payment handler is found by a paying customer or by on-call, and the runbook is exercised for real instead of in CI.\nRecommendation: A because well-tested code is non-negotiable in your stated preferences, the tests are already specified with exact assertions, and the marginal cost over B is minutes. Maps to \"I'd rather have too many tests than too few.\"\nCompleteness: A=10/10, B=6/10, C=1/10\nNet: the full table vs only the two already-approved slices vs manual staging replay only.": "A) Full automated set: unit + integration + signed-fixture replay (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T07:02:03.239Z" + }, + { + "sessionId": "1f7fabc1-f286-440b-9904-2d37428020dd", + "toolUseId": "toolu_01VezYJ6Ak2AHDm6HxZjWYJn", + "questions": [ + { + "question": "D10 (Finding 8.1) \u2014 Alert when a succeeded payment hits the unknown-user path?\nProject/branch/task: main; retained contract: the lookup-result guard acks unknown/deleted users with HTTP 200 and logs the event, then stops.\nELI10: If Stripe says \"paid\" but the user ID in the metadata matches nobody (typo in checkout code, user deleted a moment earlier), the system says \"ok, done\" to Stripe and writes one log line. Stripe never retries, the customer paid, and nobody is told. Today the only way to notice is to read logs. A log-based alert on that existing line, scoped to payment_intent.succeeded, makes it a page instead of an archaeology exercise.\nStakes if we pick wrong: Paid customers stranded until they complain; or an alert on a component the plan promised not to touch.\nRecommendation: A because it needs no code change to the retained guard (alerting config on an existing event-correlated log), it closes the only remaining silent money-loss path this review found, and observability is scope, not afterthought. Maps to \"zero silent failures.\"\nCompleteness: A=9/10, B=10/10, C=3/10\nNet: alert on the existing log now vs a metric in the guard vs leaving it as a log line.", + "header": "Unknown user", + "multiSelect": false, + "options": [ + { + "label": "A) Log-based alert on the existing unknown-user line, plus runbook entry (recommended)", + "description": "\u2705 Alert fires on the retained guard's existing event-correlated warning filtered to payment_intent.succeeded; no code change to the guard. \u2705 Runbook entry: find the PaymentIntent in Stripe, resolve the intended user, apply the paid update through the documented manual path; verified by firing one synthetic unknown-user event in staging and seeing the alert (human: ~1h / CC: ~10min). \u274c Depends on the log pipeline supporting field-filtered alerts; if it does not, this becomes option B." + }, + { + "label": "B) Emit a counter metric from the guard and alert on it", + "description": "\u2705 Metric is more reliable than log parsing and gives a dashboard panel for free. \u2705 Same runbook entry as A. \u274c Modifies the retained lookup-result guard the plan says it leaves unchanged; needs its own small review and affects every handler behind it (human: ~2h / CC: ~15min)." + }, + { + "label": "C) Leave as a log line only (as today)", + "description": "\u2705 Zero change; matches the retained contract exactly. \u2705 The event is still discoverable in logs by event ID. \u274c A paying customer with a bad user_id is stranded until they complain; the review's last silent failure path stays open." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D10 (Finding 8.1) \u2014 Alert when a succeeded payment hits the unknown-user path?\nProject/branch/task: main; retained contract: the lookup-result guard acks unknown/deleted users with HTTP 200 and logs the event, then stops.\nELI10: If Stripe says \"paid\" but the user ID in the metadata matches nobody (typo in checkout code, user deleted a moment earlier), the system says \"ok, done\" to Stripe and writes one log line. Stripe never retries, the customer paid, and nobody is told. Today the only way to notice is to read logs. A log-based alert on that existing line, scoped to payment_intent.succeeded, makes it a page instead of an archaeology exercise.\nStakes if we pick wrong: Paid customers stranded until they complain; or an alert on a component the plan promised not to touch.\nRecommendation: A because it needs no code change to the retained guard (alerting config on an existing event-correlated log), it closes the only remaining silent money-loss path this review found, and observability is scope, not afterthought. Maps to \"zero silent failures.\"\nCompleteness: A=9/10, B=10/10, C=3/10\nNet: alert on the existing log now vs a metric in the guard vs leaving it as a log line.": "A) Log-based alert on the existing unknown-user line, plus runbook entry (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T07:02:57.328Z" + } + ] +} diff --git a/test/fixtures/ceo-numbered-brief-ak.json b/test/fixtures/ceo-numbered-brief-ak.json new file mode 100644 index 000000000..5067371f6 --- /dev/null +++ b/test/fixtures/ceo-numbered-brief-ak.json @@ -0,0 +1,258 @@ +{ + "provenance": { + "sourceCommit": "04c62ac678bb7bc1a22090f72f7ed51c451c22b9", + "observationSha256": "4b8ac826e92e664d953516cf5375b8781c826b42140d51414007ceeaf30e0879", + "sessionId": "2eaeb211-e535-46d3-9a51-4f41bc42061d", + "scope": "Eight exact completed public native calls; original first attempt failed no_review_questions. Retrospective predicate replay grants no paid pass." + }, + "calls": [ + { + "sessionId": "2eaeb211-e535-46d3-9a51-4f41bc42061d", + "toolUseId": "toolu_01GUcwuzeQwkb9B1RxuUtrVw", + "questions": [ + { + "question": "D0 — Add gstack skill routing rules to this project's CLAUDE.md?\nProject/branch/task: gstack-plan-count-GwcFZk on main, one-time gstack onboarding before the CEO plan review.\nELI10: gstack works best when your project's CLAUDE.md includes skill routing rules. These tell the coding agent which gstack skill to reach for when you say things like \"review this plan\" or \"ship it\", so you don't have to remember slash-command names. It is a short appended section, nothing else in CLAUDE.md changes.\nStakes if we pick wrong: Without rules you invoke skills by hand; with rules the agent may auto-route requests you wanted handled generically. Either way is reversible in one config command.\nRecommendation: A because routing rules make every later request cheaper to phrase and this prompt only fires once per project.\nNote: options differ in kind, not coverage — no completeness score.\nNet: convenience of automatic skill routing versus keeping full manual control over when skills run.", + "header": "Routing", + "multiSelect": false, + "options": [ + { + "label": "Add routing rules (recommended)", + "description": "✅ Future requests like \"review this plan\" route straight to the right gstack skill without naming it. ✅ One appended section, committed once; nothing else in CLAUDE.md is touched. ❌ Plan mode blocks edits right now, so the append and commit happen after this review exits plan mode." + }, + { + "label": "No thanks, manual only", + "description": "✅ CLAUDE.md stays exactly as the fixture defines it, no extra commit on main. ✅ You keep explicit control over when any gstack skill runs. ❌ Every skill must be invoked by its slash name; re-enable later via gstack-config set routing_declined false." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D0 — Add gstack skill routing rules to this project's CLAUDE.md?\nProject/branch/task: gstack-plan-count-GwcFZk on main, one-time gstack onboarding before the CEO plan review.\nELI10: gstack works best when your project's CLAUDE.md includes skill routing rules. These tell the coding agent which gstack skill to reach for when you say things like \"review this plan\" or \"ship it\", so you don't have to remember slash-command names. It is a short appended section, nothing else in CLAUDE.md changes.\nStakes if we pick wrong: Without rules you invoke skills by hand; with rules the agent may auto-route requests you wanted handled generically. Either way is reversible in one config command.\nRecommendation: A because routing rules make every later request cheaper to phrase and this prompt only fires once per project.\nNote: options differ in kind, not coverage — no completeness score.\nNet: convenience of automatic skill routing versus keeping full manual control over when skills run.": "Add routing rules (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T06:39:16.345Z" + }, + { + "sessionId": "2eaeb211-e535-46d3-9a51-4f41bc42061d", + "toolUseId": "toolu_01L43dc57TZiGqemPynEw1tx", + "questions": [ + { + "question": "D1 — Let gstack search learnings from your other projects on this machine?\nProject/branch/task: gstack-plan-count-GwcFZk on main, first-run gstack setting before the Stripe webhook plan review.\nELI10: gstack keeps a local notebook of pitfalls and patterns it learned in each project. This switch lets a review in one project read the notebooks of your other projects on this machine. Nothing leaves your machine either way. Recommended for solo developers; skip if you work on multiple client codebases where cross-contamination would be a concern.\nStakes if we pick wrong: Enabled on shared client machines could surface one client's project quirks while reviewing another's. Disabled on a solo machine just means slower compounding of lessons.\nRecommendation: A because this is a local-only read and the review gets sharper when prior payment-webhook lessons can surface.\nNote: options differ in kind, not coverage — no completeness score.\nNet: richer recall across projects versus strict per-project isolation of learned lessons.", + "header": "Learnings", + "multiSelect": false, + "options": [ + { + "label": "Enable cross-project (recommended)", + "description": "✅ Reviews can apply pitfalls already learned in your other local projects, shown as \"Prior learning applied\". ✅ Stays on this machine; no network, no upload, reversible with one config command. ❌ On a machine shared across client codebases, one client's quirks could surface in another's review." + }, + { + "label": "Project-scoped only", + "description": "✅ Strict isolation: this project's review only ever sees this project's learnings. ✅ Safe default when you consult for several unrelated codebases. ❌ Lessons learned elsewhere about Stripe webhooks or raw SQL will not surface here." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D1 — Let gstack search learnings from your other projects on this machine?\nProject/branch/task: gstack-plan-count-GwcFZk on main, first-run gstack setting before the Stripe webhook plan review.\nELI10: gstack keeps a local notebook of pitfalls and patterns it learned in each project. This switch lets a review in one project read the notebooks of your other projects on this machine. Nothing leaves your machine either way. Recommended for solo developers; skip if you work on multiple client codebases where cross-contamination would be a concern.\nStakes if we pick wrong: Enabled on shared client machines could surface one client's project quirks while reviewing another's. Disabled on a solo machine just means slower compounding of lessons.\nRecommendation: A because this is a local-only read and the review gets sharper when prior payment-webhook lessons can surface.\nNote: options differ in kind, not coverage — no completeness score.\nNet: richer recall across projects versus strict per-project isolation of learned lessons.": "Enable cross-project (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T06:41:59.020Z" + }, + { + "sessionId": "2eaeb211-e535-46d3-9a51-4f41bc42061d", + "toolUseId": "toolu_0145djhbUuMjGdGGyMbBtz7T", + "questions": [ + { + "question": "D2 — Which implementation approach should the Stripe payment handler take?\nProject/branch/task: gstack-plan-count-GwcFZk on main, reviewing PLAN.md (Payment Processing Integration) in HOLD SCOPE.\nELI10: The plan adds one class that reacts to a successful Stripe payment. The big structural choice is whether that class plugs into the existing WebhookDispatcher (which already applies dedup, the per-user lock and the ownership guard) or sits beside it as a separate namespace, as the plan currently says. Plugging in reuses guards you already trust; standing alone means re-implementing or silently losing them.\nStakes if we pick wrong: A handler outside the dispatcher can process the same payment twice or race account deletion, and the plan's own contract section would be false.\nRecommendation: A because it is the smallest diff that keeps every retained contract true and adds no new abstraction (right-sized diff, engineered enough).\nCompleteness: A=9/10, B=9/10, C=3/10\nNet: A and B both satisfy every contract; B buys a reusable service layer nobody calls yet; C keeps the namespace split at the cost of the guards. ", + "header": "Approach", + "multiSelect": false, + "options": [ + { + "label": "A: Register via WebhookDispatcher (recommended)", + "description": "Completeness 9/10. Human ~1.5 days / CC ~30 min. ✅ Every retained guard (dedup, lock, ownership, unknown-user) wraps the handler for free. ✅ Smallest diff: one handler class in its own module plus a dispatcher registration line. ❌ Handler body holds lookup, update and email in one method; email retry stays manual via the runbook." + }, + { + "label": "B: Thin handler + PaymentSucceededService", + "description": "Completeness 9/10. Human ~3 days / CC ~1 hour. ✅ Business logic unit-testable without webhook plumbing and callable by replay or retry tooling. ✅ Same guard reuse as A. ❌ Second class for a single call site today; premature abstraction until retry tooling needs it." + }, + { + "label": "C: Standalone handler, bypass dispatcher (as written)", + "description": "Completeness 3/10. Human ~1 day / CC ~20 min plus re-implementing guards. ✅ Clean namespace separation with no edit to WebhookDispatcher. ✅ No coupling to dispatcher release cadence. ❌ Contradicts the contract that the handler runs inside dedup and lock; duplicate processing and deletion races become possible." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D2 — Which implementation approach should the Stripe payment handler take?\nProject/branch/task: gstack-plan-count-GwcFZk on main, reviewing PLAN.md (Payment Processing Integration) in HOLD SCOPE.\nELI10: The plan adds one class that reacts to a successful Stripe payment. The big structural choice is whether that class plugs into the existing WebhookDispatcher (which already applies dedup, the per-user lock and the ownership guard) or sits beside it as a separate namespace, as the plan currently says. Plugging in reuses guards you already trust; standing alone means re-implementing or silently losing them.\nStakes if we pick wrong: A handler outside the dispatcher can process the same payment twice or race account deletion, and the plan's own contract section would be false.\nRecommendation: A because it is the smallest diff that keeps every retained contract true and adds no new abstraction (right-sized diff, engineered enough).\nCompleteness: A=9/10, B=9/10, C=3/10\nNet: A and B both satisfy every contract; B buys a reusable service layer nobody calls yet; C keeps the namespace split at the cost of the guards. ": "A: Register via WebhookDispatcher (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T06:43:37.030Z" + }, + { + "sessionId": "2eaeb211-e535-46d3-9a51-4f41bc42061d", + "toolUseId": "toolu_01KJhHqBzWWUCcQpL2NUpuvW", + "questions": [ + { + "question": "D3 — What does the per-order fetch in the webhook handler feed?\nProject/branch/task: gstack-plan-count-GwcFZk on main, Step 0E temporal interrogation of PLAN.md before the section review.\nELI10: The Performance section says each webhook looks up the user and then fetches each order in a loop, but no section says why the orders are needed. The right fix for the loop depends on the answer: an email receipt needs a read of the user's orders, marking the paid order needs a lookup by PaymentIntent ID, and if nothing consumes the orders the loop should not exist. The implementer will hit this in hour one.\nStakes if we pick wrong: Either we keep an N+1 query that serves nothing, or we drop data the confirmation email or order state actually depends on.\nRecommendation: A, because the plan pairs the order fetch with the notification email and a receipt is the usual consumer; correct me if the orders serve another purpose.\nNote: options differ in kind, not coverage — no completeness score.\nNet: this is a fact about your domain; the answer sets the shape of the Section 7 remedy.", + "header": "Orders loop", + "multiSelect": false, + "options": [ + { + "label": "Email receipt content (recommended)", + "description": "✅ Orders are read to build the confirmation email; a single query by user ID replaces the loop. ✅ No write to orders, so the per-user lock and update contract stay as stated. ❌ If the receipt lists many orders the email builder needs a size cap." + }, + { + "label": "Mark the order(s) paid by PaymentIntent", + "description": "✅ The handler must find the order bound to this PaymentIntent and update its state alongside the user. ✅ Lookup is by intent ID, a single indexed read, no loop. ❌ Adds an order write the plan's contract section does not describe; must stay inside the same transaction." + }, + { + "label": "Nothing consumes the orders", + "description": "✅ The loop is dead weight and is removed; the handler is lookup, update, email. ✅ Fewest queries per webhook. ❌ If the prior handler used order data somewhere, the staging replay must prove nothing regressed." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D3 — What does the per-order fetch in the webhook handler feed?\nProject/branch/task: gstack-plan-count-GwcFZk on main, Step 0E temporal interrogation of PLAN.md before the section review.\nELI10: The Performance section says each webhook looks up the user and then fetches each order in a loop, but no section says why the orders are needed. The right fix for the loop depends on the answer: an email receipt needs a read of the user's orders, marking the paid order needs a lookup by PaymentIntent ID, and if nothing consumes the orders the loop should not exist. The implementer will hit this in hour one.\nStakes if we pick wrong: Either we keep an N+1 query that serves nothing, or we drop data the confirmation email or order state actually depends on.\nRecommendation: A, because the plan pairs the order fetch with the notification email and a receipt is the usual consumer; correct me if the orders serve another purpose.\nNote: options differ in kind, not coverage — no completeness score.\nNet: this is a fact about your domain; the answer sets the shape of the Section 7 remedy.": "Email receipt content (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T06:44:09.202Z" + }, + { + "sessionId": "2eaeb211-e535-46d3-9a51-4f41bc42061d", + "toolUseId": "toolu_01M5EXqqZJsFpmRFArmGgTWP", + "questions": [ + { + "question": "D4 — Issue 2: the inline email leg has no error handling and no stated ordering relative to the DB commit.\nProject/branch/task: gstack-plan-count-GwcFZk on main, Section 2 Error & Rescue Map for PLAN.md.\nELI10: After a payment succeeds the handler writes the user record and sends an email in the same call, and the plan says the email has no error handling. The mail client rethrows every exception unchanged. So if the mail provider errors or hangs, the exception escapes the handler, the ingress returns HTTP 500 to Stripe even though the payment is already committed, the 'failed webhook processing' alert fires for a payment that actually succeeded, and Stripe retries a delivery the dedup guard will just drop. Worse, if the email call lands inside the DB transaction, a mail outage rolls back payment_status=paid for every paying user until the provider recovers. Stripe also gives up after 10 seconds, so an unbounded mail timeout guarantees retries.\nStakes if we pick wrong: paid users stuck unpaid during a mail outage, false payment-failure alerts, and duplicate receipts on retry.\nRecommendation: 2A because it commits the money path first, names the mail exceptions instead of a catch-all, and keeps failures visible through the mail client's existing failure-rate alert and runbook (zero silent failures, every error has a name).\nCompleteness: 2A=10/10, 2B=6/10, 2C=2/10\nNet: 2A decouples notification failure from payment state with named rescues; 2B keeps the coupling but stops the rollback; 2C ships the defect as written. ", + "header": "Email leg", + "multiSelect": false, + "options": [ + { + "label": "2A: Commit first, rescue named mail errors, 200 (recommended)", + "description": "Human ~3h / CC ~15min. ✅ Order fixed: lookup, update, commit, then email outside the transaction; a mail failure can never roll back payment_status=paid. ✅ Rescue only the mail client's concrete exception classes (delivery error, provider 4xx/5xx, timeout with a bound that keeps the handler under Stripe's 10s window); log event ID, user ID, exception class; return normally so completion is recorded and Stripe gets 200; pass the Stripe event ID as the mail idempotency key if the client supports it. ✅ Verified by unit tests for each rescued class plus one asserting a DB error still propagates as 500. ❌ Email retry remains manual through the existing runbook, driven by the mail failure-rate alert." + }, + { + "label": "2B: Move email after commit, let it raise", + "description": "Human ~1h / CC ~5min. ✅ Removes the transaction rollback risk with a one-line reorder. ✅ No new rescue code to test. ❌ Ingress still returns 500 on a committed payment, fires the failed-webhook alert falsely, and Stripe retries into the dedup guard; the runbook must untangle it every time." + }, + { + "label": "2C: Keep as written (no handling, unspecified order)", + "description": "Human 0 / CC 0. ✅ Nothing to build. ✅ Matches the current draft. ❌ A mail outage can block payment_status=paid entirely if the send sits inside the transaction, and every mail error surfaces as a payment-processing failure." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D4 — Issue 2: the inline email leg has no error handling and no stated ordering relative to the DB commit.\nProject/branch/task: gstack-plan-count-GwcFZk on main, Section 2 Error & Rescue Map for PLAN.md.\nELI10: After a payment succeeds the handler writes the user record and sends an email in the same call, and the plan says the email has no error handling. The mail client rethrows every exception unchanged. So if the mail provider errors or hangs, the exception escapes the handler, the ingress returns HTTP 500 to Stripe even though the payment is already committed, the 'failed webhook processing' alert fires for a payment that actually succeeded, and Stripe retries a delivery the dedup guard will just drop. Worse, if the email call lands inside the DB transaction, a mail outage rolls back payment_status=paid for every paying user until the provider recovers. Stripe also gives up after 10 seconds, so an unbounded mail timeout guarantees retries.\nStakes if we pick wrong: paid users stuck unpaid during a mail outage, false payment-failure alerts, and duplicate receipts on retry.\nRecommendation: 2A because it commits the money path first, names the mail exceptions instead of a catch-all, and keeps failures visible through the mail client's existing failure-rate alert and runbook (zero silent failures, every error has a name).\nCompleteness: 2A=10/10, 2B=6/10, 2C=2/10\nNet: 2A decouples notification failure from payment state with named rescues; 2B keeps the coupling but stops the rollback; 2C ships the defect as written. ": "2A: Commit first, rescue named mail errors, 200 (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T06:45:03.521Z" + }, + { + "sessionId": "2eaeb211-e535-46d3-9a51-4f41bc42061d", + "toolUseId": "toolu_016xSwTF1bwcQwLq6Y7U7BDd", + "questions": [ + { + "question": "D5 — Issue 3: the lookup interpolates request.params.userId into a raw SQL fragment.\nProject/branch/task: gstack-plan-count-GwcFZk on main, Section 3 Security & Threat Model for PLAN.md.\nELI10: The Database access section reads the Stripe metadata user_id straight into a SQL string. The plan's own contracts say this string arrives unchanged, unescaped, and can hold any punctuation or Unicode, and that a valid Stripe signature does not make it SQL-safe. Two things go wrong. First, a legitimate user ID containing a quote or semicolon breaks the query, the DB raises, the ingress returns 500, and Stripe retries that user's payment for three days while the alert stays lit. Second, anyone who influences metadata.user_id on an intent bound to their own account gets a SQL injection primitive against the users table. Likelihood: High (opaque TEXT IDs make quotes routine). Impact: High (data exposure or corruption, payment stuck).\nStakes if we pick wrong: a payments handler with an injection vector and a guaranteed outage for any user whose ID contains a quote.\nRecommendation: 3A because a bound parameter removes the injection class entirely and honors the contract that every nonempty string is a valid ID with no format restriction (security is not optional; explicit over clever).\nCompleteness: 3A=10/10, 3B=5/10, 3C=1/10\nNet: 3A fixes the root cause in the one query; 3B patches symptoms with escaping that still leaks on encoding edge cases; 3C ships the vulnerability. ", + "header": "Raw SQL", + "multiSelect": false, + "options": [ + { + "label": "3A: Bound parameter, no format validation (recommended)", + "description": "Human ~2h / CC ~10min. ✅ Lookup uses the DB client's parameter binding (`WHERE id = ?` or ORM finder); the user ID never touches SQL text, so quotes, semicolons and Unicode are just data. ✅ No allowlist or cast, matching the contract that every nonempty string is a valid identifier. ✅ Verified by unit tests that look up IDs containing `'`, `;--`, and multibyte Unicode and find the right row, plus one asserting a nonexistent ID hits the existing unknown-user 200 path. ❌ Requires the implementer to confirm the DB client exposes a binding API for this query shape." + }, + { + "label": "3B: Escape the string before interpolation", + "description": "Human ~1h / CC ~5min. ✅ Stops the naive quote-breaking case with one escape call. ✅ Keeps the raw SQL fragment the plan already describes. ❌ Escaping is driver and encoding specific; multibyte and charset edge cases historically bypass it, and the fragment stays a review magnet forever." + }, + { + "label": "3C: Keep raw interpolation (as written)", + "description": "Human 0 / CC 0. ✅ No change to the draft. ✅ Fastest to type. ❌ Injection vector in a payment path plus a hard outage for every user whose opaque ID contains a quote." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D5 — Issue 3: the lookup interpolates request.params.userId into a raw SQL fragment.\nProject/branch/task: gstack-plan-count-GwcFZk on main, Section 3 Security & Threat Model for PLAN.md.\nELI10: The Database access section reads the Stripe metadata user_id straight into a SQL string. The plan's own contracts say this string arrives unchanged, unescaped, and can hold any punctuation or Unicode, and that a valid Stripe signature does not make it SQL-safe. Two things go wrong. First, a legitimate user ID containing a quote or semicolon breaks the query, the DB raises, the ingress returns 500, and Stripe retries that user's payment for three days while the alert stays lit. Second, anyone who influences metadata.user_id on an intent bound to their own account gets a SQL injection primitive against the users table. Likelihood: High (opaque TEXT IDs make quotes routine). Impact: High (data exposure or corruption, payment stuck).\nStakes if we pick wrong: a payments handler with an injection vector and a guaranteed outage for any user whose ID contains a quote.\nRecommendation: 3A because a bound parameter removes the injection class entirely and honors the contract that every nonempty string is a valid ID with no format restriction (security is not optional; explicit over clever).\nCompleteness: 3A=10/10, 3B=5/10, 3C=1/10\nNet: 3A fixes the root cause in the one query; 3B patches symptoms with escaping that still leaks on encoding edge cases; 3C ships the vulnerability. ": "3A: Bound parameter, no format validation (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T06:45:26.149Z" + }, + { + "sessionId": "2eaeb211-e535-46d3-9a51-4f41bc42061d", + "toolUseId": "toolu_01KN76Yk1j8owHKo7cJWv2AM", + "questions": [ + { + "question": "D6 — Issue 4: no automated tests are planned for a new payment handler.\nProject/branch/task: gstack-plan-count-GwcFZk on main, Section 6 Test Review for PLAN.md.\nELI10: The Tests section says none are planned and the existing integration suite will catch regressions. But the plan's own contract section admits the staging replay is manual deployment verification, not automated regression coverage, and the existing suite cannot exercise a class that does not exist yet. Every remedy approved so far (dispatcher registration, commit-then-notify with named rescues, bound parameters, the batched order read) has a behavior that is only proven by a test. Without them the first proof that a quote-containing user ID works is a production Stripe retry storm.\nStakes if we pick wrong: regressions in the money path are found by customers and on-call instead of CI.\nRecommendation: 4A because well-tested code is non-negotiable and each approved remedy already defines its assertion; writing them costs minutes with CC.\nCompleteness: 4A=10/10, 4B=6/10, 4C=1/10\nNet: 4A proves every approved behavior in CI; 4B proves the happy path only; 4C keeps the manual checklist as the sole safety net. ", + "header": "Tests", + "multiSelect": false, + "options": [ + { + "label": "4A: Full unit + integration suite (recommended)", + "description": "Human ~1 day / CC ~20min. ✅ Unit: happy path sets payment_status=paid and intent ID and sends one email; user IDs with `'`, `;--`, Unicode resolve; unknown user takes the 200 guard path with no update or email; each named mail exception is rescued, logged with event and user ID, and the handler returns normally; DB exception propagates; orders load in exactly one query (query-count assertion); email idempotency key equals the event ID. ✅ Integration: signed fixture event through the ingress proves the handler is reached only via WebhookDispatcher under the flag; a duplicate event ID is dropped with no second update or email; a concurrent duplicate blocked on the per-user lock rechecks the marker and skips. ❌ Roughly 12 to 15 test cases to write and keep green." + }, + { + "label": "4B: Happy-path unit tests only", + "description": "Human ~2h / CC ~5min. ✅ Proves the update and email fire on a valid event. ✅ Small suite, quick to write. ❌ Leaves the injection fix, the rescue paths and the dedup registration unproven; those are exactly the behaviors that failed in the draft." + }, + { + "label": "4C: No tests, rely on manual staging replay (as written)", + "description": "Human 0 / CC 0. ✅ Nothing to write. ✅ The rollout checklist still runs once before broad enable. ❌ Contradicts the contract that the checklist is not regression coverage; every later change to the handler ships blind." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D6 — Issue 4: no automated tests are planned for a new payment handler.\nProject/branch/task: gstack-plan-count-GwcFZk on main, Section 6 Test Review for PLAN.md.\nELI10: The Tests section says none are planned and the existing integration suite will catch regressions. But the plan's own contract section admits the staging replay is manual deployment verification, not automated regression coverage, and the existing suite cannot exercise a class that does not exist yet. Every remedy approved so far (dispatcher registration, commit-then-notify with named rescues, bound parameters, the batched order read) has a behavior that is only proven by a test. Without them the first proof that a quote-containing user ID works is a production Stripe retry storm.\nStakes if we pick wrong: regressions in the money path are found by customers and on-call instead of CI.\nRecommendation: 4A because well-tested code is non-negotiable and each approved remedy already defines its assertion; writing them costs minutes with CC.\nCompleteness: 4A=10/10, 4B=6/10, 4C=1/10\nNet: 4A proves every approved behavior in CI; 4B proves the happy path only; 4C keeps the manual checklist as the sole safety net. ": "4A: Full unit + integration suite (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T06:45:52.304Z" + }, + { + "sessionId": "2eaeb211-e535-46d3-9a51-4f41bc42061d", + "toolUseId": "toolu_01EmXPYA1TtkW9PFC3KnscWU", + "questions": [ + { + "question": "D7 — Issue 5: the handler fetches each order in a loop (N+1) inside the webhook request.\nProject/branch/task: gstack-plan-count-GwcFZk on main, Section 7 Performance Review for PLAN.md.\nELI10: You confirmed in D3 that the orders feed the receipt email. The plan reads them one query per order, all while holding the per-user lock and inside Stripe's 10 second response window. A user with 200 orders means 201 queries per webhook; under 10x traffic that is connection-pool pressure and slow webhooks, and a slow webhook turns into Stripe retries that the dedup guard must absorb. One query scoped by user ID returns the same rows.\nStakes if we pick wrong: webhook latency scales with a user's order history, and busy users become the ones whose payments time out.\nRecommendation: 5A because a single indexed read is the smallest change that makes latency flat per webhook and it is directly testable with a query-count assertion (engineered enough, observability included).\nCompleteness: 5A=10/10, 5B=6/10, 5C=2/10\nNet: 5A makes the read O(1) queries with a bounded payload; 5B fixes the query count but not the payload; 5C leaves latency tied to order history. ", + "header": "N+1 orders", + "multiSelect": false, + "options": [ + { + "label": "5A: Single query, index check, receipt cap (recommended)", + "description": "Human ~2h / CC ~10min. ✅ Replace the loop with one `orders WHERE user_id = ?` read (bound parameter, same rule as 3A) selecting only the columns the receipt template uses; verify or add the `orders.user_id` index in the same change. ✅ Cap the receipt at the N most recent orders with a 'view all' line so the email body and query payload stay bounded; log the query duration through the existing DB client trace. ✅ Verified by a query-count assertion (exactly one orders query) and a test with more than N orders producing a capped receipt. ❌ Needs the receipt template to accept a truncated list." + }, + { + "label": "5B: Preload orders, no cap", + "description": "Human ~1h / CC ~5min. ✅ One query instead of N with an ORM preload. ✅ No template change. ❌ A user with thousands of orders still produces a huge query payload and email body inside the 10 second window." + }, + { + "label": "5C: Keep the loop (as written)", + "description": "Human 0 / CC 0. ✅ Nothing to change. ✅ Simple to read. ❌ Query count and latency scale with order history under the per-user lock; retries and pool pressure at 10x load." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D7 — Issue 5: the handler fetches each order in a loop (N+1) inside the webhook request.\nProject/branch/task: gstack-plan-count-GwcFZk on main, Section 7 Performance Review for PLAN.md.\nELI10: You confirmed in D3 that the orders feed the receipt email. The plan reads them one query per order, all while holding the per-user lock and inside Stripe's 10 second response window. A user with 200 orders means 201 queries per webhook; under 10x traffic that is connection-pool pressure and slow webhooks, and a slow webhook turns into Stripe retries that the dedup guard must absorb. One query scoped by user ID returns the same rows.\nStakes if we pick wrong: webhook latency scales with a user's order history, and busy users become the ones whose payments time out.\nRecommendation: 5A because a single indexed read is the smallest change that makes latency flat per webhook and it is directly testable with a query-count assertion (engineered enough, observability included).\nCompleteness: 5A=10/10, 5B=6/10, 5C=2/10\nNet: 5A makes the read O(1) queries with a bounded payload; 5B fixes the query count but not the payload; 5C leaves latency tied to order history. ": "5A: Single query, index check, receipt cap (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T06:46:12.434Z" + } + ] +} diff --git a/test/fixtures/design-primary-contract-ak.json b/test/fixtures/design-primary-contract-ak.json new file mode 100644 index 000000000..290af3fe1 --- /dev/null +++ b/test/fixtures/design-primary-contract-ak.json @@ -0,0 +1,62 @@ +{ + "provenance": { + "sourceHead": "04c62ac678bb7bc1a22090f72f7ed51c451c22b9", + "run": "ship-source-ak-delta-paid-20260910-v1", + "job": 3, + "call": "toolu_01YTWjwWXdWVKEK7bKT4eANC", + "session": "ca736f12-33d6-4ee1-831b-5924db71bf82", + "scope": "Exact public answered native question from the first attempt; paid outcome remains separate." + }, + "fingerprint": { + "signature": "ca736f12-33d6-4ee1-831b-5924db71bf82:toolu_01YTWjwWXdWVKEK7bKT4eANC", + "promptSnippet": "Issue 1 D2 \u2014 Issue 1: make Save the visible primary action? Project/branch/task: main, Settings Page plan review, Pass 1 Information Architecture. ELI10: Right now Save, Reset, Cancel and Export are four identical buttons. A user has to rea", + "options": [ + { + "index": 1, + "label": "1A: Save filled primary, others ghost (recommended)" + }, + { + "index": 2, + "label": "1B: Save primary, Export outlined secondary" + }, + { + "index": 3, + "label": "1C: Leave all four identical" + } + ], + "observedAtMs": 213717, + "preReview": true, + "nativeCall": { + "sessionId": "ca736f12-33d6-4ee1-831b-5924db71bf82", + "toolUseId": "toolu_01YTWjwWXdWVKEK7bKT4eANC", + "questions": [ + { + "question": "D2 \u2014 Issue 1: make Save the visible primary action?\nProject/branch/task: main, Settings Page plan review, Pass 1 Information Architecture.\nELI10: Right now Save, Reset, Cancel and Export are four identical buttons. A user has to read every label to find the one they came for. DESIGN.md already says Save is the only filled primary button and the other three are neutral ghost buttons. Stakes if wrong: people mis-click Reset or Cancel, hit the discard dialog, and lose trust in the form.\nRecommendation: 1A because DESIGN.md prescribes the exact treatment and it reuses the existing Button variants.\nCompleteness: 1A=10/10, 1B=6/10, 1C=3/10\n1A) Save filled primary (#1d4ed8, white), Reset/Cancel/Export ghost (recommended) (human: ~1h / CC: ~5min)\n \u2705 One filled button in the row means the primary action is found in the 3-second scan\n \u2705 Reuses existing Button primary and ghost variants; zero new CSS tokens\n \u274c Export loses visual weight, so export-heavy users find it one beat slower\n1B) Save primary plus Export as an outlined secondary; Reset/Cancel ghost (human: ~2h / CC: ~10min)\n \u2705 Keeps Export discoverable as a distinct second tier\n \u2705 Still gives Save the only filled treatment\n \u274c Adds a third button tier DESIGN.md does not define; new token to maintain\n1C) Leave all four identical\n \u2705 No implementation work\n \u2705 Zero risk of regressing button styles elsewhere\n \u274c Documented violation ships; primary action stays invisible\nNet: 1A trades a hair of Export prominence for an unmistakable primary action and zero new tokens.", + "header": "Issue 1", + "multiSelect": false, + "options": [ + { + "label": "1A: Save filled primary, others ghost (recommended)", + "description": "Apply DESIGN.md tokens: Save #1d4ed8 filled with white text; Reset, Cancel, Export neutral ghost. Reuse existing Button variants." + }, + { + "label": "1B: Save primary, Export outlined secondary", + "description": "Adds a third tier not in DESIGN.md. Requires a new token." + }, + { + "label": "1C: Leave all four identical", + "description": "Decline the fix; gap stays documented and lowers the score." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D2 \u2014 Issue 1: make Save the visible primary action?\nProject/branch/task: main, Settings Page plan review, Pass 1 Information Architecture.\nELI10: Right now Save, Reset, Cancel and Export are four identical buttons. A user has to read every label to find the one they came for. DESIGN.md already says Save is the only filled primary button and the other three are neutral ghost buttons. Stakes if wrong: people mis-click Reset or Cancel, hit the discard dialog, and lose trust in the form.\nRecommendation: 1A because DESIGN.md prescribes the exact treatment and it reuses the existing Button variants.\nCompleteness: 1A=10/10, 1B=6/10, 1C=3/10\n1A) Save filled primary (#1d4ed8, white), Reset/Cancel/Export ghost (recommended) (human: ~1h / CC: ~5min)\n \u2705 One filled button in the row means the primary action is found in the 3-second scan\n \u2705 Reuses existing Button primary and ghost variants; zero new CSS tokens\n \u274c Export loses visual weight, so export-heavy users find it one beat slower\n1B) Save primary plus Export as an outlined secondary; Reset/Cancel ghost (human: ~2h / CC: ~10min)\n \u2705 Keeps Export discoverable as a distinct second tier\n \u2705 Still gives Save the only filled treatment\n \u274c Adds a third button tier DESIGN.md does not define; new token to maintain\n1C) Leave all four identical\n \u2705 No implementation work\n \u2705 Zero risk of regressing button styles elsewhere\n \u274c Documented violation ships; primary action stays invisible\nNet: 1A trades a hair of Export prominence for an unmistakable primary action and zero new tokens.": "1A: Save filled primary, others ghost (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T06:42:02.231Z" + } + } +} diff --git a/test/fixtures/design-scope-declaration-ak.json b/test/fixtures/design-scope-declaration-ak.json new file mode 100644 index 000000000..4ca8baa15 --- /dev/null +++ b/test/fixtures/design-scope-declaration-ak.json @@ -0,0 +1,171 @@ +{ + "sourceHead": "04c62ac678bb7bc1a22090f72f7ed51c451c22b9", + "proof": ".context/ship-source-ak-delta-paid-20260910-v1/design19-scope-evidence-ledger-v1/proof.json", + "proofSha256": "0e5451e8db7de6b3a6441ba1ca2739c98c3be1a6c7c9971950b5562e86e29b49", + "attempts": [ + { + "attempt": 1, + "source": { + "path": ".context/ship-source-ak-delta-paid-20260910-v1/evals/job-19/shards/skill-e2e-plan-design-plan-mode/pty-count/ship-source-ak-delta-paid-20260910-v1-19/plan-design-review-1789024094296-1SOisw/observation.json", + "sha256": "a697e4cb3a4452e4104bcaf5585048cf72e28b455d10076f83f6df33155683b5" + }, + "rawScopeGateAutoSelectObserved": false, + "projection": { + "transcript": { + "status": "ready", + "calls": [], + "assistantMessages": [ + { + "sessionId": "67726d1f-17e6-4716-9f05-ee0287088742", + "text": "I'll run the design review skill against your draft plan.", + "timestamp": "2026-09-10T07:06:50.423Z" + }, + { + "sessionId": "67726d1f-17e6-4716-9f05-ee0287088742", + "text": "Running the skill preamble first.", + "timestamp": "2026-09-10T07:07:01.381Z" + }, + { + "sessionId": "67726d1f-17e6-4716-9f05-ee0287088742", + "text": "I'll proceed with reviewing the \"Marketing landing page\" draft, starting with a pre-review audit covering context recovery, the design binary check, brain context, and repo state.\n\n", + "timestamp": "2026-09-10T07:07:13.761Z" + }, + { + "sessionId": "67726d1f-17e6-4716-9f05-ee0287088742", + "text": "Audit done. Here's what I found before rating.\n\n**Pre-review system audit**\n\n- **Target:** the pasted \"Marketing landing page\" draft (plan mode auto-selected B). No plan file exists yet; I'll create one at the host-referenced path as the review artifact.\n- **DESIGN.md exists** at repo root. It is a real spec: Satoshi display, DM Sans body, JetBrains Mono as the personality font, amber accent used rarely, dark mode default, Industrial/Utilitarian direction, editorial hero for the landing page, 12-col grid at lg+, 4px spacing base, one authored motion moment. Every finding below calibrates against it.\n- **Prior design reviews:** none on this branch. TODOS.md has no landing-page or pricing items.\n- **Designer binary:** `DESIGN_READY`. Mockups will be generated after you answer the scope question.\n- **Brain context:** cold (no product, brand, or decisions digests).\n- **Base branch:** `main`.\n- **UI scope:** two pages, six sections, one carousel. Design review applies.\n\n**Step 0A: Initial design rating: 2/10**\n\nIt's a 2 because the plan names sections but makes exactly three concrete design decisions, and all three contradict DESIGN.md or a core principle:\n\n1. **\"Everything center-aligned for a clean modern look\"** is a vibe, not a decision, and it fights DESIGN.md's \"editorial hero\" and 12-column grid. Centered body copy is also the single most reliable AI-slop tell.\n2. **Hero h1 8px above the subhead** is `sm` on the spacing scale. At 72px Satoshi Black, 8px reads as a typo, not a pairing. Proximity is right, magnitude is wrong.\n3. **CTA has the same visual weight as \"Learn more\"** breaks hierarchy on the one element the page exists to drive. Users satisfice; if nothing is more prominent, they click nothing.\n\nThen the pattern list: hero, 3-column features grid, testimonials carousel, footer, 3 pricing tier cards. That is the Codex hard-rejection list verbatim (generic card grid, carousel with no narrative purpose, stacked cards instead of layout). No copy hierarchy, no brand anchor, no states, and \"stacks on mobile\" is Principle 6's exact anti-pattern.\n\nA 10 for this plan: every section names its one job and its first/second/third read; the hero is a composition using Satoshi + JetBrains Mono per DESIGN.md with one authored motion moment; primary CTA is a filled amber button and \"Learn more\" is a text link; the features section is a layout, not a grid of cards; testimonials are a static, scannable set or cut; pricing shows tier comparison without three identical cards; loading, error, empty, and success states are specified for anything dynamic (carousel, pricing toggle, any form); mobile has its own hierarchy decisions per section; touch targets, contrast, keyboard nav, and reduced-motion are specified.\n\n**0B:** DESIGN.md exists. All decisions calibrate against it.\n\n**0C: Existing leverage.** DESIGN.md already gives you the type scale, color tokens, spacing scale, radii, grain texture, and the motion budget. The plan should reference these tokens by name rather than invent \"8px\" or \"same weight\" ad hoc.", + "timestamp": "2026-09-10T07:08:01.152Z" + } + ] + }, + "tools": [ + { + "sessionId": "67726d1f-17e6-4716-9f05-ee0287088742", + "timestamp": "2026-09-10T07:06:52.634Z", + "toolUseId": "toolu_013AVSbqXfk4Pma6UmJKtGwY", + "kind": "use", + "name": "Skill", + "input": { + "skill": "plan-design-review", + "args": "Review the draft plan \"Marketing landing page\" (centered-everything layout, hero h1 8px above subhead, CTA same weight as Learn more link, / and /pricing pages, happy-path-only states, \"stacks on mobile\")" + } + }, + { + "sessionId": "67726d1f-17e6-4716-9f05-ee0287088742", + "timestamp": "2026-09-10T07:06:54.050Z", + "toolUseId": "toolu_013AVSbqXfk4Pma6UmJKtGwY", + "kind": "result", + "content": "Launching skill: plan-design-review", + "isError": false + } + ], + "opts": { + "seed": "\n# Plan: Marketing landing page\n\n## Layout\nAll headings, taglines, and body copy will be center-aligned for a\n\"clean modern look.\" The hero h1 sits 8px above the subhead; the CTA\nbutton has the same visual weight as the \"Learn more\" link beside it.\n\n## Pages\n- / (hero, 3-column features grid, testimonials carousel, footer)\n- /pricing (3 tier cards)\n\n## States\nOnly the happy path is designed. No empty states, no error states,\nno loading states. Mobile: \"stacks on mobile.\"\n", + "skillName": "plan-design-review", + "sessionId": "67726d1f-17e6-4716-9f05-ee0287088742", + "commandStartedAt": 1789024004718 + } + } + }, + { + "attempt": 2, + "source": { + "path": ".context/ship-source-ak-delta-paid-20260910-v1/evals/job-19/shards/skill-e2e-plan-design-plan-mode/pty-count/ship-source-ak-delta-paid-20260910-v1-19/plan-design-review-1789024241776-ohRpzN/observation.json", + "sha256": "93c2cb9fce8e642b7ed3044f9637f9a611b9d7a7f240d188ec743c8cd069cc4a" + }, + "rawScopeGateAutoSelectObserved": false, + "projection": { + "transcript": { + "status": "ready", + "calls": [], + "assistantMessages": [ + { + "sessionId": "edf34f25-8871-4710-b541-df9b2f625431", + "text": "I'll run the /plan-design-review skill against this draft plan.", + "timestamp": "2026-09-10T07:08:31.881Z" + }, + { + "sessionId": "edf34f25-8871-4710-b541-df9b2f625431", + "text": "Scope gate confirms plan mode, so I'll review the pasted \"Marketing landing page\" draft plan. Running the pre-review audit, design setup check, brain context, and base-branch detection in parallel next.\n\n", + "timestamp": "2026-09-10T07:08:52.518Z" + } + ] + }, + "tools": [ + { + "sessionId": "edf34f25-8871-4710-b541-df9b2f625431", + "timestamp": "2026-09-10T07:08:33.112Z", + "toolUseId": "toolu_01MSnSSPSULjT7Pn11A36Vw7", + "kind": "use", + "name": "Skill", + "input": { + "skill": "plan-design-review" + } + }, + { + "sessionId": "edf34f25-8871-4710-b541-df9b2f625431", + "timestamp": "2026-09-10T07:08:34.543Z", + "toolUseId": "toolu_01MSnSSPSULjT7Pn11A36Vw7", + "kind": "result", + "content": "Launching skill: plan-design-review", + "isError": false + } + ], + "opts": { + "seed": "\n# Plan: Marketing landing page\n\n## Layout\nAll headings, taglines, and body copy will be center-aligned for a\n\"clean modern look.\" The hero h1 sits 8px above the subhead; the CTA\nbutton has the same visual weight as the \"Learn more\" link beside it.\n\n## Pages\n- / (hero, 3-column features grid, testimonials carousel, footer)\n- /pricing (3 tier cards)\n\n## States\nOnly the happy path is designed. No empty states, no error states,\nno loading states. Mobile: \"stacks on mobile.\"\n", + "skillName": "plan-design-review", + "sessionId": "edf34f25-8871-4710-b541-df9b2f625431", + "commandStartedAt": 1789024105646 + } + } + } + ], + "priorGenuineFailure": { + "source": ".context/ship-source-aj-delta-paid-20260910-v1/eng24-scope-replay-ledger-v1/projection.json", + "sha256": "7d2b435d77c060a7964e43a3273a86702f75e50c62a37881ba44be479999bb96", + "rawScopeSelection": false, + "projection": { + "transcript": { + "status": "ready", + "calls": [], + "assistantMessages": [ + { + "sessionId": "54227e78-941b-4cd3-937a-dd4f30837acd", + "text": "I'll run the /plan-eng-review skill on this draft plan.", + "timestamp": "2026-09-10T06:04:55.547Z" + }, + { + "sessionId": "54227e78-941b-4cd3-937a-dd4f30837acd", + "text": "Step 0 scope check: gathering the last facts on the existing runner before I write up findings.", + "timestamp": "2026-09-10T06:06:00.736Z" + } + ] + }, + "tools": [ + { + "sessionId": "54227e78-941b-4cd3-937a-dd4f30837acd", + "timestamp": "2026-09-10T06:04:55.590Z", + "toolUseId": "toolu_01NvWCxjAJZ3EEQdqDWa6yo7", + "kind": "use", + "name": "Skill", + "input": { + "skill": "plan-eng-review" + } + }, + { + "sessionId": "54227e78-941b-4cd3-937a-dd4f30837acd", + "timestamp": "2026-09-10T06:04:57.061Z", + "toolUseId": "toolu_01NvWCxjAJZ3EEQdqDWa6yo7", + "kind": "result", + "content": "Launching skill: plan-eng-review", + "isError": false + } + ], + "opts": { + "seed": "\n# Parallelize unit tests\n\n## Plan\nBuild a custom test runner: scripts/test-parallel.ts, scripts/test-shard-impl.ts,\nscripts/test-merge-results.ts, scripts/test-progress.ts, scripts/test-watch.ts,\nscripts/test-coverage.ts, scripts/test-cli.ts, scripts/test-config.ts.\n\nAdd new TestRunner class, new ShardManager class, new ResultMerger class.\n\nIgnore Bun's native --shard flag because we want full control.\n\n## Files\n- scripts/test-parallel.ts (new)\n- scripts/test-shard-impl.ts (new)\n- scripts/test-merge-results.ts (new)\n- scripts/test-progress.ts (new)\n- scripts/test-watch.ts (new)\n- scripts/test-coverage.ts (new)\n- scripts/test-cli.ts (new)\n- scripts/test-config.ts (new)\n- package.json (add scripts)\n\n## Tests\nNone planned \u2014 will add later.\n", + "skillName": "plan-eng-review", + "sessionId": "54227e78-941b-4cd3-937a-dd4f30837acd", + "commandStartedAt": 1789020263038 + } + } + } +} diff --git a/test/fixtures/dx-signature-identity-ak.json b/test/fixtures/dx-signature-identity-ak.json new file mode 100644 index 000000000..f3e4fcbdb --- /dev/null +++ b/test/fixtures/dx-signature-identity-ak.json @@ -0,0 +1,43 @@ +{ + "sourceHead": "04c62ac678bb7bc1a22090f72f7ed51c451c22b9", + "sourceProof": ".context/ship-source-ak-delta-paid-20260910-v1/dx-first-evidence-ledger-v1/proof.json", + "sourceProofSha256": "65ecd20f1192f93db35bb029cbf15a3a413d1a458e9ab94a52e254cd693f416d", + "nativeUseLine": 117, + "nativeResultLine": 118, + "call": { + "sessionId": "b13879bb-972e-423e-8eb7-39e3e1d34afc", + "toolUseId": "toolu_01BNM2BvRUKbmDJprW1thfBr", + "questions": [ + { + "question": "D5 — Journey stage REAL USAGE: the two public functions take the same two arguments in opposite positional order. How should the plan fix the signatures?\nProject/branch/task: EvalKit 2.0.0b1 on `main`; reviewing the public function signatures in docs/api.md.\nELI10: docs/api.md lines 5-9 define `run_eval(dataset, evaluator)` and `run_batch(evaluator, dataset)`. Same two concepts, reversed order, and the reversal is called intentional. A developer who learns one function will call the other wrong, and since both arguments are objects, the failure surfaces somewhere inside the evaluator instead of at the call site. Consistency across a small API is what lets a developer stop reading docs, which is the whole point of an SDK.\nStakes if we pick wrong: Every developer who uses both functions hits one swapped call with a confusing deep failure; some will assume the SDK is buggy.\nRecommendation: A because 2.0.0b1 is a major-version beta, the one moment a positional reorder is cheap, and adding a clear call-site error covers anyone who still swaps them.\nCompleteness: A=10/10, B=8/10, C=6/10, D=1/10\nNet: fix the shape of the API once at the major boundary vs bolting guards onto an inconsistent shape forever.", + "header": "Signatures", + "multiSelect": false, + "options": [ + { + "label": "Same order + swap guard (recommended)", + "description": "✅ Both become `(dataset, evaluator)`, accept keywords, and raise a call-site `TypeError` naming the swapped argument and the fix if types are reversed. (human: ~1 day / CC: ~15 min)\n✅ Learn one function, know both; docs and examples show a single shape.\n❌ Breaking change for any v1 `run_batch` positional caller; must be listed in the 2.0 changelog and migration guide from D7." + }, + { + "label": "Keyword-only for both", + "description": "✅ A bare `*` makes positional calls impossible, so order can never be confused.\n✅ No reorder needed; existing keyword callers unaffected. (human: ~half day / CC: ~10 min)\n❌ Forces `run_eval(dataset=ds, evaluator=ev)` on every call, which is verbose for a two-argument function and still breaks every positional caller." + }, + { + "label": "Keep order, add swap detection", + "description": "✅ No signature change; a runtime check raises a clear error when types are reversed. (human: ~half day / CC: ~10 min)\n✅ Zero breakage for current callers.\n❌ The inconsistency stays in the docs and in muscle memory; the error fires on every mistake instead of preventing it." + }, + { + "label": "Retain as drafted", + "description": "✅ No engineering change for this beta.\n✅ Existing positional callers of both functions keep working unchanged.\n❌ An intentional inconsistency in a two-function API is the kind of thing developers screenshot and post." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D5 — Journey stage REAL USAGE: the two public functions take the same two arguments in opposite positional order. How should the plan fix the signatures?\nProject/branch/task: EvalKit 2.0.0b1 on `main`; reviewing the public function signatures in docs/api.md.\nELI10: docs/api.md lines 5-9 define `run_eval(dataset, evaluator)` and `run_batch(evaluator, dataset)`. Same two concepts, reversed order, and the reversal is called intentional. A developer who learns one function will call the other wrong, and since both arguments are objects, the failure surfaces somewhere inside the evaluator instead of at the call site. Consistency across a small API is what lets a developer stop reading docs, which is the whole point of an SDK.\nStakes if we pick wrong: Every developer who uses both functions hits one swapped call with a confusing deep failure; some will assume the SDK is buggy.\nRecommendation: A because 2.0.0b1 is a major-version beta, the one moment a positional reorder is cheap, and adding a clear call-site error covers anyone who still swaps them.\nCompleteness: A=10/10, B=8/10, C=6/10, D=1/10\nNet: fix the shape of the API once at the major boundary vs bolting guards onto an inconsistent shape forever.": "Same order + swap guard (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T06:43:10.229Z" + } +} diff --git a/test/fixtures/eng-declared-suite-ak.json b/test/fixtures/eng-declared-suite-ak.json new file mode 100644 index 000000000..a76ed52a6 --- /dev/null +++ b/test/fixtures/eng-declared-suite-ak.json @@ -0,0 +1,152 @@ +{ + "provenance": { + "sourceHead": "04c62ac678bb7bc1a22090f72f7ed51c451c22b9", + "observationSha256": "0e5d4ec5422747358ee231c9f20ad7ebf3f1066c4a5945858bfb7d9baabc0bd6", + "reportSha256": "4f1412af8f6dfb915bb46f544ec8c862ebed9c2453dd71a1d292bfda7de2a34d", + "window": { + "start": 1789022787679, + "end": 1789023257841 + }, + "historicalPaidFailurePreserved": true, + "limitation": "The original caller start timestamp was not retained; replay uses the conservative inclusive interval of exact completed owned calls. No live completion or paid pass is credited." + }, + "transcript": { + "status": "ready", + "calls": [ + { + "sessionId": "e92fe932-2f66-48b1-94b7-e0601f44ac51", + "toolUseId": "toolu_013PApJiurP7M7yVUy8t2UAv", + "questions": [ + { + "question": "D3 — Step 0 scope challenge: reduce to two new services on the existing adapter, or proceed with all five new types?\nProject/branch/task: gstack-plan-count-EN8bNx on main, PLAN.md Multi-tenant Auth Refactor.\nELI10: The plan adds AuthBroker, SessionMint, AuthCache, TokenStore, and RequestPolicy across 12 files (PLAN.md:19, :35-36). But PLAN.md:11-13 says AuthCache is only a facade over the existing cache adapter, which stays unchanged with one backing cache and its own tests. That means at least one new class exists to wrap something that already works. TokenStore and RequestPolicy are named but never described, so nobody can say what problem they solve. Every extra class here is another mutable surface in the auth path, the one place where a bug means users get logged out or, worse, see another tenant's session.\nStakes if we pick wrong: Too big means five new types to review, test, and keep coherent in the hottest security path. Too small means we defer a class the core goal actually needs and rebuild it in a follow-up.\nRecommendation: A because AuthBroker and SessionMint carry the real behavior; the existing adapter (PLAN.md:7-13) already keys by tenant/issuer/audience/policy version and already invalidates on logout/revocation/suspension, so it can be injected directly [Layer 1: reuse what is a few files over]. TokenStore and RequestPolicy get a one-line justification each or move to NOT in scope.\nCompleteness: A=9/10, B=9/10, C=7/10 (coverage of the stated goal is equal for A and B; C defers pieces without deciding).\nNet: fewer moving parts in the auth path versus keeping every named class in case it turns out to be needed.", + "header": "Scope", + "multiSelect": false, + "options": [ + { + "label": "Reduce: 2 services + existing adapter (recommended)", + "description": "✅ AuthBroker and SessionMint take the existing cache adapter as a constructor dependency; no AuthCache facade, no module-level global (human: ~1 day less / CC: ~20 min less). ✅ TokenStore and RequestPolicy each need a stated purpose in the plan or go to NOT in scope, so no undocumented class ships in the auth path. ❌ If RequestPolicy is the reason the refactor exists, we spend a review round adding it back with its rationale." + }, + { + "label": "Proceed as-is: 5 new types, 12 files", + "description": "✅ No rework of the plan's component list; whoever wrote it may already know why each class exists. ✅ Keeps a clean separation if TokenStore and RequestPolicy do carry distinct responsibilities. ❌ Five new types and 12 files in the auth path with two of them undescribed; review and test surface roughly doubles for the same goal." + }, + { + "label": "Reduce and defer TokenStore/RequestPolicy to a follow-up", + "description": "✅ Ships the two services now and parks the two unexplained classes as TODOs with context (human: ~2 days / CC: ~30 min). ✅ Smallest first diff, lowest blast radius for the first landing. ❌ Defers the decision rather than making it; if RequestPolicy is load-bearing, the first landing is incomplete." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D3 — Step 0 scope challenge: reduce to two new services on the existing adapter, or proceed with all five new types?\nProject/branch/task: gstack-plan-count-EN8bNx on main, PLAN.md Multi-tenant Auth Refactor.\nELI10: The plan adds AuthBroker, SessionMint, AuthCache, TokenStore, and RequestPolicy across 12 files (PLAN.md:19, :35-36). But PLAN.md:11-13 says AuthCache is only a facade over the existing cache adapter, which stays unchanged with one backing cache and its own tests. That means at least one new class exists to wrap something that already works. TokenStore and RequestPolicy are named but never described, so nobody can say what problem they solve. Every extra class here is another mutable surface in the auth path, the one place where a bug means users get logged out or, worse, see another tenant's session.\nStakes if we pick wrong: Too big means five new types to review, test, and keep coherent in the hottest security path. Too small means we defer a class the core goal actually needs and rebuild it in a follow-up.\nRecommendation: A because AuthBroker and SessionMint carry the real behavior; the existing adapter (PLAN.md:7-13) already keys by tenant/issuer/audience/policy version and already invalidates on logout/revocation/suspension, so it can be injected directly [Layer 1: reuse what is a few files over]. TokenStore and RequestPolicy get a one-line justification each or move to NOT in scope.\nCompleteness: A=9/10, B=9/10, C=7/10 (coverage of the stated goal is equal for A and B; C defers pieces without deciding).\nNet: fewer moving parts in the auth path versus keeping every named class in case it turns out to be needed.": "Reduce: 2 services + existing adapter (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T06:48:44.050Z" + }, + { + "sessionId": "e92fe932-2f66-48b1-94b7-e0601f44ac51", + "toolUseId": "toolu_01GQN7GojZ1BDMneXoASuHKe", + "questions": [ + { + "question": "D5 — Issue 1 [P1] (confidence 8/10) PLAN.md:19-20 + :10 — two writers on one cache with no mutation ordering. Who owns writes?\nProject/branch/task: gstack-plan-count-EN8bNx on main, PLAN.md Multi-tenant Auth Refactor, Architecture section.\nELI10: PLAN.md:19-20 says AuthBroker and SessionMint 'both mutate' the shared cache, and PLAN.md:10 admits the cache rules 'do not serialize mutations'. D3 already replaced the module-level global with constructor injection, but injection does not fix ordering. Picture it: a tenant gets suspended, the adapter's invalidation hook wipes their entries, and 5ms later SessionMint finishes an in-flight mint and writes a fresh session for that suspended tenant. The user keeps a valid session after suspension. Nobody sees an error; the cache just quietly holds a session that should not exist.\nStakes if we pick wrong: A suspended or logged-out tenant keeps a live session. That is a silent security failure in the exact path this refactor exists to harden.\nRecommendation: A because a single writer plus a version check is the explicit, boring fix; it maps to your 'explicit over clever' and 'handle more edge cases' preferences and costs minutes with CC.\nCompleteness: A=10/10, B=7/10, C=3/10\nNet: one clear write path with a re-check on commit, versus trusting two services to never race the invalidation hooks.", + "header": "Issue 1", + "multiSelect": false, + "options": [ + { + "label": "1A: Single writer + invalidation-version check (recommended)", + "description": "✅ SessionMint is the only service that writes session entries; AuthBroker reads and asks SessionMint to mint. Each write re-reads the tenant's invalidation version (or suspension flag) from the adapter right before commit and aborts if it moved. Tests: mint-during-suspend, mint-during-logout, mint-during-revocation each assert no entry lands (human: ~1 day / CC: ~20 min). ✅ The race becomes impossible by construction, and the ASCII write-ownership diagram goes in the SessionMint header comment. ❌ Adds one adapter read per write and a small ordering contract that SessionMint must honor." + }, + { + "label": "1B: Single writer only, no version check", + "description": "✅ Removes the two-writer coupling with a one-line ownership rule and no new adapter calls (human: ~half day / CC: ~10 min). ✅ Simpler contract; easier to explain in the plan. ❌ The suspend-then-late-mint race still lands a session for a suspended tenant; it only removes broker-vs-mint conflicts, not hook-vs-mint." + }, + { + "label": "1C: Keep both writers as planned", + "description": "✅ Zero plan changes; matches PLAN.md:19-20 as written. ✅ No new ordering contract to document or test. ❌ Two services and the invalidation hooks all mutate one cache with no ordering; the stale-session-after-suspension race stays untested and silent." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D5 — Issue 1 [P1] (confidence 8/10) PLAN.md:19-20 + :10 — two writers on one cache with no mutation ordering. Who owns writes?\nProject/branch/task: gstack-plan-count-EN8bNx on main, PLAN.md Multi-tenant Auth Refactor, Architecture section.\nELI10: PLAN.md:19-20 says AuthBroker and SessionMint 'both mutate' the shared cache, and PLAN.md:10 admits the cache rules 'do not serialize mutations'. D3 already replaced the module-level global with constructor injection, but injection does not fix ordering. Picture it: a tenant gets suspended, the adapter's invalidation hook wipes their entries, and 5ms later SessionMint finishes an in-flight mint and writes a fresh session for that suspended tenant. The user keeps a valid session after suspension. Nobody sees an error; the cache just quietly holds a session that should not exist.\nStakes if we pick wrong: A suspended or logged-out tenant keeps a live session. That is a silent security failure in the exact path this refactor exists to harden.\nRecommendation: A because a single writer plus a version check is the explicit, boring fix; it maps to your 'explicit over clever' and 'handle more edge cases' preferences and costs minutes with CC.\nCompleteness: A=10/10, B=7/10, C=3/10\nNet: one clear write path with a re-check on commit, versus trusting two services to never race the invalidation hooks.": "1A: Single writer + invalidation-version check (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T06:50:10.324Z" + }, + { + "sessionId": "e92fe932-2f66-48b1-94b7-e0601f44ac51", + "toolUseId": "toolu_01LhBJGJYHEvKNFSBEVaBFRK", + "questions": [ + { + "question": "D8 — Issue 4 [P1] (confidence 9/10) PLAN.md:23-24 — validateAndDispatch() is 60 lines with three nested try/catch blocks that each swallow a different error class. Decompose or leave?\nProject/branch/task: gstack-plan-count-EN8bNx on main, PLAN.md Multi-tenant Auth Refactor, Code Quality section.\nELI10: PLAN.md:23-24 describes one function that validates a token and dispatches on the result, with three try/catch blocks stacked inside each other, and each catch eats a different kind of error. In an auth path, a swallowed error means a token that failed validation can fall through to the dispatch step looking like it passed. It also means when something breaks in production, the log shows nothing because the catch already ate the evidence. The fix is to split the function into small named steps that each return an explicit result, and to make every error either handled with a named outcome or rethrown.\nStakes if we pick wrong: Silent validation failures that let bad tokens through, plus zero forensic trail when it happens.\nRecommendation: A because 'explicit over clever' is your stated preference and swallowed errors in auth are the textbook silent-failure case; each extracted step also becomes independently testable.\nCompleteness: A=10/10, B=6/10, C=2/10\nNet: a handful of small pure steps with a typed result, versus one long function whose failure modes are invisible.", + "header": "Issue 4", + "multiSelect": false, + "options": [ + { + "label": "4A: Split into steps, typed result, no swallowed errors (recommended)", + "description": "✅ Extract parseToken, verifyWithIdp, checkTenantPolicy, and dispatch as separate functions; each returns a discriminated Ok/Err result, and every catch either maps to a named Err variant or rethrows (human: ~1 day / CC: ~20 min). ✅ Each step gets its own unit tests for success and every error class, and every Err is logged with tenant and error class before the caller sees it. ❌ Four small functions instead of one; a little more surface in the file and a result type to define once." + }, + { + "label": "4B: Keep one function, flatten the try/catch, log before swallowing", + "description": "✅ Smaller diff: one try block with error-class dispatch in a single catch, plus a log line per swallowed class (human: ~2 hours / CC: ~5 min). ✅ Removes the nesting and restores a forensic trail. ❌ Still a 50-plus-line function where validation and dispatch share scope; error classes still get swallowed instead of surfacing as explicit outcomes." + }, + { + "label": "4C: Leave as described", + "description": "✅ No refactor risk to a function that presumably works today. ✅ Zero extra effort in this PR. ❌ Three nested swallowing catches in the auth path stay untestable per branch and keep hiding failures." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D8 — Issue 4 [P1] (confidence 9/10) PLAN.md:23-24 — validateAndDispatch() is 60 lines with three nested try/catch blocks that each swallow a different error class. Decompose or leave?\nProject/branch/task: gstack-plan-count-EN8bNx on main, PLAN.md Multi-tenant Auth Refactor, Code Quality section.\nELI10: PLAN.md:23-24 describes one function that validates a token and dispatches on the result, with three try/catch blocks stacked inside each other, and each catch eats a different kind of error. In an auth path, a swallowed error means a token that failed validation can fall through to the dispatch step looking like it passed. It also means when something breaks in production, the log shows nothing because the catch already ate the evidence. The fix is to split the function into small named steps that each return an explicit result, and to make every error either handled with a named outcome or rethrown.\nStakes if we pick wrong: Silent validation failures that let bad tokens through, plus zero forensic trail when it happens.\nRecommendation: A because 'explicit over clever' is your stated preference and swallowed errors in auth are the textbook silent-failure case; each extracted step also becomes independently testable.\nCompleteness: A=10/10, B=6/10, C=2/10\nNet: a handful of small pure steps with a typed result, versus one long function whose failure modes are invisible.": "4A: Split into steps, typed result, no swallowed errors (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T06:51:20.578Z" + }, + { + "sessionId": "e92fe932-2f66-48b1-94b7-e0601f44ac51", + "toolUseId": "toolu_015k34JTspLGeiGma1yVDone", + "questions": [ + { + "question": "D11 — Issue 7 [P2] (confidence 8/10) PLAN.md:31-32 — five sequential IDP calls; plan says Promise.all is trivial. Parallelize, and with which failure semantics?\nProject/branch/task: gstack-plan-count-EN8bNx on main, PLAN.md Multi-tenant Auth Refactor, Performance section.\nELI10: PLAN.md:31-32 says the five IDP calls are independent and could run at once with Promise.all. That turns five round trips into one, so every login gets roughly five times less IDP wait. The catch is what 'all' means on failure: Promise.all rejects the moment one call fails, which is exactly right for auth (fail closed, per Issue 3), but the other four calls keep running in the background with nobody listening. Each call needs the timeout from Issue 3 and an abort signal so a rejected validation does not leave four requests hammering the IDP.\nStakes if we pick wrong: Either logins stay five round trips slow, or a naive Promise.all leaks in-flight requests and doubles IDP load during an IDP incident, which is the worst time to do it.\nRecommendation: A because Promise.all is the correct fail-closed primitive here and the abort wiring is a few lines that pays off precisely when the IDP is unhealthy.\nCompleteness: A=10/10, B=7/10, C=3/10\nNet: parallel validation that cleans up after itself on failure, versus parallel validation that leaks work under the conditions where leaking hurts most.", + "header": "Issue 7", + "multiSelect": false, + "options": [ + { + "label": "7A: Promise.all + shared AbortController + per-call timeout (recommended)", + "description": "✅ All five calls fire together under one AbortController; the first rejection aborts the rest and validation fails closed with the typed error from Issue 3 (human: ~half day / CC: ~10 min). ✅ Tests assert one failure aborts the remaining four, total latency is bounded by the slowest call not the sum, and a success needs all five fulfilled. ❌ Slightly more wiring than a bare Promise.all, and the IDP client must accept an abort signal." + }, + { + "label": "7B: Bare Promise.all as the plan states", + "description": "✅ One-line change, exactly what PLAN.md:32 proposes (human: ~15 min / CC: ~2 min). ✅ Gets the full latency win immediately. ❌ A rejection leaves four in-flight requests running to completion; during an IDP incident the retry from Issue 3 multiplies that." + }, + { + "label": "7C: Keep sequential", + "description": "✅ Zero change and the simplest mental model; one call at a time. ✅ No abort or concurrency handling to test. ❌ Every login pays five serial IDP round trips when the plan itself says they are independent." + } + ] + } + ], + "answered": true, + "failed": false, + "answers": { + "D11 — Issue 7 [P2] (confidence 8/10) PLAN.md:31-32 — five sequential IDP calls; plan says Promise.all is trivial. Parallelize, and with which failure semantics?\nProject/branch/task: gstack-plan-count-EN8bNx on main, PLAN.md Multi-tenant Auth Refactor, Performance section.\nELI10: PLAN.md:31-32 says the five IDP calls are independent and could run at once with Promise.all. That turns five round trips into one, so every login gets roughly five times less IDP wait. The catch is what 'all' means on failure: Promise.all rejects the moment one call fails, which is exactly right for auth (fail closed, per Issue 3), but the other four calls keep running in the background with nobody listening. Each call needs the timeout from Issue 3 and an abort signal so a rejected validation does not leave four requests hammering the IDP.\nStakes if we pick wrong: Either logins stay five round trips slow, or a naive Promise.all leaks in-flight requests and doubles IDP load during an IDP incident, which is the worst time to do it.\nRecommendation: A because Promise.all is the correct fail-closed primitive here and the abort wiring is a few lines that pays off precisely when the IDP is unhealthy.\nCompleteness: A=10/10, B=7/10, C=3/10\nNet: parallel validation that cleans up after itself on failure, versus parallel validation that leaks work under the conditions where leaking hurts most.": "7A: Promise.all + shared AbortController + per-call timeout (recommended)" + }, + "unansweredQuestionIndices": [], + "answeredAt": "2026-09-10T06:52:42.902Z" + } + ], + "assistantMessages": [], + "planReadyRequests": [] + }, + "required": "### REGRESSION (mandatory rule, no approval needed) — CRITICAL\n\nPLAN.md:27-28 rewrites `legacyAuthFlow()` and plans no regression test; PLAN.md:15-16\nsays planned coverage \"does not exercise legacyAuthFlow() or assert compatibility with\nits prior behavior\". That is a regression by definition (modifies existing behavior,\nexisting tests do not cover it). **Add a characterization test suite for\n`legacyAuthFlow()` before touching it**: capture current inputs and outputs (success,\nexpired token, bad signature, unknown tenant, suspended tenant, revoked token) and run\nthe same suite against `routeAuth` on both flag settings. A behavior difference between\npaths is a test failure, not a support ticket.", + "task": "- [ ] **T7 (P1, human: ~1 day / CC: ~15 min)** — tests/regression — CRITICAL characterization suite for legacyAuthFlow(), run on both router paths\n - Surfaced by: Test review — mandatory REGRESSION RULE — PLAN.md:27-28, :15-16\n - Files: tests/regression/legacyAuthFlow\n - Verify: suite passes on legacy before any refactor; passes on new path before flag enable", + "verification": "## Verification (end to end)\n\n1. Run the characterization suite against `legacyAuthFlow()` on the unmodified code; it must pass before any refactor lands.\n2. Implement T6, T4, T9, T10, T5, T2, T1, T3, T11 with their unit tests; run the full unit suite.\n3. Run the characterization suite through `routeAuth` with the flag on `new`; zero differences.\n4. Run the E2E suite (T8) against the fake IDP; all nine flows pass on both paths.\n5. Enable the flag for internal tenants; confirm the dashboard shows both paths with comparable failure rate and latency; flip the kill switch once to prove rollback.\n6. Widen to the canary slice, then all tenants. Open TODO 1 with the bake-period date.", + "reviewReport": "## GSTACK REVIEW REPORT\n\n| Review | Trigger | Why | Runs | Status | Findings |\n|--------|---------|-----|------|--------|----------|\n| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | — | — |\n| Outside Review | codex via `/plan-eng-review` (host: claude) | Independent 2nd opinion | 1 | disabled (skipped) | outside_status: disabled, phase: plan-review |\n| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | CLEAR (PLAN) | 9 issues, 0 critical gaps, mode SCOPE_REDUCED |\n| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | — |\n| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | — |\n\n**OUTSIDE COVERAGE:** provider codex, phase plan-review, outside_status disabled by `codex_reviews=disabled` config; no outside process ran and no native fallback was dispatched (disabled is an intentional opt-out). Re-enable with `gstack-config set codex_reviews enabled`.\n\n**VERDICT:** ENG CLEARED — ready to implement. Eng Review status clean at commit ec7383b (0 unresolved, 0 critical gaps).\n\nNO UNRESOLVED DECISIONS" +} diff --git a/test/fixtures/plan-count-crop-ak.json b/test/fixtures/plan-count-crop-ak.json new file mode 100644 index 000000000..269b2f942 --- /dev/null +++ b/test/fixtures/plan-count-crop-ak.json @@ -0,0 +1,43 @@ +{ + "sourceCommit": "04c62ac678bb7bc1a22090f72f7ed51c451c22b9", + "screen": " f2 (~7.6:1) with icon and explicit text | PENDING |\n 82 \n 83 +## Information hierarchy (Pass 1, approved) \n 84 +What the user sees first, second, third on the page: \n 85 +```text \n 86 +1st Account settings (h1) + description orientation: where am I \n 87 +2nd [ Save ] Reset Cancel Export one filled button = the action \n 88 + ^ #1d4ed8 filled, white text ^ neutral ghost buttons \n 89 +3rd InlineStatus (blank / Unsaved changes / Saved at HH:mm) \n 90 +4th Profile (h2) \u2192 Display name, Email \n 91 +5th Notifications (h2) \u2192 Weekly digest, Product tips \n 92 +``` \n 93 +Constraint check: four header actions stay (Export is required by the accepted \n 94 +behavior), but only one carries fill. Ghost treatment on Reset/Cancel/Export \n 95 +keeps them findable without competing with Save. \n 96 + \n 97 ## Implementation Tasks\n 84 -_Populated only with individually approved remedies. Nothing approved yet._ \n 98 +Synthesized from this review's findings. Each task derives from a specific \n 99 +finding above. Run with Claude Code or Codex; checkbox as you ship. \n 100 \n 101 +- [ ] **T1 (P1, human: ~1h / CC: ~5min)** \u2014 Header action Buttons \u2014 Render Save with the existing filled primary v\n +ariant (#1d4ed8, white text); render Reset, Cancel and Export with the existing neutral ghost variant. \n 102 + - Surfaced by: Pass 1 Information Architecture \u2014 Issue 1 (D2 \u2192 1A), \"Save is rendered with the same size, weight\n +, and color as three other buttons\" \n 103 + - Files: settings page header action group; Button variant usage (no new tokens) \n 104 + - Verify: visual check at 1280px and 320px shows exactly one filled button; focus ring still 2px #1d4ed8 offset \n +2px on all four; disabled/pending states keep the variant \n 105 + \n 106 ## NOT in scope\n 107 _To be written after the review passes._\n\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\n Do you want to make this edit to gstack-test-plan-design.md?\n \u276f 1. Yes\n 2. Yes, and switch to accept edits (auto-approve file edits and common file commands) for this session; Yes, and\n always allow access to /tmp/gstack-paid-shard-m7O6cU/tmp/gstack-e2e-plan-design-B4lVyk for this session\n 3. Nohift+tab)\n\n Esc to cancel \u00b7 Tab to amend\n", + "hook": { + "cwd": "/tmp/gstack-paid-shard-m7O6cU/tmp/gstack-plan-count-0rEMmZ", + "expected": "/tmp/gstack-paid-shard-m7O6cU/tmp/gstack-e2e-plan-design-B4lVyk/gstack-test-plan-design.md", + "sessionId": "ca736f12-33d6-4ee1-831b-5924db71bf82", + "transcriptPath": "/tmp/gstack-paid-shard-m7O6cU/tmp/gstack-hermetic-1325936-1eazEZ/with-skills/.claude/projects/-tmp-gstack-paid-shard-m7O6cU-tmp-gstack-plan-count-0rEMmZ/ca736f12-33d6-4ee1-831b-5924db71bf82.jsonl", + "seenIds": [ + "ca736f12-33d6-4ee1-831b-5924db71bf82:toolu_01JqWqNxJZtP7tKSd1viyqzo", + "ca736f12-33d6-4ee1-831b-5924db71bf82:toolu_014NkKMqRz1245RDiWFVMm4J", + "ca736f12-33d6-4ee1-831b-5924db71bf82:toolu_01APPQpBXrbJRqrQwHR5p5mR" + ], + "pendingId": "ca736f12-33d6-4ee1-831b-5924db71bf82:toolu_01APPQpBXrbJRqrQwHR5p5mR", + "completedId": "ca736f12-33d6-4ee1-831b-5924db71bf82:toolu_014NkKMqRz1245RDiWFVMm4J", + "completedIds": [ + "ca736f12-33d6-4ee1-831b-5924db71bf82:toolu_01JqWqNxJZtP7tKSd1viyqzo", + "ca736f12-33d6-4ee1-831b-5924db71bf82:toolu_014NkKMqRz1245RDiWFVMm4J" + ], + "timestamp": "2026-09-10T06:42:26.280Z" + }, + "cwd": "/tmp/gstack-paid-shard-m7O6cU/tmp/gstack-plan-count-0rEMmZ", + "config": "/tmp/gstack-paid-shard-m7O6cU/tmp/gstack-hermetic-1325936-1eazEZ/with-skills/.claude", + "startedAt": 1789022312505, + "transcript": { + "status": "ready", + "calls": [], + "assistantMessages": [ + { + "sessionId": "ca736f12-33d6-4ee1-831b-5924db71bf82", + "text": "No prior learnings found, so I'll start with the review passes. Pass 1 (Information Architecture) scores 6/10: the DOM order is solid, but the four header buttons all render with equal visual weight, giving no clear hierarchy for finding Save first\u2014violating Design Principle 2 and Krug's satisficing rule.", + "timestamp": "2026-09-10T06:41:51.810Z" + } + ] + }, + "ownedBefore": "# Plan: Settings Page UI redesign (design-reviewed)\n\n## Context\nThis updates the existing account-settings form to match the checked-in\nDESIGN.md. The shell, components (Button, Field, InlineStatus, ErrorSummary,\nConfirmationDialog), interaction states, accessibility contract and responsive\nbehavior already exist and are accepted. The work is closing five documented\nvisual inconsistencies between the current form and the approved design system,\nwithout visual exploration or component replacement.\n\nSource plan: `PLAN.md`. Design system: `DESIGN.md`. Review: `/plan-design-review`\n(text-only, native voice only, per user request).\n\n## Existing product and accepted behavior (inherited, unchanged)\n- Single-column settings shell, 640px max width: persistent app navigation,\n page title and description, action group, InlineStatus, Profile and\n Notifications fieldsets. Reuse existing Button, Field, InlineStatus,\n ErrorSummary and ConfirmationDialog; no new component family.\n- Description stays verbatim: \u201cManage your display name, email address, and\n notification preferences.\u201d\n- \u201cAccount settings\u201d is h1. Profile and Notifications are h2 headings labelling\n their fieldsets via aria-labelledby; no heading level skipped.\n- DOM and visual order:\n ```text\n Persistent app navigation\n main: Account settings (h1) + description\n Save | Reset | Cancel | Export\n InlineStatus\n Profile (h2): Display name, Email\n Notifications (h2): Weekly digest, Product tips\n ```\n- Profile: Display name (text), Email (email). Notifications: Weekly digest and\n Product tips switches. New-account defaults: account name/email, Weekly digest\n on, Product tips off. Labels and order fixed. Initial load uses the existing\n form skeleton; read failure shows Retry (aria-label \u201cRetry loading\u201d).\n- InlineStatus: role=status, aria-live=polite, aria-atomic=true. After success:\n \u201cSaved at HH:mm\u201d (local 24-hour, existing formatter). Editing away from saved\n values: \u201cUnsaved changes\u201d (text, never color alone). Reverting all edits or\n confirming Reset restores the last save timestamp. Before first successful\n save: blank status; editing shows \u201cUnsaved changes\u201d; revert/Reset restores\n blank. Failed saves keep \u201cUnsaved changes\u201d beside the error.\n- Save is atomic. Validation errors appear beside fields and in the linked\n ErrorSummary (aria-describedby); focus the first invalid field. Network\n failure preserves edits and shows Retry in the status area. Retry is a\n sibling button outside the live region; visible text \u201cRetry\u201d, aria-label\n \u201cRetry save\u201d / \u201cRetry export\u201d.\n- Pending rules: repeat Save disabled while pending. Save and Export mutually\n exclusive; Reset and Cancel disabled while either is pending; all four\n re-enable on settle. Export uses the existing inline spinner beside\n \u201cExporting\u2026\u201d inside its disabled button, aria-busy=true, reduced-motion\n aware. After Save finishes, Export downloads the latest successfully saved\n preferences as JSON. Export failure uses the same error/retry area, keeps\n unsaved fields; Retry repeats Export; success clears only the Export error.\n- Reset restores saved values after confirmation. Dialog: \u201cDiscard unsaved\n changes?\u201d / \u201cYour saved preferences will be restored.\u201d Actions \u201cKeep editing\u201d\n (default) and \u201cDiscard changes\u201d. Cancel navigation uses the same dialog with\n \u201cKeep editing\u201d (default) and \u201cDiscard and leave\u201d. Dialogs trap focus; Escape\n cancels; closing while staying returns focus to the Reset/Cancel trigger;\n confirmed navigation uses the existing destination-main-heading focus.\n- Responsive: above 640px header actions in one row. At 640px and below:\n full-width Save first, then Reset/Cancel/Export in one equal-column row,\n DOM/tab order preserved, fits 320px with 44px targets, no horizontal scroll.\n- A11y: 44px targets everywhere; visible focus-visible = existing 2px solid\n #1d4ed8 outline, 2px offset on white (measured contrast above 3:1) on every\n control and dialog action; semantic fieldsets/labels/main landmark; respect\n reduced motion; Export remains a clearly labelled button.\n- Font family stays the existing system-ui, sans-serif, inherited by form\n controls (explicit scope decision in PLAN.md; not reopened by this review).\n\n## Design gaps under review (PENDING \u2014 no remedy approved yet)\nEach gap below is a documented violation of DESIGN.md. The proposed remedy is a\nrecommendation, not a decision. Each needs its own approval before it moves\ninto Implementation Tasks.\n\n| # | Gap (current form) | Proposed remedy (DESIGN.md) | Status |\n|---|--------------------|-----------------------------|--------|\n| 1 | Visual hierarchy: Save renders identically to Reset/Cancel/Export; no visible primary action | Save is the only filled primary button (#1d4ed8, white text); Reset/Cancel/Export are neutral ghost buttons | APPROVED (D2 \u2192 1A) |\n| 2 | Motion: Save takes 2-5s with no loading indicator; page looks frozen | Existing pending pattern: inline spinner beside \u201cSaving\u2026\u201d inside the disabled Save button, aria-busy=true, reduced-motion aware | PENDING |\n| 3 | Typography: form labels use 14px, 16px and 18px | Two roles only: 16px body/labels/helper text, 20px h2 section headings | PENDING |\n| 4 | Spacing: section gaps are 16px, 24px and 32px with no rhythm | 8px base scale: sections 32px, field groups 24px, label-to-input 8px | PENDING |\n| 5 | Color: error message is red on light pink at ~3:1, below WCAG AA | error.text #991b1b on error.surface #fef2f2 (~7.6:1) with icon and explicit text | PENDING |\n\n## Implementation Tasks\n_Populated only with individually approved remedies. Nothing approved yet._\n\n## NOT in scope\n_To be written after the review passes._\n\n## What already exists\n_To be written after the review passes._\n", + "provenance": { + "permissionReplaySHA": "89687a6694f5b33e2c9a2a5d4e6a5de031c658ec1850e708019d791e6c15b632", + "publicToolsProofSHA": "2c8b892a108f26ca0d05ec6a96714631e2e83fe309817c2d6c374444157b3157", + "diskFileRetained": false, + "limitation": "File bytes reconstructed only from the two acknowledged public Write calls after the natural timeout removed the fixture. No direct retained disk-file comparison." + } +} diff --git a/test/fixtures/plan-count-quoted-frame-ak.json b/test/fixtures/plan-count-quoted-frame-ak.json new file mode 100644 index 000000000..20d2cff7d --- /dev/null +++ b/test/fixtures/plan-count-quoted-frame-ak.json @@ -0,0 +1,10 @@ +{ + "screen": " f2 (~7.6:1) with icon and explicit text | PENDING |\n 82 \n 83 +## Information hierarchy (Pass 1, approved) \n 84 +What the user sees first, second, third on the page: \n 85 +```text \n 86 +1st Account settings (h1) + description orientation: where am I \n 87 +2nd [ Save ] Reset Cancel Export one filled button = the action \n 88 + ^ #1d4ed8 filled, white text ^ neutral ghost buttons \n 89 +3rd InlineStatus (blank / Unsaved changes / Saved at HH:mm) \n 90 +4th Profile (h2) \u2192 Display name, Email \n 91 +5th Notifications (h2) \u2192 Weekly digest, Product tips \n 92 +``` \n 93 +Constraint check: four header actions stay (Export is required by the accepted \n 94 +behavior), but only one carries fill. Ghost treatment on Reset/Cancel/Export \n 95 +keeps them findable without competing with Save. \n 96 + \n 97 ## Implementation Tasks\n 84 -_Populated only with individually approved remedies. Nothing approved yet._ \n 98 +Synthesized from this review's findings. Each task derives from a specific \n 99 +finding above. Run with Claude Code or Codex; checkbox as you ship. \n 100 \n 101 +- [ ] **T1 (P1, human: ~1h / CC: ~5min)** \u2014 Header action Buttons \u2014 Render Save with the existing filled primary v\n +ariant (#1d4ed8, white text); render Reset, Cancel and Export with the existing neutral ghost variant. \n 102 + - Surfaced by: Pass 1 Information Architecture \u2014 Issue 1 (D2 \u2192 1A), \"Save is rendered with the same size, weight\n +, and color as three other buttons\" \n 103 + - Files: settings page header action group; Button variant usage (no new tokens) \n 104 + - Verify: visual check at 1280px and 320px shows exactly one filled button; focus ring still 2px #1d4ed8 offset \n +2px on all four; disabled/pending states keep the variant \n 105 + \n 106 ## NOT in scope\n 107 _To be written after the review passes._\n\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\n Do you want to make this edit to gstack-test-plan-design.md?\n \u276f 1. Yes\n 2. Yes, and switch to accept edits (auto-approve file edits and common file commands) for this session; Yes, and\n always allow access to /tmp/gstack-paid-shard-m7O6cU/tmp/gstack-e2e-plan-design-B4lVyk for this session\n 3. Nohift+tab)\n\n Esc to cancel \u00b7 Tab to amend\n", + "provenance": { + "source": "AK Design permission capture; every row is quoted only by the test transform", + "originalReplaySHA": "89687a6694f5b33e2c9a2a5d4e6a5de031c658ec1850e708019d791e6c15b632", + "baselineRootControl": ".context/plan-count-crop-ak-root-peer-baseline-controls-v1.json", + "baselineActual": "70d862", + "noNewNativeCompletionClaim": true + } +} diff --git a/test/helpers/autoplan-artifact-permission.ts b/test/helpers/autoplan-artifact-permission.ts index 46f25816b..073724d9a 100644 --- a/test/helpers/autoplan-artifact-permission.ts +++ b/test/helpers/autoplan-artifact-permission.ts @@ -20,6 +20,24 @@ const compact = (text: string) => text.replace(/\s/g, ''); /** The native header may remain above the diff; both displayed paths must bind. */ function ownedEditDiffRows(rows: string[], file: string, ownedStateRoot?: string): string[] | null { + // A redraw can repeat the same native tool title above one current panel. + // Those homogeneous titles supply no authority: the full panel below must + // still bind its path, current request, content, and exact one-time menu. + const repeated: string[] = []; + let panelAt = 0; + for (; panelAt < rows.length; panelAt++) { + if (!rows[panelAt]!.trim()) continue; + const title = /^[●⏺] Update\(([^\n]+)\)$/.exec(rows[panelAt]!); + if (!title) break; + repeated.push(title[1]!); + } + if (repeated.length > 1 && ownedStateRoot && /^[─╌]{8,}$/.test(rows[panelAt] ?? '') && + rows[panelAt + 1]?.trim() === 'Edit file') { + const relative = path.relative(ownedStateRoot, file).split(path.sep).join('/'); + const alias = path.basename(ownedStateRoot) === '.gstack' ? `~/.gstack/${relative}` : undefined; + if (repeated.some(title => title !== repeated[0]) || (repeated[0] !== file && repeated[0] !== alias)) return null; + rows = rows.slice(panelAt); + } const headers = rows.flatMap((row, index) => /^[●⏺] Update\(/.test(row) ? [index] : []); if (headers.length > 1) return null; const header = headers[0] ?? 0; diff --git a/test/helpers/claude-pty-runner.ts b/test/helpers/claude-pty-runner.ts index 6f5b31753..ce164855f 100644 --- a/test/helpers/claude-pty-runner.ts +++ b/test/helpers/claude-pty-runner.ts @@ -1545,6 +1545,10 @@ export function createPlanCountPermissionGuard(): (visible: string, completionHi const candidate = planCountPermissionMenu(visible); if (!candidate) return null; const { normalized, cursorAt, prompt, menu } = candidate; + // A whole quoted pane is source text, even if it contains a native cursor. + // Returning handled also suppresses the dispatcher's default permission input. + const rows = stripVTControlCharacters(normalized).split('\n').filter(row => row.trim()); + if (rows.length && rows.every(row => /^[\t ]*>/.test(row))) return 'handled'; // File permission identity comes from the action or native approval // choices, not a brittle exact rendering of "Do you want to ...". // Other permission kinds retain the existing caller policy. @@ -2218,6 +2222,12 @@ function ceoNumberedBriefDecision(q: NativePlanQuestionCall['questions'][number] if (prose.split(/[.!?;]\s+|\n/).some(clause => /^(?:there\s+(?:is|are)\s+no\s+(?:current\s+)?|no\s+current\s+)(?:defect|gap|issue|problem)s?\b/i.test(clause.trim()))) return false; const findingIdentity = /\b(?:Finding|Issue)\s+F?([1-9]\d*(?:\.[1-9]\d*)*)\b/i.exec(prose.split('\n')[0]!); + // A decision counter is separate from its issue number. Numbered choices + // and the recommendation must belong to that issue (or dotted section). + const optionTokens = q.options.map(option => label(option.label)); + if (optionTokens.some(token => !token || (token[1] ?? '') !== (recommendation[1] ?? '')) || + new Set(optionTokens.map(token => token![2]!.toUpperCase())).size !== optionTokens.length || + (findingIdentity && recommendation[1] && recommendation[1] !== findingIdentity[1]!.split('.')[0])) return false; if (findingIdentity && new RegExp('\\b(?:(?:Finding|Issue)\\s+F?|F)' + findingIdentity[1]!.replace(/\./g, '\\.') + '\\s+(?:is|was|has been)\\s+(?:withdrawn|rejected|retracted|resolved)\\b', 'i').test(prose)) return false; if ([subject, explanation].some(text => /\b(?:gap|defect|issue|problem)\b[^.!?]{0,80}\b(?:already|now)\s+(?:resolved|fixed|closed)\b/i.test(publicText(text)))) return false; // A complete assessment can withdraw a historical problem in a later @@ -2231,12 +2241,18 @@ function ceoNumberedBriefDecision(q: NativePlanQuestionCall['questions'][number] // A numbered test may state its assertion gap through the regression it // cannot reject, without using the word "missing" or a question mark. const assertionGap = /^test\s+[1-9]\d*(?:\s+\([^()\n]*\))?\s+(?:cannot\s+(?:detect|catch|reject)\b[^.!?\n]*\bregressions|accepts\s+any\s+truthy\s+value)\b/i.test(statement.trim()) && ceoAssertionMismatchBrief(q); + // This clause asserts the current plan's behavior. A source prefix, + // negated failure, or historical/example qualification cannot supply it. + const escapingMailFailure = /^(?:(?:today|currently|now)[,:]?\s+)?(?:(?:the|this|current)\s+)?(?:plan|handler|implementation)\s+(?:lets?|allows?)\s+(?:(?:any|a|an|the)\s+)?(?:mail|email|notification)\s+failures?(?:\s*\([^()\n]*\))?\s+(?:to\s+)?escape\b/i.test(statement.trim()) && + !/^(?:source|previously|formerly)\b/i.test(openingAssessment) && + !/\b(?:if|unless|whether|hypothetical|historical|quoted|example|template|previously|formerly)\b|\bsource\s+(?:excerpt|material|text)\b/i.test(statement); return !/^(?:if|unless|whether|example|template|hypothetical|quoted)\b|\b(?:already resolved|no (?:current )?(?:defect|gap|issue|problem)s?\b|not true)\b/i.test(statement.trim()) && !/\b(?:not|never|no longer|isn't)\s+(?:missing|unspecified|unvalidated|unhandled)\b/i.test(statement) && !/\b(?:not|never|no longer|doesn't|does not)\s+(?:asserts?|checks?)\s+only\b/i.test(statement) && !/\b(?:was|were)\s+(?:missing|unspecified|unvalidated|unhandled)\b/i.test(statement) && !/\b(?:not|never|no longer|doesn't|does not)\s+(?:paste|send|deliver|receive)\b/i.test(statement) && - (assertionGap || /\b(?:missing|unspecified|unvalidated|unhandled)\b|\b(?:(?:has|with|leaves)\s+no|without)\s+(?:(?:automated|explicit|defined)\s+)?(?:error handling|tests?|checks?|validation|coordination|cap|bound|timeout)\b|\b(?:asserts?|checks?)\s+only\b|\b(?:does not|doesn't|never)\s+(?:say|says|state|define|specify|cover|handle)\b|\bpastes?\b[^.!?]*\bstraight into (?:a )?SQL\b|\b(?:gets?|sends?|delivers?|receives?)\b[^.!?]*\btwice\b|\b(?:proves?|checks?|tests?|covers?)\s+(?:the\s+)?happy path\s+and\s+nothing else\b/i.test(statement)); + !/\b(?:not|never|no longer|doesn't|does not|used to|previously|formerly)\s+(?:interpolates?|reads?|fetch(?:es)?|loads?|quer(?:y|ies))\b/i.test(statement) && + (assertionGap || /\b(?:missing|unspecified|unvalidated|unhandled)\b|\b(?:(?:has|with|leaves)\s+no|without)\s+(?:(?:automated|explicit|defined)\s+)?(?:error handling|tests?|checks?|validation|coordination|cap|bound|timeout)\b|\b(?:asserts?|checks?)\s+only\b|\b(?:does not|doesn't|never)\s+(?:say|says|state|define|specify|cover|handle)\b|\bpastes?\b[^.!?]*\bstraight into (?:a )?SQL\b|\b(?:gets?|sends?|delivers?|receives?)\b[^.!?]*\btwice\b|\b(?:proves?|checks?|tests?|covers?)\s+(?:the\s+)?happy path\s+and\s+nothing else\b|\binterpolates?\b[^!?]*\b(?:raw\s+)?SQL\s+(?:fragment|string)\b|\bno\s+(?:automated\s+)?tests?\s+(?:are\s+)?planned\b|\b(?:fetch(?:es)?|reads?|loads?|queries)\b[^!?]*\bN\+1\b/i.test(statement) || escapingMailFailure); }); const amendment = q.options.some(option => { const token = label(option.label); @@ -2250,12 +2266,21 @@ function ceoNumberedBriefDecision(q: NativePlanQuestionCall['questions'][number] if (optionRows.length > 1) return false; const boundedConfiguration = /\b(?:no|without)\s+(?:cap|bound|timeout)\b/i.test(explanation) && /^explicit\b[^.!?]*\b(?:timeout|budget|cap|bound)\b/i.test(optionLabel); - const completeTestSuite = /\b(?:no|without)\s+(?:automated\s+)?tests?\b/i.test(explanation) && - /^full\s+(?:test\s+)?(?:matrix|table|suite)\b[^.!?]*\b(?:unit|integration|ordering|tests?)\b/i.test(optionLabel); - return boundedConfiguration || completeTestSuite || [optionLabel, option.description ?? '', optionRows[0]?.[1] ?? ''].some(text => - publicText(text).split(/[.;]\s+/).some(clause => + const completeTestSuite = /\b(?:no|without)\s+(?:automated\s+)?tests?\b/i.test(`${subject} ${explanation}`) && + /^(?:full\s+(?:test\s+)?(?:matrix|table|suite)\b[^.!?]*\b(?:unit|integration|ordering|tests?)\b|full\s+unit\s*(?:\+|and)\s*integration\s+suite\b)/i.test(optionLabel); + return boundedConfiguration || completeTestSuite || [optionLabel, option.description ?? '', optionRows[0]?.[1] ?? ''].some(text => { + // Effort estimates are display text. A positive option bullet can own + // an action; a drawback bullet and its continuation cannot supply one. + // Preserve a source or conditional introduction before the first bullet. + const actionText = publicText(text); + if (actionText.split(/[✅❌]/, 1)[0]!.split(/[.;]\s+/).some(clause => + /^(?:if|unless|whether|suppose|imagine|example|template|hypothetical|historical|quoted|source)\b/i.test(clause.trim()) || + /\b(?:is|was|presents?|represents?)\s+(?:(?:only|just)\s+)?(?:an?\s+)?(?:quoted|hypothetical|historical|example|template)\b/i.test(clause))) return false; + return actionText.split(/(?=[✅❌])/).filter(part => !/^\s*❌/.test(part)) + .flatMap(part => part.replace(/^\s*✅\s*/, '').split(/[.;]\s+/)).some(clause => /^(?:add|remove|replace|send|rescue|handle|validate|check|assert|pin|deep-equal|require|define|specify|guard|serialize|parameterize|escape|use|implement|write)\b/i.test(clause.trim()) && - !/^[a-z-]+\s+(?:(?:the|a|this|prior|previous|completed|reviewed|current|saved|stored|exact|expected|full|complete|whole)\s+)*(?:review\s+)?(?:plan|report|summary|note|record|document|log)\b/i.test(clause.trim()))); + !/^[a-z-]+\s+(?:(?:the|a|this|prior|previous|completed|reviewed|current|saved|stored|exact|expected|full|complete|whole)\s+)*(?:review\s+)?(?:plan|report|summary|note|record|document|log)\b/i.test(clause.trim())); + }); }); return currentProblem && amendment; } @@ -2263,17 +2288,19 @@ function ceoNumberedBriefDecision(q: NativePlanQuestionCall['questions'][number] /** A descriptive menu header can accompany a fully numbered issue brief. */ function ceoParenthesizedIssueBrief(q: NativePlanQuestionCall['questions'][number], number: string): boolean { const title = q.question.split('\n')[0]!; - if (!/^D[1-9]\d*\s+\(Issue [1-9]\d*(?:\.[1-9]\d*)*\)\s*[—–-]\s*(?:What|How|Which|Should)\b[^\n?]+\?$/i.test(title) || + const finding = /^D[1-9]\d*\s+\(Finding\s/i.test(title); + if (!/^D[1-9]\d*\s+\((?:Issue|Finding) [1-9]\d*(?:\.[1-9]\d*)*\)\s*[—–-]\s*(?:What|How|Which|Should)\b[^\n?]+\?$/i.test(title) || /\b(?:hypothetical|example|template)\b/i.test(title)) return false; const prose = q.question .replace(/```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)/g, '') .replace(/^(?:\s*>| {4}|\t).*$/gm, '') .replace(/"(?:[^"\\]|\\.)*"|“[^”]*”|`[^`]*`/g, '""'); const explanations = [...prose.matchAll(/^ELI10:\s*(.+)$/gm)]; - const recommendations = [...prose.matchAll(/^Recommendation:\s*([1-9]\d*)([A-Z])\b/gim)]; + const recommendations = [...prose.matchAll(/^Recommendation:\s*([1-9]\d*)?([A-Z])\b/gim)]; const section = number.split('.')[0]!; + const optionPrefix = recommendations[0]?.[1] ?? ''; if (explanations.length !== 1 || recommendations.length !== 1 || - recommendations[0]![1] !== section || !/\w/.test(explanations[0]![1]!) || + (optionPrefix ? optionPrefix !== section : !finding) || !/\w/.test(explanations[0]![1]!) || /^(?:if|unless|whether|example|template|hypothetical|historical|quoted)\b/i.test(explanations[0]![1]!.trim())) return false; // Current prose may explicitly withdraw an earlier issue. Literal examples // and attributed quotations cannot supply either the brief or its withdrawal. @@ -2287,13 +2314,13 @@ function ceoParenthesizedIssueBrief(q: NativePlanQuestionCall['questions'][numbe .replace(/^Completeness\s+\d+\/10\.\s*/i, '').split(/[.;]\s+/).some(clause => /^(?:add|remove|replace|send|rescue|handle|validate|check|assert|pin|require|define|specify|guard|serialize|parameterize|use|implement|write)\b/i.test(clause.trim()) && !/^[a-z]+\s+(?:(?:the|a|this|prior|previous|completed|reviewed|current|saved|stored)\s+)*(?:review\s+)?(?:plan|report|summary|note|record|document|log)\b/i.test(clause.trim())))); - if (number.includes('.') ? !ceoNumberedBriefDecision(q, title.replace(/^D[1-9]\d*\s+\(Issue [^)]+\)\s*[—–-]\s*/i, ''), true) : !missingContract || !amendment) return false; + if (finding || number.includes('.') ? !ceoNumberedBriefDecision(q, title.replace(/^D[1-9]\d*\s+\((?:Issue|Finding) [^)]+\)\s*[—–-]\s*/i, ''), true) : !missingContract || !amendment) return false; const headerNumber = /^(?:(?:Finding|Issue)\s+F?|F)([1-9]\d*(?:\.[1-9]\d*)*)(?:\s+[a-z][a-z -]*)?$/i.exec(q.header.trim()); if (/^(?:finding|issue)\b|^f\d/i.test(q.header.trim()) && !headerNumber) return false; if (headerNumber && headerNumber[1] !== number) return false; - const labels = q.options.map(option => /^([1-9]\d*)([A-Z])[):.]\s*\S/i.exec(option.label)); + const labels = q.options.map(option => /^([1-9]\d*)?([A-Z])[):.]\s*\S/i.exec(option.label)); return q.options.length >= 2 && q.options.every((option, i) => - Boolean(option.description?.trim()) && labels[i]?.[1] === section) && + Boolean(option.description?.trim()) && (labels[i]?.[1] ?? '') === optionPrefix) && new Set(labels.map(label => label![2]!.toUpperCase())).size === labels.length && labels.some(label => label![2]!.toUpperCase() === recommendations[0]![2]!.toUpperCase()); } @@ -2383,8 +2410,8 @@ function nativeExplicitCeoFinding(fp: AskUserQuestionFingerprint, allowQuestionI new Set(labels.map(token => token![2]!.toUpperCase())).size !== labels.length) return false; } const numberedSubject = /^([1-9]\d*(?:\.[1-9]\d*)+)\s+([^:\n]+):\s*([^\n]+)$/.exec(normalized); - const parenthesized = /^D[1-9]\d*\s+\(issue\s+([1-9]\d*(?:\.[1-9]\d*)*)\)\s*[—–-]\s*[^\n?]+\?$/i.exec(title); - if (parenthesized && (allowQuestionId || (parenthesized[1]!.includes('.') && + const parenthesized = /^D[1-9]\d*\s+\((?:issue|finding)\s+([1-9]\d*(?:\.[1-9]\d*)*)\)\s*[—–-]\s*[^\n?]+\?$/i.exec(title); + if (parenthesized && (allowQuestionId || /^D[1-9]\d*\s+\(Finding\s/i.test(title) || (parenthesized[1]!.includes('.') && !/^(?:(?:Finding|Issue)\s+F?|F)[1-9]\d*/i.test(q.header.trim())))) return ceoParenthesizedIssueBrief(q, parenthesized[1]!); if (identity && !/^[^\n?]+\?$/.test(identity[3]!) && !ceoNumberedBriefDecision(q, identity[3]!)) return false; if (numberedSubject && (numberedSubject[2]!.trim().toLowerCase() !== q.header.trim().toLowerCase() || @@ -2413,7 +2440,7 @@ export const ceoFirstReviewAUQ: Step0BoundaryPredicate = (fp) => const id = / /^(?:[1-9]\d*[A-Z](?:[).]\s*|\s+))?(?:Defer|Leave|Keep|Accept the gap)\b/i.test(o.label)); + const opposed = q.options.filter(o => /^(?:[1-9]\d*[A-Z](?:[).:]\s*|\s+))?(?:Defer|Leave|Keep|Accept the gap)\b/i.test(o.label)); const repair = /\b(?:fix|resolve|address)\b/i.test(title) && q.options.some(o => /\b(?:closing|closes|fixes|resolves?|applies?)\b/i.test(o.description ?? '')); // A source citation alone can describe a report or the next reviewer. @@ -86,22 +86,52 @@ function ordinaryDesignIssue(fp: AskUserQuestionFingerprint): boolean { '[A-Za-z][A-Za-z0-9 /,_-]{0,99} become neutral ghost buttons exactly as DESIGN\\.md specifies\\b', 'i'), new RegExp(`^(?:✅\\s*)?${primary[1]} is the single filled #[0-9a-f]{6} button; ` + '[A-Za-z][A-Za-z0-9 /,_-]{0,99} become neutral ghosts, exactly per DESIGN\\.md\\b', 'i'), + new RegExp(`^(?:✅\\s*)?Apply DESIGN\\.md tokens: ${primary[1]} #[0-9a-f]{6} filled with (?:white|black) text; ` + + '[A-Za-z][A-Za-z0-9 ,/_-]{0,99} neutral ghost(?: buttons)?\\.', 'i'), ]; const primaryHeader = !q.header.includes(':') || q.header.split(':')[1]!.trim().toLowerCase() === primary?.[1]?.toLowerCase(); // The style wordings share one owned decision: a current equal-weight gap, // a named control's DESIGN.md amendment, and a different choice retaining it. - const assessment = /^ELI10: (.+)$/m.exec(q.question)?.[1] ?? ''; - const currentPrimary = primary && new RegExp(`^(?:Right now|Today) ${primary[1]}(?:, [A-Za-z][A-Za-z0-9 _-]{0,39})+(?:,? and [A-Za-z][A-Za-z0-9 _-]{0,39})? (?:all )?look (?:the same|identical)\\b`, 'i').test(assessment); + const currentText = (text: string) => text + .replace(/```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)/g, '') + .replace(/^(?:\s*>| {4}|\t).*$/gm, '') + .replace(/`([^`]+)`/g, (_, body: string) => /\s/.test(body) ? '' : body) + .replace(/"[^"\n]*"|“[^”\n]*”/g, '').replace(/\*\*/g, ''); + const questionText = currentText(q.question); + const assessments = [...questionText.matchAll(/^ELI10: (.+)$/gm)]; + const prefix = questionText.slice(0, assessments[0]?.index ?? 0) + .split('\n').filter(line => line.trim()).slice(1); + const sourceAssessment = /\b(?:historical|hypothetical|quoted|source)\s+(?:example|excerpt|assessment|material|text)\b|\bnot\s+(?:the\s+)?current\s+(?:UI|assessment|finding)\b/i; + const assessment = assessments.length === 1 && + prefix.every(line => /^(?:Project\/branch\/task:|\[P[0-3]\])/.test(line)) && + !sourceAssessment.test(prefix.join(' ')) && !sourceAssessment.test(assessments[0]![1]!) + ? assessments[0]![1]! : ''; + const currentPrimary = primary && new RegExp(`^(?:Right now|Today) ${primary[1]}(?:, [A-Za-z][A-Za-z0-9 _-]{0,39})+(?:,? and [A-Za-z][A-Za-z0-9 _-]{0,39})? (?:(?:all )?look (?:the same|identical)|are (?:all )?(?:(?:two|three|four|five|six|seven|eight|nine|ten|[1-9]\\d*) )?identical buttons)\\b`, 'i').test(assessment); const withdrawn = new RegExp(`(?:^|[.!?]\\s+|\\n)(?:Correction:\\s*)?(?:(?:This (?:issue|finding|question)|Issue ${issue[1]}) (?:is|was|has been) (?:withdrawn|resolved|closed|hypothetical)|We have (?:resolved|closed|withdrawn) this (?:issue|finding)|No current (?:issue|finding|gap|violation) (?:remains|exists))\\b`, 'i'); + const closedGap = /(?:^|[.!?;]\s+|\n)(?:Correction:\s*)?(?:this|the|that) (?:gap|violation) (?:is|was|has been) (?:already\s+|now\s+)?(?:resolved|fixed|closed)\b/i; + const cancelledStyle = /(?:^|[.!?;]\s+|\n)(?:Correction:\s*)?(?:do not|don't|never|skip|cancel|withdraw)\s+(?:apply|use|add|keep)\s+(?:(?:these|the|this)\s+)?(?:tokens?|styles?|primary treatment)\b/i; const choiceIds = q.options.map(o => /^([1-9]\d*)[A-Z](?:[).:]?\s+)/.exec(o.label)); const primaryRepair = primaryHeader && amendments && currentPrimary && !!call.answeredAt && Number.isFinite(Date.parse(call.answeredAt)) && choiceIds.every(id => id?.[1] === issue[1]) && - !withdrawn.test(q.question) && - q.options.some(amendment => amendments.some(pattern => pattern.test(amendment.description ?? '')) && - !withdrawn.test(amendment.description ?? '') && opposed.some(defer => defer !== amendment && - !withdrawn.test(defer.description ?? '') && - /(?:^|❌\s*)(?:Leaves a documented DESIGN\.md violation in place|Ships the documented violation;[^.\n]*\bthe gap remains open|Primary-action ambiguity ships; documented DESIGN\.md violation remains)\b/i.test(defer.description ?? ''))); + !withdrawn.test(questionText) && !closedGap.test(questionText) && + q.options.some(amendment => { + const body = currentText(amendment.description ?? ''); + const style = amendments.map(pattern => pattern.exec(body)).find(Boolean)?.[0]; + // A named primary cannot simultaneously occur in the ghost-control list. + if (!style || new RegExp(`\\b${primary![1]}\\b`, 'i').test(style.slice(style.indexOf(';') + 1)) || + withdrawn.test(body) || closedGap.test(body) || cancelledStyle.test(body)) return false; + return opposed.some(defer => { + const declined = currentText(defer.description ?? ''); + // Native menus can list current benefits before the gap retained by + // declining. Only consume a complete affirmative pro/con prefix; prose + // framing a source example or a future condition cannot expose an icon. + const pros = /^(?:✅(?!\s*(?:If|When|Unless|Historical|Hypothetical|Quoted|Source|Example)\b)\s*[^✅❌]+)+❌\s*/i.exec(declined); + const remaining = pros && !sourceAssessment.test(pros[0]) ? declined.slice(pros[0].length) : declined; + return defer !== amendment && !withdrawn.test(declined) && !closedGap.test(declined) && + /^(?:❌\s*)?(?:Leaves a documented DESIGN\.md violation in place|Ships the documented violation;[^.\n]*\bthe gap remains open|Primary-action ambiguity ships; documented DESIGN\.md violation remains|Decline the fix; gap stays documented and lowers the score)\b/i.test(remaining); + }); + }); return opposed.length > 0 && !!(repair || primaryRepair); } diff --git a/test/helpers/devex-seed-coverage.ts b/test/helpers/devex-seed-coverage.ts index 0cce58b24..b1baf03a0 100644 --- a/test/helpers/devex-seed-coverage.ts +++ b/test/helpers/devex-seed-coverage.ts @@ -5,6 +5,37 @@ export const DEVEX_SEEDED_GAPS = [ ] as const; export type DevexSeededGap = typeof DEVEX_SEEDED_GAPS[number]; +/** Bind an unnamed signature question to its own first asserted explanation. */ +function explainedReversedSignatures(q: NativePlanQuestion, title: string): boolean { + if (!/^(?:Journey stage [A-Z ]+: )?the two public functions take the same two arguments in (?:opposite|reversed) positional order\. How should (?:the plan|we) (?:fix|align|unify) the signatures\?$/i.test(title)) return false; + const lines = q.question.split('\n'); + if (lines[0]!.trim().replace(/^D\s*\d+\s*[—–:-]\s*/i, '') !== title) return false; + const explanation = lines.findIndex(line => line.startsWith('ELI10: ')); + if (explanation < 1 || lines.slice(1, explanation).filter(line => line.trim()).some(line => + !/^Project\/branch\/task: [^;\n]+; reviewing the public function signatures in [\w./-]+\.$/.test(line) || + /\b(?:quoted|source excerpt|source example|hypothetical|historical|not (?:a )?current|if approved)\b/i.test(line))) return false; + // Inline code may name each signature; a quoted/fenced explanation, earlier + // unrelated sentence, past definition or hypothetical definition cannot. + if (!/^ELI10: [\w./-]+(?: lines? \d+(?:\s*[-–]\s*\d+)?)? define (`?)run_eval\(\s*dataset\s*,\s*evaluator\s*\)\1 and (`?)run_batch\(\s*evaluator\s*,\s*dataset\s*\)\2\./.test(lines[explanation]!)) return false; + const currentProse = (text: string) => { + let fence = false; + return text.split('\n').filter(line => { + if (/^\s*(?:```|~~~)/.test(line)) { fence = !fence; return false; } + return !fence && !/^\s*>/.test(line); + }).join('\n').replace(/`[^`\n]*`|"[^"\n]*"|“[^”\n]*”/g, ''); + }; + const current = currentProse(lines.slice(explanation).join('\n')); + if ((current.match(/^ELI10:/gm)?.length ?? 0) !== 1) return false; + if (/(?:^|[.!?\n]\s*)(?:Correction:\s*)?(?:(?:this|that|the) (?:finding|explanation)|(?:(?:this|that|the) )?argument[- ]order (?:issue|defect)|these signatures)\b[^.\n]*\b(?:withdrawn|rejected|(?:already )?(?:fixed|resolved)|historical|not current)\b/i.test(current) || + /(?:^|[.!?\n]\s*)(?:Correction:\s*)?(?:there is|there's) no argument[- ]order (?:issue|defect)\b/i.test(current) || + /(?:^|[.!?\n]\s*)(?:Correction:\s*)?run_eval and run_batch now (?:use|take) the same positional order\b/i.test(current)) return false; + // The same offered action must align both functions and retain the call-site + // swap guard. Selecting an offered alternate or deferral is still a decision. + return q.options.some(option => /^Same order\s*\+\s*swap guard(?: \(recommended\))?$/i.test(option.label) && + /^(?:✅\s*)?Both (?:become|use|take) `?\(\s*dataset\s*,\s*evaluator\s*\)`?, accept keywords, and raise a call-site `?TypeError`? naming the swapped argument and the fix if types are reversed\./i.test(option.description ?? '') && + !/(?:^|[.!?\n]\s*)(?:Correction:\s*)?(?:(?:do not|don't|never) (?:change|align|unify) (?:either|both|the) signatures?\b|(?:do not|don't|never|skip) (?:add|require|implement) (?:a |the )?swap guard\b|(?:this|the) (?:option|correction|action) is (?:withdrawn|rejected|cancelled)\b)/i.test(currentProse(option.description ?? ''))); +} + /** Identify a dedicated seed decision by its subject and meaningful alternatives. */ function decisionGaps(q: NativePlanQuestion): DevexSeededGap[] { const title = q.question.split('\n')[0]!.trim().replace(/^D\s*\d+\s*[—–:-]\s*/i, '') @@ -29,9 +60,9 @@ function decisionGaps(q: NativePlanQuestion): DevexSeededGap[] { if (/\bquickstart\b|examples\/first_eval\.py/i.test(title) && /\b(?:README|file|example|demo|missing|absent|package|wheel|ship|point)\b|first_eval\.py/i.test(title) && (options.some(o => /\b(?:point|ship|add|demo is)\b/i.test(o) && /\bquickstart\b|first_eval\.py/i.test(o)) || directAction('point|ship|add|replace|fix'))) found.push('missing-quickstart'); - if (/\brun_eval\b/i.test(title) && /\brun_batch\b/i.test(title) && + if (explainedReversedSignatures(q, title) || (/\brun_eval\b/i.test(title) && /\brun_batch\b/i.test(title) && /\b(?:arguments?|order|positional|reversed|opposite|consistent|align|unify|dataset|evaluator)\b/i.test(title) && - (options.some(o => /\b(?:align|unify|standardize|keyword|swap)\b/i.test(o) && /\b(?:order|dataset|arguments?|positional)\b/i.test(o)) || directAction('align|unify|standardize|enforce|make'))) found.push('reversed-arguments'); + (options.some(o => /\b(?:align|unify|standardize|keyword|swap)\b/i.test(o) && /\b(?:order|dataset|arguments?|positional)\b/i.test(o)) || directAction('align|unify|standardize|enforce|make')))) found.push('reversed-arguments'); if (/\bAuthError\b|\binvalid API key\b/i.test(title) && /\b(?:error|message|code|cause|fix|guidance|opaque|explain)\b|request failed/i.test(title) && (options.some(o => /\bcode\b/i.test(o) && /\b(?:cause|fix|link)\b/i.test(o)) || directAction('add|include|explain|replace|report|give'))) found.push('opaque-auth-error'); diff --git a/test/helpers/eng-seeded-coverage.ts b/test/helpers/eng-seeded-coverage.ts index 70f25b56e..622f041a1 100644 --- a/test/helpers/eng-seeded-coverage.ts +++ b/test/helpers/eng-seeded-coverage.ts @@ -84,7 +84,7 @@ function declaredLegacyCharacterization(text: string): boolean { const sections: Array<{ title: string; body: string[]; asserted: boolean }> = []; const owners: Array<{ level: number; asserted: boolean }> = []; let preamble = '', sourcePreamble = false; - const sourceFrame = (body: string) => /\b(?:(?:hypothetical|historical) example|unproven hypothesis|(?:source|quoted) (?:material|text) only|(?:are|is) not requirements? of this plan)\b/i.test(body.replace(/\s+/g, ' ')); + const sourceFrame = (body: string) => /\b(?:(?:hypothetical|historical) example|unproven hypothesis|(?:source|quoted) (?:material|text) only|(?:are|is) not requirements? of this plan)\b/i.test(body.replace(/"[^"\n]*"|“[^”\n]*”/g, '').replace(/\s+/g, ' ')); for (const line of prose(text).split('\n')) { const heading = /^(#{1,6})\s+(.+)$/.exec(line); if (heading) { @@ -186,6 +186,44 @@ function declaredLegacyCharacterization(text: string): boolean { } } } + // A mandatory declaration can require capture before touching the legacy + // function, with a task and ordered verification on both router settings. + const sourceOwner = (body: string) => sourceFrame(body) || + /\b(?:is|was|presents?|represents?)\s+(?:(?:only|just)\s+)?(?:an?\s+)?(?:quoted|hypothetical|historical|example|template)\b/i.test(body); + const conditionalOwner = (prefix: string) => /^(?:if|unless|maybe|perhaps|proposed|optional)\b/i.test(prefix.trim().split('\n').at(-1)?.trim() ?? ''); + for (const section of current.filter(s => /^REGRESSION \(mandatory rule, no approval needed\) [—–-] CRITICAL$/i.test(s.title))) { + const body = unquoted(section.body.join(' ')).replace(/\s+/g, ' ').trim(); + const declaration = /(?:^|[.!?]\s+)Add a characterization (?:test )?suite for legacyAuthFlow\(\) before (?:touching|changing|refactoring) it:\s*(?:capture|pin|record) current inputs and outputs \([^()!?]{1,300}\) and run the same suite against ([A-Za-z][\w]*) on both flag settings\. A behavior difference between paths is a test failure\b/i.exec(body); + if (!declaration || suiteWithdrawn || withdrawn(body) || + !snapshotSource.includes(declaration[0].trim()) || sourceOwner(body.slice(0, declaration.index)) || + /\b(?:if|unless|maybe|might|could|proposed|optional|hypothetical|unproven)\b/i.test(body.slice(0, declaration.index))) continue; + for (const section of current.filter(s => s.title === 'Implementation Tasks')) { + const taskBody = section.body.join('\n').trim(); + const taskPrefix = taskBody.split(/\n(?=-\s)/)[0]?.trim() ?? ''; + if (!taskBody.startsWith('- ') && (!/^Synthesized from (?:this|the) review's findings\./.test(taskPrefix) || + /\b(?:if|unless|optional|hypothetical|example|source|quoted|unproven)\b/i.test(taskPrefix))) continue; + for (const task of taskBody.split(/\n(?=-\s)/)) { + const match = /^- (?:\[[ xX]\] )?(T[1-9]\d*)(?: \([^\n)]*\))? [—–-] [A-Za-z][\w-]*(?:\/[A-Za-z][\w-]*)+ [—–-] CRITICAL characterization suite for legacyAuthFlow\(\), run on both router paths[\t ]*(?:\n|$)/i.exec(task); + const verify = /^\s+- Verify: suite passes on legacy before any refactor; passes on new path before flag enable[\t ]*$/m.exec(task); + const taskIntro = unquoted(taskBody.slice(0, taskBody.indexOf(task))).trim().split('\n').at(-1) ?? ''; + if (!match || !verify || !snapshotSource.includes(task.replace(/\s+/g, ' ').trim()) || + conditionalOwner(taskIntro) || sourceOwner(taskIntro) || conditionalOwner(task.slice(0, verify.index)) || sourceOwner(unquoted(task.slice(0, verify.index))) || + withdrawn(unquoted(task).replace(/\b(?:this|that|the)\s+(?:(?:unchanged-code|baseline)\s+)?verification\b/gi, 'this requirement'), match[1])) continue; + const taskWithdrawal = new RegExp(`\\b${match[1]}\\s+(?:is|was|has been)\\s+(?:cancelled|canceled|withdrawn|rejected|deferred|optional|not required|no longer required)\\b`, 'i'); + if (current.some(s => taskWithdrawal.test(unquoted(s.body.join('\n'))))) continue; + for (const verification of current.filter(s => /^Verification(?: \([^\n]*\))?$/i.test(s.title))) { + const body = unquoted(verification.body.join('\n')).trim(); + const baseline = /^([1-9]\d*)\. Run the characterization suite against legacyAuthFlow\(\) on the unmodified code; it must pass before any refactor lands\.[\t ]*$/m.exec(body); + const compare = new RegExp(`^([1-9]\\d*)\\. Run the characterization suite through ${declaration[1]} with the flag on new; zero differences\\.[\\t ]*$`, 'm').exec(body); + if (baseline && compare && Number(baseline[1]) < Number(compare[1]) && baseline.index < compare.index && + !conditionalOwner(body.slice(0, baseline.index)) && !conditionalOwner(body.slice(0, compare.index)) && + !sourceOwner(body.slice(0, baseline.index)) && !sourceOwner(body.slice(0, compare.index)) && + !withdrawn(body.replace(/\b(?:this|that|the)\s+(?:baseline|verification)\b/gi, 'this requirement'), match[1]) && + snapshotSource.includes(baseline[0]) && snapshotSource.includes(compare[0])) return true; + } + } + } + } return false; } diff --git a/test/helpers/plan-count-file-permission.ts b/test/helpers/plan-count-file-permission.ts index 3eea1a4f3..032af4add 100644 --- a/test/helpers/plan-count-file-permission.ts +++ b/test/helpers/plan-count-file-permission.ts @@ -71,7 +71,7 @@ export function recordFilePermission(input: string, file: string, cwd: string, c } /** A long diff can crop its path header; the native access choice repeats the directory. */ -function croppedEditTarget(screen: string, cwd: string): string | undefined { +function croppedEditTarget(screen: string, cwd: string, expected: string): string | undefined { const text = screen.replace(/\r+\n?/g, '\n'); // Cropping may begin inside a wrapped added/deleted diff row (four/five-space gutter). // Still require numbered rows below and the full native footer; never a quoted AUQ. @@ -81,7 +81,11 @@ function croppedEditTarget(screen: string, cwd: string): string | undefined { const headerPath = header?.[1]?.trim(); const pathOnly = headerPath && (path.isAbsolute(headerPath) || /^\.\.?[/\\]/.test(headerPath)); // A crop can start on the single native rule immediately above the diff. - const diff = pathOnly ? text.slice(header![0].length) : text.replace(/^[╌─━]{3,}[ \t]*\n/, ''); + let diff = pathOnly ? text.slice(header![0].length) : text.replace(/^[╌─━]{3,}[ \t]*\n/, ''); + // A wrapped unchanged row has no +/- marker. Its visible tail must belong + // to the preceding line of the exact current owned file, not arbitrary prose. + const continuation = /^ {6}([^+\-\s][^\n]*)\n(?= {0,3}([1-9]\d*) )/.exec(diff); + if (continuation) diff = diff.slice(continuation[0].length); if (!/^(?:\s*\d+\s+[ +\-]?| {4,5}[+\-])/.test(diff) || /[☐□]|^\s*(?:>|`{3}|~{3})/m.test(text)) return undefined; const prompt = [...text.matchAll(/^ {0,3}Do you want to make this edit to ([^\n?\/\\]+)\?[ \t]*\n([\s\S]*)$/gm)].at(-1); if (!prompt || (text.slice(0, prompt.index).match(/^\s*\d+\s+/gm)?.length ?? 0) < 2) return undefined; @@ -92,6 +96,25 @@ function croppedEditTarget(screen: string, cwd: string): string | undefined { const directory = choices?.[1]?.trim(); if (!directory || !path.isAbsolute(directory)) return undefined; const target = path.join(directory, prompt[1]!.trim()); + if (continuation) { + if (target !== expected) return undefined; + const nextLine = Number(continuation[2]); + if (!Number.isSafeInteger(nextLine) || nextLine < 2) return undefined; + try { + const stat = fs.lstatSync(target); + if (!stat.isFile() || stat.size > MAX_RECORD_BYTES) return undefined; + const fd = fs.openSync(target, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); + try { + const opened = fs.fstatSync(fd); + if (!opened.isFile() || opened.dev !== stat.dev || opened.ino !== stat.ino || opened.size > MAX_RECORD_BYTES) return undefined; + const bytes = Buffer.alloc(MAX_RECORD_BYTES + 1); + const length = fs.readSync(fd, bytes, 0, bytes.length, 0); + if (length !== opened.size || length > MAX_RECORD_BYTES) return undefined; + const prior = bytes.subarray(0, length).toString('utf8').split(/\r?\n/)[nextLine - 2]; + if (!prior?.trimEnd().endsWith(continuation[1]!.trimEnd())) return undefined; + } finally { fs.closeSync(fd); } + } catch { return undefined; } + } return !pathOnly || path.resolve(cwd, headerPath!) === target ? target : undefined; } @@ -101,7 +124,7 @@ export function currentFilePermissionEpoch(file: string | undefined, expected: s screen: string): FilePermissionEpoch | null | undefined { if (!file || !expected || !config) return undefined; const panel = [...screen.matchAll(/(?:^|\n) {0,3}(?:Edit|Write) file[ \t]*\n {0,3}([^\n]+)\n/g)].at(-1); - const target = panel ? path.resolve(cwd,panel[1]!.trim()) : croppedEditTarget(screen, cwd); + const target = panel ? path.resolve(cwd,panel[1]!.trim()) : croppedEditTarget(screen, cwd, expected); if (target !== expected) { // A foreign path with this report's basename cannot fall back to a stale // owned grant. An incomplete owned menu also waits for full path identity. diff --git a/test/helpers/plan-scope-selection.ts b/test/helpers/plan-scope-selection.ts index 6b74e3cd0..c25f3dbba 100644 --- a/test/helpers/plan-scope-selection.ts +++ b/test/helpers/plan-scope-selection.ts @@ -67,7 +67,7 @@ export function nativeSeededPlanSelection( if (!line || /^(?: {4}|\t)/.test(line)) continue; const text = line.trim(); const selectedNow = /^(?:I've|I have) selected (?:reviewing|to review)\s+(?:the\s+)?pasted\s+(?:"([^"\n]+)"|“([^”\n]+)”|`([^`\n]+)`)\s+(?:draft(?:\s+plan)?|plan)(.*)$/i.exec(text); - const selected = selectedNow ?? /^(?:I'll review|I will review|I'll go with reviewing|I will go with reviewing|I'm proceeding with reviewing|I am proceeding with reviewing)\s+(?:the\s+)?(?:"([^"\n]+)"|“([^”\n]+)”|`([^`\n]+)`)\s+(?:draft(?:\s+plan)?|plan)(?:\s+(?:you pasted|pasted here))?(.*)$/i.exec(text); + const selected = selectedNow ?? /^(?:Scope gate confirms plan mode, so )?(?:I'll review|I will review|I'll go with reviewing|I will go with reviewing|I'll proceed with reviewing|I will proceed with reviewing|I'm proceeding with reviewing|I am proceeding with reviewing)\s+(?:the\s+)?(?:pasted\s+)?(?:"([^"\n]+)"|“([^”\n]+)”|`([^`\n]+)`)\s+(?:draft(?:\s+plan)?|plan)(?:\s+(?:you pasted|pasted here))?(.*)$/i.exec(text); if (!selected || (selected[1] ?? selected[2] ?? selected[3])!.trim().toLowerCase() !== title.toLowerCase()) continue; const tail = selected[4]!; if (/\b(?:if|unless|assuming|pending|only after|instead|not|won't|cannot)\b/i.test(tail)) continue; @@ -81,7 +81,7 @@ export function nativeSeededPlanSelection( if (/^(?:\s+(?:since|because)\s+(?:we're|we are|I'm|I am)\s+in plan mode)?\.(?:\s+(?:Now|Next,|Then)\s+(?:I'll|I will)\s+(?:run|start|begin)\s+(?:the\s+)?(?:pre-review\s+)?audit\b(?:[^.!?]|\.(?=\S))*\.)?$/i.test(tail) && remainsSelected(message.timestamp)) return true; continue; } - if (/^(?:\.(?:\s+(?:Next,|Then\b).*)?|,\s*(?:starting|beginning)\s+(?:with|by)\b.*|,\s*and\s+now\s+(?:I'm|I am)\s+(?:running|starting|beginning)\s+(?:the\s+)?(?:pre-review\s+)?audit\b[^?]*\.|)$/.test(tail) && remainsSelected(message.timestamp)) return true; + if (/^(?:\.(?:\s+(?:Next,|Then\b).*)?|,\s*(?:starting|beginning)\s+(?:with|by)\b.*|,\s*and\s+now\s+(?:I'm|I am)\s+(?:running|starting|beginning)\s+(?:the\s+)?(?:pre-review\s+)?audit\b[^?]*\.|\. Running (?:the )?(?:pre-review )?audit\b(?:[^.!?]|\.(?=\S))*\.|)$/.test(tail) && remainsSelected(message.timestamp)) return true; } return false; } diff --git a/test/helpers/touchfiles-data.ts b/test/helpers/touchfiles-data.ts index 71b77f592..4967b0db0 100644 --- a/test/helpers/touchfiles-data.ts +++ b/test/helpers/touchfiles-data.ts @@ -101,13 +101,25 @@ export const E2E_TOUCHFILES: Record = { // AUTO_DECIDE preamble injection lives there and changes can flip the // regression test outcome between 'asked' and 'auto_decided'. 'plan-ceo-review-plan-mode': [ - 'test/auto-decide-saved-ai.test.ts', 'test/fixtures/auto-decide-saved-ai.json', 'test/fixtures/auto-decide-retry-ai.json','bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-ceo-review/**', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/question-tuning.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/review.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/skill-e2e-plan-ceo-plan-mode.test.ts', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/plan-scope-selection.ts', 'test/plan-scope-selection.test.ts', 'test/helpers/native-auto-decide.ts', 'test/native-auto-decide.test.ts', 'test/native-auto-decide-pty.test.ts', 'test/fixtures/native-auto-decide-ag.json', 'test/eng-seeded-completion-ai.test.ts', 'test/fixtures/eng-seeded-completion-ai.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**'], + 'test/auto-decide-saved-ai.test.ts', 'test/fixtures/auto-decide-saved-ai.json', 'test/fixtures/auto-decide-retry-ai.json','bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-ceo-review/**', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/question-tuning.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/review.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/skill-e2e-plan-ceo-plan-mode.test.ts', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/plan-scope-selection.ts', 'test/plan-scope-selection.test.ts', 'test/helpers/native-auto-decide.ts', 'test/native-auto-decide.test.ts', 'test/native-auto-decide-pty.test.ts', 'test/fixtures/native-auto-decide-ag.json', 'test/eng-seeded-completion-ai.test.ts', 'test/fixtures/eng-seeded-completion-ai.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', + 'test/design-scope-declaration-ak.test.ts', + 'test/fixtures/design-scope-declaration-ak.json', + ], 'plan-eng-review-plan-mode': [ - 'test/auto-decide-saved-ai.test.ts', 'test/fixtures/auto-decide-saved-ai.json', 'test/fixtures/auto-decide-retry-ai.json','bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-eng-review/**', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/question-tuning.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/review.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/skill-e2e-plan-eng-plan-mode.test.ts', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/plan-scope-selection.ts', 'test/plan-scope-selection.test.ts', 'test/fixtures/design-plan-scope-ag.json', 'test/design-scope-selection-aj.test.ts', 'test/fixtures/design-scope-selection-aj.json', 'test/helpers/native-auto-decide.ts', 'test/native-auto-decide.test.ts', 'test/native-auto-decide-pty.test.ts', 'test/fixtures/native-auto-decide-ag.json', 'test/eng-seeded-completion-ai.test.ts', 'test/fixtures/eng-seeded-completion-ai.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**'], + 'test/auto-decide-saved-ai.test.ts', 'test/fixtures/auto-decide-saved-ai.json', 'test/fixtures/auto-decide-retry-ai.json','bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-eng-review/**', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/question-tuning.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/review.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/skill-e2e-plan-eng-plan-mode.test.ts', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/plan-scope-selection.ts', 'test/plan-scope-selection.test.ts', 'test/fixtures/design-plan-scope-ag.json', 'test/design-scope-selection-aj.test.ts', 'test/fixtures/design-scope-selection-aj.json', 'test/helpers/native-auto-decide.ts', 'test/native-auto-decide.test.ts', 'test/native-auto-decide-pty.test.ts', 'test/fixtures/native-auto-decide-ag.json', 'test/eng-seeded-completion-ai.test.ts', 'test/fixtures/eng-seeded-completion-ai.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', + 'test/design-scope-declaration-ak.test.ts', + 'test/fixtures/design-scope-declaration-ak.json', + ], 'plan-design-review-plan-mode': [ - 'test/auto-decide-saved-ai.test.ts', 'test/fixtures/auto-decide-saved-ai.json', 'test/fixtures/auto-decide-retry-ai.json','bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-design-review/**', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/question-tuning.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/review.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/skill-e2e-plan-design-plan-mode.test.ts', 'test/skill-e2e-design.test.ts', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/plan-scope-selection.ts', 'test/plan-scope-selection.test.ts', 'test/fixtures/design-plan-scope-ag.json', 'test/design-scope-selection-aj.test.ts', 'test/fixtures/design-scope-selection-aj.json', 'test/helpers/native-auto-decide.ts', 'test/native-auto-decide.test.ts', 'test/native-auto-decide-pty.test.ts', 'test/fixtures/native-auto-decide-ag.json', 'test/eng-seeded-completion-ai.test.ts', 'test/fixtures/eng-seeded-completion-ai.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**'], + 'test/auto-decide-saved-ai.test.ts', 'test/fixtures/auto-decide-saved-ai.json', 'test/fixtures/auto-decide-retry-ai.json','bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-design-review/**', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/question-tuning.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/review.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/skill-e2e-plan-design-plan-mode.test.ts', 'test/skill-e2e-design.test.ts', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/plan-scope-selection.ts', 'test/plan-scope-selection.test.ts', 'test/fixtures/design-plan-scope-ag.json', 'test/design-scope-selection-aj.test.ts', 'test/fixtures/design-scope-selection-aj.json', 'test/helpers/native-auto-decide.ts', 'test/native-auto-decide.test.ts', 'test/native-auto-decide-pty.test.ts', 'test/fixtures/native-auto-decide-ag.json', 'test/eng-seeded-completion-ai.test.ts', 'test/fixtures/eng-seeded-completion-ai.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', + 'test/design-scope-declaration-ak.test.ts', + 'test/fixtures/design-scope-declaration-ak.json', + ], 'plan-devex-review-plan-mode': [ - 'test/auto-decide-saved-ai.test.ts', 'test/fixtures/auto-decide-saved-ai.json', 'test/fixtures/auto-decide-retry-ai.json','bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-devex-review/**', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/question-tuning.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/review.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/skill-e2e-plan-devex-plan-mode.test.ts', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/plan-scope-selection.ts', 'test/plan-scope-selection.test.ts', 'test/helpers/native-auto-decide.ts', 'test/native-auto-decide.test.ts', 'test/native-auto-decide-pty.test.ts', 'test/fixtures/native-auto-decide-ag.json', 'test/eng-seeded-completion-ai.test.ts', 'test/fixtures/eng-seeded-completion-ai.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**'], + 'test/auto-decide-saved-ai.test.ts', 'test/fixtures/auto-decide-saved-ai.json', 'test/fixtures/auto-decide-retry-ai.json','bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-devex-review/**', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/question-tuning.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/review.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/skill-e2e-plan-devex-plan-mode.test.ts', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/plan-scope-selection.ts', 'test/plan-scope-selection.test.ts', 'test/helpers/native-auto-decide.ts', 'test/native-auto-decide.test.ts', 'test/native-auto-decide-pty.test.ts', 'test/fixtures/native-auto-decide-ag.json', 'test/eng-seeded-completion-ai.test.ts', 'test/fixtures/eng-seeded-completion-ai.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', + 'test/design-scope-declaration-ak.test.ts', + 'test/fixtures/design-scope-declaration-ak.json', + ], // Covers ceo (preamble misfire) + eng/design (scope-gate bypass must not // fire outside plan mode) + the named-target exception case. 4 PTY runs; // in CI these run CONCURRENT with the rest of the pty-plan-smoke suite @@ -115,7 +127,10 @@ export const E2E_TOUCHFILES: Record = { // pass of each, sharing the API budget with sibling tests — not the // sequential ~+10min a local read suggests. 'plan-mode-no-op': [ - 'test/auto-decide-saved-ai.test.ts', 'test/fixtures/auto-decide-saved-ai.json', 'test/fixtures/auto-decide-retry-ai.json','bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-ceo-review/**', 'plan-eng-review/**', 'plan-design-review/**', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/preamble.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/skill-e2e-plan-mode-no-op.test.ts', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/plan-scope-selection.ts', 'test/plan-scope-selection.test.ts', 'test/helpers/native-auto-decide.ts', 'test/native-auto-decide.test.ts', 'test/native-auto-decide-pty.test.ts', 'test/fixtures/native-auto-decide-ag.json', 'test/eng-seeded-completion-ai.test.ts', 'test/fixtures/eng-seeded-completion-ai.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**'], + 'test/auto-decide-saved-ai.test.ts', 'test/fixtures/auto-decide-saved-ai.json', 'test/fixtures/auto-decide-retry-ai.json','bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-ceo-review/**', 'plan-eng-review/**', 'plan-design-review/**', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/preamble.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/skill-e2e-plan-mode-no-op.test.ts', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/plan-scope-selection.ts', 'test/plan-scope-selection.test.ts', 'test/helpers/native-auto-decide.ts', 'test/native-auto-decide.test.ts', 'test/native-auto-decide-pty.test.ts', 'test/fixtures/native-auto-decide-ag.json', 'test/eng-seeded-completion-ai.test.ts', 'test/fixtures/eng-seeded-completion-ai.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', + 'test/design-scope-declaration-ak.test.ts', + 'test/fixtures/design-scope-declaration-ak.json', + ], // v1.21+ AskUserQuestion-blocked regression tests — Conductor launches // claude with `--disallowedTools AskUserQuestion --permission-mode default` @@ -161,16 +176,49 @@ export const E2E_TOUCHFILES: Record = { // the selector sets GSTACK_CARVE_SKILL= to scope cost to the changed // skill (D-CODEX A). Touching the registry/helper or sections.ts runs all. 'carve-section-loading': ['scripts/resolvers/composition.ts', 'test/autoplan-review-discovery.test.ts', 'test/autoplan-phase-order.test.ts', 'design-html/**', 'design-shotgun/**', 'qa/**', 'browse/**', 'retro/**', 'autoplan/**', 'spec/**', 'setup-gbrain/**', 'review/**', 'codex/**', 'land-and-deploy/**', 'plan-eng-review/**', 'plan-design-review/**', 'plan-devex-review/**', 'office-hours/**', 'document-release/**', 'design-consultation/**', 'cso/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'test/session-runner-tools.test.ts', 'test/carve-section-loading.test.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/autoplan-snapshot.test.ts', 'test/autoplan-init.test.ts', 'test/autoplan-obligations.test.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json'], - 'autoplan-chain-pty': ['scripts/resolvers/composition.ts', 'test/autoplan-review-discovery.test.ts', 'test/autoplan-phase-order.test.ts', 'autoplan/**', 'plan-ceo-review/**', 'plan-design-review/**', 'plan-eng-review/**', 'plan-devex-review/**', 'test/fixtures/plans/autoplan-dashboard.md', 'test/autoplan-chain-fixture.test.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/plan-count-prerequisite-n.test.ts', 'test/fixtures/ceo-prerequisite-n-call.json', 'bin/gstack-config', 'test/helpers/claude-pty-runner.ts', 'test/plan-count-native-input.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/autoplan-setup-question.ts', 'test/autoplan-setup-question.test.ts', 'test/helpers/autoplan-phase-observer.ts', 'test/autoplan-phase-observer.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/autoplan-public-narration.test.ts', 'test/fixtures/autoplan-public-narration-ad.json', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/skill-e2e-autoplan-chain.test.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/autoplan-snapshot.test.ts', 'test/autoplan-init.test.ts', 'test/autoplan-obligations.test.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/plan-count-checkbox.test.ts', 'test/fixtures/ceo-checkbox-l.screen.txt', 'test/helpers/eval-budgets.ts', 'test/autoplan-eval-budget.test.ts', 'test/eval-budgets-policy.test.ts', 'test/eval-detach-timeout-floor.test.ts', 'scripts/test-paid-shards.ts', '.github/workflows/evals-periodic.yml', 'test/fixtures/autoplan-routing-n-screen.txt', 'test/autoplan-routing-o.test.ts', 'test/fixtures/autoplan-routing-o-screen.txt', 'test/autoplan-setup-packet-o.test.ts', 'test/fixtures/autoplan-setup-packet-o-screen.txt', 'test/fixtures/autoplan-setup-packet-o-call.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'test/fixtures/autoplan-setup-z-packet.json', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/autoplan-method-read-audit.ts', 'test/autoplan-method-read-audit.test.ts', 'test/fixtures/autoplan-method-read-aa-events.json', 'test/autoplan-routing-manual-skills.test.ts', 'test/fixtures/autoplan-routing-manual-skills-ac.json', 'test/helpers/plan-count-pending-question.ts', 'test/autoplan-pending-question.test.ts', 'test/plan-pending-question-pty.test.ts', 'test/pending-question-completion.test.ts', 'test/fixtures/pending-question-completion-ad.json', 'test/fixtures/autoplan-setup-ad-v2-packet.json', 'test/helpers/autoplan-artifact-permission.ts', 'test/autoplan-artifact-permission.test.ts', 'test/fixtures/autoplan-artifact-permission-ad-v3.json', 'scripts/resolvers/testing.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/autoplan-artifact-recorder.test.ts', 'test/autoplan-pending-artifact.test.ts', 'test/fixtures/autoplan-pending-artifact-ae.json', 'test/autoplan-edit-header-ag.test.ts', 'test/fixtures/autoplan-edit-header-ag.json', 'test/autoplan-edit-prefix-ai.test.ts', 'test/fixtures/autoplan-edit-prefix-ai.json', 'test/autoplan-edit-panel-aj.test.ts', 'test/fixtures/autoplan-edit-panel-aj.json'], + 'autoplan-chain-pty': ['scripts/resolvers/composition.ts', 'test/autoplan-review-discovery.test.ts', 'test/autoplan-phase-order.test.ts', 'autoplan/**', 'plan-ceo-review/**', 'plan-design-review/**', 'plan-eng-review/**', 'plan-devex-review/**', 'test/fixtures/plans/autoplan-dashboard.md', 'test/autoplan-chain-fixture.test.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/plan-count-prerequisite-n.test.ts', 'test/fixtures/ceo-prerequisite-n-call.json', 'bin/gstack-config', 'test/helpers/claude-pty-runner.ts', 'test/plan-count-native-input.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/autoplan-setup-question.ts', 'test/autoplan-setup-question.test.ts', 'test/helpers/autoplan-phase-observer.ts', 'test/autoplan-phase-observer.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/autoplan-public-narration.test.ts', 'test/fixtures/autoplan-public-narration-ad.json', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/skill-e2e-autoplan-chain.test.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/autoplan-snapshot.test.ts', 'test/autoplan-init.test.ts', 'test/autoplan-obligations.test.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/plan-count-checkbox.test.ts', 'test/fixtures/ceo-checkbox-l.screen.txt', 'test/helpers/eval-budgets.ts', 'test/autoplan-eval-budget.test.ts', 'test/eval-budgets-policy.test.ts', 'test/eval-detach-timeout-floor.test.ts', 'scripts/test-paid-shards.ts', '.github/workflows/evals-periodic.yml', 'test/fixtures/autoplan-routing-n-screen.txt', 'test/autoplan-routing-o.test.ts', 'test/fixtures/autoplan-routing-o-screen.txt', 'test/autoplan-setup-packet-o.test.ts', 'test/fixtures/autoplan-setup-packet-o-screen.txt', 'test/fixtures/autoplan-setup-packet-o-call.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'test/fixtures/autoplan-setup-z-packet.json', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/autoplan-method-read-audit.ts', 'test/autoplan-method-read-audit.test.ts', 'test/fixtures/autoplan-method-read-aa-events.json', 'test/autoplan-routing-manual-skills.test.ts', 'test/fixtures/autoplan-routing-manual-skills-ac.json', 'test/helpers/plan-count-pending-question.ts', 'test/autoplan-pending-question.test.ts', 'test/plan-pending-question-pty.test.ts', 'test/pending-question-completion.test.ts', 'test/fixtures/pending-question-completion-ad.json', 'test/fixtures/autoplan-setup-ad-v2-packet.json', 'test/helpers/autoplan-artifact-permission.ts', 'test/autoplan-artifact-permission.test.ts', 'test/fixtures/autoplan-artifact-permission-ad-v3.json', 'scripts/resolvers/testing.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/autoplan-artifact-recorder.test.ts', 'test/autoplan-pending-artifact.test.ts', 'test/fixtures/autoplan-pending-artifact-ae.json', 'test/autoplan-edit-header-ag.test.ts', 'test/fixtures/autoplan-edit-header-ag.json', 'test/autoplan-edit-prefix-ai.test.ts', 'test/fixtures/autoplan-edit-prefix-ai.json', 'test/autoplan-edit-panel-aj.test.ts', 'test/fixtures/autoplan-edit-panel-aj.json', + 'test/autoplan-repeated-header-ak.test.ts', + 'test/fixtures/autoplan-repeated-header-ak.json', + ], // Per-finding AskUserQuestion count + review-report-at-bottom assertion. // Each test drives its skill end-to-end; touchfiles include preamble + // completion-status resolvers because they affect question cadence and // terminal output (the regression surface this test catches). - 'plan-ceo-finding-count': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-ceo-review/**', 'test/skill-ceo-section-ordering.test.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/plan-count-native-input.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/plan-count-completion.test.ts', 'test/plan-count-dx-handoff.test.ts', 'test/fixtures/devex-handoff-n-call.json', 'test/helpers/ceo-completion-handoff.ts', 'test/ceo-completion-handoff.test.ts', 'test/ceo-count-s-terminals.test.ts', 'test/fixtures/ceo-count-s-paired.json', 'test/fixtures/ceo-completion-handoff-calls.json', 'test/fixtures/ceo-completion-handoff-j-calls.json', 'test/fixtures/ceo-completion-handoff-k-calls.json', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/autoplan-public-narration.test.ts', 'test/fixtures/autoplan-public-narration-ad.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/plan-count-timeout.test.ts', 'test/plan-count-navigation-r.test.ts', 'test/plan-count-prerequisite-n.test.ts', 'test/fixtures/ceo-prerequisite-n-call.json', 'test/skill-e2e-plan-ceo-finding-count.test.ts', 'test/ceo-completion-handoff-l.test.ts', 'test/fixtures/ceo-completion-handoff-l-calls.json', 'test/ceo-completion-handoff-m.test.ts', 'test/fixtures/ceo-completion-handoff-m-call.json', 'test/fixtures/devex-review-l-calls.json', 'test/plan-count-checkbox.test.ts', 'test/fixtures/ceo-checkbox-l.screen.txt', 'test/fixtures/ceo-handoff-n-calls.json', 'test/ceo-completion-handoff-o.test.ts', 'test/fixtures/ceo-completion-handoff-o-call.json', 'test/plan-count-empty-review.test.ts', 'test/fixtures/ceo-count-s-distinct.json', 'test/fixtures/plan-count-design-questionless-report.md', 'test/plan-count-dx-handoff-o.test.ts', 'test/fixtures/devex-handoff-o-call.json', 'test/helpers/ceo-approach-pick.ts', 'test/ceo-approach-pick.test.ts', 'test/fixtures/ceo-approach-q-call.json', 'test/fixtures/ceo-approach-r-call.json', 'test/fixtures/ceo-approach-r-distinct-call.json', 'test/fixtures/ceo-approach-q-paired-call.json', 'test/fixtures/ceo-completion-handoff-q-call.json', 'test/fixtures/ceo-completion-handoff-r-calls.json', 'test/fixtures/ceo-completion-handoff-t-call.json', 'test/helpers/plan-count-file-permission.ts', 'test/plan-count-file-permission.test.ts', 'test/fixtures/plan-count-edit-permission-t.json', 'test/plan-count-preview-footer.test.ts', 'test/fixtures/ceo-preview-u-call.json', 'test/fixtures/ceo-preview-u-screen.txt', 'test/fixtures/ceo-completion-handoff-u-call.json', 'test/fixtures/ceo-completion-handoff-v-call.json', 'test/fixtures/design-preview-v-screen.txt', 'test/plan-count-owned-permission.test.ts', 'test/fixtures/plan-count-owned-permission-v.json', 'test/fixtures/ceo-questionless-w-native.json', 'test/plan-count-ceo-body-finding.test.ts', 'test/fixtures/ceo-count-w-paired.json', 'test/fixtures/ceo-completion-handoff-w-call.json', 'test/fixtures/ceo-approach-y-call.json', 'test/fixtures/ceo-approach-y-screen.txt', 'test/ceo-handoff-y.test.ts', 'test/fixtures/ceo-handoff-y-call.json', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/fixtures/ceo-handoff-z-call.json', 'test/fixtures/ceo-approach-aa-call.json', 'test/review-handoffs-aa.test.ts', 'test/fixtures/review-handoff-aa-ceo.json', 'test/fixtures/review-handoff-aa-dx.json', 'test/helpers/ceo-mode-option.ts', 'test/ceo-count-mode.test.ts', 'test/fixtures/ceo-count-mode-ab-call.json', 'test/ceo-count-ac.test.ts', 'test/fixtures/ceo-count-ac-calls.json', 'test/fixtures/ceo-count-ac-later-calls.json', 'test/plan-count-permission-ac.test.ts', 'test/fixtures/plan-count-permission-ac.json', 'test/fixtures/plan-count-permission-ad.json', 'test/fixtures/plan-count-permission-ae.json', 'test/ceo-count-ad-v2.test.ts', 'test/fixtures/ceo-count-ad-v2.json', 'test/fixtures/plan-count-permission-target-ad-v2.json', 'test/fixtures/ceo-finding-alias-af.json', 'test/fixtures/ceo-numbered-brief-af.json', 'test/ceo-contract-assertions-ag.test.ts', 'test/fixtures/ceo-contract-assertions-ag.json', 'test/fixtures/ceo-contract-assertions-ag-retry.json', 'test/fixtures/plan-count-permission-ah.json', 'test/ceo-parenthesized-issue-ah.test.ts', 'test/fixtures/ceo-parenthesized-issue-ah.json', 'test/ceo-section-choice-ai.test.ts', 'test/fixtures/ceo-section-choice-ai.json', 'test/ceo-annotation-aj.test.ts', 'test/fixtures/ceo-annotation-aj.json'], - 'plan-eng-finding-count': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-eng-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/plan-count-native-input.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/plan-count-completion.test.ts', 'test/plan-count-dx-handoff.test.ts', 'test/fixtures/devex-handoff-n-call.json', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/autoplan-public-narration.test.ts', 'test/fixtures/autoplan-public-narration-ad.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/plan-count-timeout.test.ts', 'test/plan-count-navigation-r.test.ts', 'test/plan-count-prerequisite-n.test.ts', 'test/fixtures/ceo-prerequisite-n-call.json', 'test/skill-e2e-plan-eng-finding-count.test.ts', 'test/fixtures/devex-review-l-calls.json', 'test/plan-count-checkbox.test.ts', 'test/fixtures/ceo-checkbox-l.screen.txt', 'test/plan-count-empty-review.test.ts', 'test/fixtures/ceo-count-s-distinct.json', 'test/fixtures/plan-count-design-questionless-report.md', 'test/plan-count-dx-handoff-o.test.ts', 'test/fixtures/devex-handoff-o-call.json', 'test/eng-devex-s-count.test.ts', 'test/fixtures/eng-devex-s-first-calls.json', 'test/fixtures/eng-devex-s-retry-calls.json', 'test/helpers/plan-count-file-permission.ts', 'test/plan-count-file-permission.test.ts', 'test/fixtures/plan-count-edit-permission-t.json', 'test/eng-first-review-t.test.ts', 'test/fixtures/eng-batching-t-calls.json', 'test/plan-count-preview-footer.test.ts', 'test/fixtures/ceo-preview-u-call.json', 'test/fixtures/ceo-preview-u-screen.txt', 'test/fixtures/design-preview-v-screen.txt', 'test/plan-count-owned-permission.test.ts', 'test/fixtures/plan-count-owned-permission-v.json', 'test/fixtures/ceo-questionless-w-native.json', 'test/eng-scope-y.test.ts', 'test/fixtures/eng-scope-y-calls.json', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/eng-binding-z.test.ts', 'test/fixtures/eng-binding-z-calls.json', 'test/eng-binding-retry-z.test.ts', 'test/fixtures/eng-binding-retry-z-calls.json', 'test/plan-count-permission-ac.test.ts', 'test/fixtures/plan-count-permission-ac.json', 'test/fixtures/plan-count-permission-ad.json', 'test/fixtures/plan-count-permission-ae.json', 'test/fixtures/plan-count-permission-target-ad-v2.json', 'test/helpers/eng-completion-handoff.ts', 'test/eng-count-ad-v2.test.ts', 'test/fixtures/eng-count-ad-v2.json', 'test/helpers/eng-seeded-coverage.ts', 'test/eng-seeded-coverage.test.ts', 'test/eng-seeded-packet-ae.test.ts', 'test/fixtures/eng-seeded-packet-ae.json', 'test/eng-first-category-af.test.ts', 'test/fixtures/eng-first-category-af.json', 'test/eng-regression-pinning-ag.test.ts', 'test/fixtures/eng-regression-pinning-ag.json', 'test/fixtures/plan-count-permission-ah.json', 'test/eng-next-handoff-ah.test.ts', 'test/fixtures/eng-next-handoff-ah.json', 'test/eng-declared-regression-ai.test.ts', 'test/fixtures/eng-declared-regression-ai.json', 'test/eng-snapshot-adapter-aj.test.ts', 'test/fixtures/eng-snapshot-adapter-aj.json'], - 'plan-design-finding-count': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-design-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/plan-count-native-input.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/plan-count-completion.test.ts', 'test/plan-count-dx-handoff.test.ts', 'test/fixtures/devex-handoff-n-call.json', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/autoplan-public-narration.test.ts', 'test/fixtures/autoplan-public-narration-ad.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/plan-count-timeout.test.ts', 'test/plan-count-navigation-r.test.ts', 'test/plan-count-prerequisite-n.test.ts', 'test/fixtures/ceo-prerequisite-n-call.json', 'test/helpers/design-count-review.ts', 'test/design-count-review.test.ts', 'test/fixtures/design-review-j-calls.json', 'test/helpers/design-count-outside.ts', 'test/design-count-outside.test.ts', 'test/skill-e2e-plan-design-finding-count.test.ts', 'test/fixtures/design-review-l-calls.json', 'test/design-completion-handoff.test.ts', 'test/fixtures/design-handoff-l-calls.json', 'test/fixtures/devex-review-l-calls.json', 'test/plan-count-checkbox.test.ts', 'test/fixtures/ceo-checkbox-l.screen.txt', 'test/fixtures/design-review-n-calls.json', 'test/design-completion-handoff-scored.test.ts', 'test/fixtures/design-handoff-n-calls.json', 'test/fixtures/design-handoff-q-calls.json', 'test/plan-count-empty-review.test.ts', 'test/fixtures/ceo-count-s-distinct.json', 'test/fixtures/plan-count-design-questionless-report.md', 'test/plan-count-dx-handoff-o.test.ts', 'test/fixtures/devex-handoff-o-call.json', 'test/helpers/plan-count-file-permission.ts', 'test/plan-count-file-permission.test.ts', 'test/fixtures/plan-count-edit-permission-t.json', 'test/plan-count-preview-footer.test.ts', 'test/fixtures/ceo-preview-u-call.json', 'test/fixtures/ceo-preview-u-screen.txt', 'test/design-completion-handoff-u.test.ts', 'test/fixtures/design-handoff-u-calls.json', 'test/fixtures/design-preview-v-screen.txt', 'test/plan-count-owned-permission.test.ts', 'test/fixtures/plan-count-owned-permission-v.json', 'test/fixtures/ceo-questionless-w-native.json', 'test/helpers/design-artifact-question.ts', 'test/design-artifact-question.test.ts', 'test/fixtures/design-artifacts-w-calls.json', 'test/fixtures/design-outside-y-calls.json', 'test/fixtures/design-boundaries-y-calls.json', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/fixtures/design-gap-z-calls.json', 'test/plan-count-permission-ac.test.ts', 'test/fixtures/plan-count-permission-ac.json', 'test/fixtures/plan-count-permission-ad.json', 'test/fixtures/plan-count-permission-ae.json', 'test/fixtures/plan-count-permission-target-ad-v2.json', 'test/design-count-ad-v2.test.ts', 'test/fixtures/design-count-ad-v2.json', 'test/design-first-decision-af.test.ts', 'test/fixtures/design-first-decision-af.json', 'test/fixtures/design-first-decision-af-retry.json', 'test/fixtures/plan-count-permission-ah.json', 'test/design-first-issue-ai.test.ts', 'test/fixtures/design-first-issue-ai.json', 'test/design-primary-action-aj.test.ts', 'test/fixtures/design-primary-action-aj.json', 'test/fixtures/design-future-todo-aj.json'], - 'plan-devex-finding-count': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-devex-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/plan-count-native-input.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/plan-count-completion.test.ts', 'test/plan-count-dx-handoff.test.ts', 'test/fixtures/devex-handoff-n-call.json', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/autoplan-public-narration.test.ts', 'test/fixtures/autoplan-public-narration-ad.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/plan-count-timeout.test.ts', 'test/plan-count-navigation-r.test.ts', 'test/plan-count-prerequisite-n.test.ts', 'test/fixtures/ceo-prerequisite-n-call.json', 'test/helpers/devex-count-fixture.ts', 'test/devex-count-fixture.test.ts', 'test/skill-e2e-plan-devex-finding-count.test.ts', 'test/fixtures/dx-prerequisite-r-call.json', 'test/fixtures/devex-review-l-calls.json', 'test/plan-count-checkbox.test.ts', 'test/fixtures/ceo-checkbox-l.screen.txt', 'test/fixtures/devex-review-n-calls.json', 'test/plan-count-empty-review.test.ts', 'test/fixtures/ceo-count-s-distinct.json', 'test/fixtures/plan-count-design-questionless-report.md', 'test/devex-output-o.test.ts', 'test/fixtures/devex-review-o-calls.json', 'test/fixtures/devex-output-o-retry-call.json', 'test/devex-setup-remedy-o.test.ts', 'test/fixtures/devex-review-o-retry-calls.json', 'test/plan-count-dx-handoff-o.test.ts', 'test/fixtures/devex-handoff-o-call.json', 'test/eng-devex-s-count.test.ts', 'test/fixtures/eng-devex-s-first-calls.json', 'test/fixtures/eng-devex-s-retry-calls.json', 'test/fixtures/devex-review-t-calls.json', 'test/helpers/plan-count-file-permission.ts', 'test/plan-count-file-permission.test.ts', 'test/fixtures/plan-count-edit-permission-t.json', 'test/plan-count-preview-footer.test.ts', 'test/fixtures/ceo-preview-u-call.json', 'test/fixtures/ceo-preview-u-screen.txt', 'test/fixtures/devex-count-u-calls.json', 'test/fixtures/devex-count-u-retry-calls.json', 'test/fixtures/devex-empathy-v-calls.json', 'test/fixtures/devex-handoff-v-call.json', 'test/fixtures/design-preview-v-screen.txt', 'test/plan-count-owned-permission.test.ts', 'test/fixtures/plan-count-owned-permission-v.json', 'test/fixtures/ceo-questionless-w-native.json', 'test/fixtures/devex-count-y-calls.json', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/fixtures/devex-count-z-calls.json', 'test/fixtures/devex-handoff-z-call.json', 'test/review-handoffs-aa.test.ts', 'test/fixtures/review-handoff-aa-ceo.json', 'test/fixtures/review-handoff-aa-dx.json', 'test/devex-empathy-ab.test.ts', 'test/fixtures/devex-empathy-ab-calls.json', 'test/devex-ac-accounting.test.ts', 'test/fixtures/devex-ac-first-attempt-calls.json', 'test/plan-count-permission-ac.test.ts', 'test/fixtures/plan-count-permission-ac.json', 'test/fixtures/plan-count-permission-ad.json', 'test/fixtures/plan-count-permission-ae.json', 'test/fixtures/plan-count-permission-target-ad-v2.json', 'test/devex-reconfirmation-ad-v2.test.ts', 'test/fixtures/devex-reconfirmation-ad-v2.json', 'test/plan-count-history.test.ts', 'test/helpers/devex-seed-coverage.ts', 'test/devex-seed-coverage.test.ts', 'test/fixtures/devex-seed-coverage-ad-v3.json', 'test/fixtures/plan-count-permission-ah.json'], + 'plan-ceo-finding-count': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-ceo-review/**', 'test/skill-ceo-section-ordering.test.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/plan-count-native-input.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/plan-count-completion.test.ts', 'test/plan-count-dx-handoff.test.ts', 'test/fixtures/devex-handoff-n-call.json', 'test/helpers/ceo-completion-handoff.ts', 'test/ceo-completion-handoff.test.ts', 'test/ceo-count-s-terminals.test.ts', 'test/fixtures/ceo-count-s-paired.json', 'test/fixtures/ceo-completion-handoff-calls.json', 'test/fixtures/ceo-completion-handoff-j-calls.json', 'test/fixtures/ceo-completion-handoff-k-calls.json', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/autoplan-public-narration.test.ts', 'test/fixtures/autoplan-public-narration-ad.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/plan-count-timeout.test.ts', 'test/plan-count-navigation-r.test.ts', 'test/plan-count-prerequisite-n.test.ts', 'test/fixtures/ceo-prerequisite-n-call.json', 'test/skill-e2e-plan-ceo-finding-count.test.ts', 'test/ceo-completion-handoff-l.test.ts', 'test/fixtures/ceo-completion-handoff-l-calls.json', 'test/ceo-completion-handoff-m.test.ts', 'test/fixtures/ceo-completion-handoff-m-call.json', 'test/fixtures/devex-review-l-calls.json', 'test/plan-count-checkbox.test.ts', 'test/fixtures/ceo-checkbox-l.screen.txt', 'test/fixtures/ceo-handoff-n-calls.json', 'test/ceo-completion-handoff-o.test.ts', 'test/fixtures/ceo-completion-handoff-o-call.json', 'test/plan-count-empty-review.test.ts', 'test/fixtures/ceo-count-s-distinct.json', 'test/fixtures/plan-count-design-questionless-report.md', 'test/plan-count-dx-handoff-o.test.ts', 'test/fixtures/devex-handoff-o-call.json', 'test/helpers/ceo-approach-pick.ts', 'test/ceo-approach-pick.test.ts', 'test/fixtures/ceo-approach-q-call.json', 'test/fixtures/ceo-approach-r-call.json', 'test/fixtures/ceo-approach-r-distinct-call.json', 'test/fixtures/ceo-approach-q-paired-call.json', 'test/fixtures/ceo-completion-handoff-q-call.json', 'test/fixtures/ceo-completion-handoff-r-calls.json', 'test/fixtures/ceo-completion-handoff-t-call.json', 'test/helpers/plan-count-file-permission.ts', 'test/plan-count-file-permission.test.ts', 'test/fixtures/plan-count-edit-permission-t.json', 'test/plan-count-preview-footer.test.ts', 'test/fixtures/ceo-preview-u-call.json', 'test/fixtures/ceo-preview-u-screen.txt', 'test/fixtures/ceo-completion-handoff-u-call.json', 'test/fixtures/ceo-completion-handoff-v-call.json', 'test/fixtures/design-preview-v-screen.txt', 'test/plan-count-owned-permission.test.ts', 'test/fixtures/plan-count-owned-permission-v.json', 'test/fixtures/ceo-questionless-w-native.json', 'test/plan-count-ceo-body-finding.test.ts', 'test/fixtures/ceo-count-w-paired.json', 'test/fixtures/ceo-completion-handoff-w-call.json', 'test/fixtures/ceo-approach-y-call.json', 'test/fixtures/ceo-approach-y-screen.txt', 'test/ceo-handoff-y.test.ts', 'test/fixtures/ceo-handoff-y-call.json', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/fixtures/ceo-handoff-z-call.json', 'test/fixtures/ceo-approach-aa-call.json', 'test/review-handoffs-aa.test.ts', 'test/fixtures/review-handoff-aa-ceo.json', 'test/fixtures/review-handoff-aa-dx.json', 'test/helpers/ceo-mode-option.ts', 'test/ceo-count-mode.test.ts', 'test/fixtures/ceo-count-mode-ab-call.json', 'test/ceo-count-ac.test.ts', 'test/fixtures/ceo-count-ac-calls.json', 'test/fixtures/ceo-count-ac-later-calls.json', 'test/plan-count-permission-ac.test.ts', 'test/fixtures/plan-count-permission-ac.json', 'test/fixtures/plan-count-permission-ad.json', 'test/fixtures/plan-count-permission-ae.json', 'test/ceo-count-ad-v2.test.ts', 'test/fixtures/ceo-count-ad-v2.json', 'test/fixtures/plan-count-permission-target-ad-v2.json', 'test/fixtures/ceo-finding-alias-af.json', 'test/fixtures/ceo-numbered-brief-af.json', 'test/ceo-contract-assertions-ag.test.ts', 'test/fixtures/ceo-contract-assertions-ag.json', 'test/fixtures/ceo-contract-assertions-ag-retry.json', 'test/fixtures/plan-count-permission-ah.json', 'test/ceo-parenthesized-issue-ah.test.ts', 'test/fixtures/ceo-parenthesized-issue-ah.json', 'test/ceo-section-choice-ai.test.ts', 'test/fixtures/ceo-section-choice-ai.json', 'test/ceo-annotation-aj.test.ts', 'test/fixtures/ceo-annotation-aj.json', + 'test/plan-count-crop-ak.test.ts', + 'test/fixtures/plan-count-crop-ak.json', + 'test/ceo-numbered-brief-ak.test.ts', + 'test/fixtures/ceo-numbered-brief-ak.json', + 'test/ceo-finding-brief-ak.test.ts', + 'test/fixtures/ceo-finding-brief-ak.json', + 'test/plan-count-quoted-frame-ak.test.ts', + 'test/fixtures/plan-count-quoted-frame-ak.json', + ], + 'plan-eng-finding-count': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-eng-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/plan-count-native-input.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/plan-count-completion.test.ts', 'test/plan-count-dx-handoff.test.ts', 'test/fixtures/devex-handoff-n-call.json', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/autoplan-public-narration.test.ts', 'test/fixtures/autoplan-public-narration-ad.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/plan-count-timeout.test.ts', 'test/plan-count-navigation-r.test.ts', 'test/plan-count-prerequisite-n.test.ts', 'test/fixtures/ceo-prerequisite-n-call.json', 'test/skill-e2e-plan-eng-finding-count.test.ts', 'test/fixtures/devex-review-l-calls.json', 'test/plan-count-checkbox.test.ts', 'test/fixtures/ceo-checkbox-l.screen.txt', 'test/plan-count-empty-review.test.ts', 'test/fixtures/ceo-count-s-distinct.json', 'test/fixtures/plan-count-design-questionless-report.md', 'test/plan-count-dx-handoff-o.test.ts', 'test/fixtures/devex-handoff-o-call.json', 'test/eng-devex-s-count.test.ts', 'test/fixtures/eng-devex-s-first-calls.json', 'test/fixtures/eng-devex-s-retry-calls.json', 'test/helpers/plan-count-file-permission.ts', 'test/plan-count-file-permission.test.ts', 'test/fixtures/plan-count-edit-permission-t.json', 'test/eng-first-review-t.test.ts', 'test/fixtures/eng-batching-t-calls.json', 'test/plan-count-preview-footer.test.ts', 'test/fixtures/ceo-preview-u-call.json', 'test/fixtures/ceo-preview-u-screen.txt', 'test/fixtures/design-preview-v-screen.txt', 'test/plan-count-owned-permission.test.ts', 'test/fixtures/plan-count-owned-permission-v.json', 'test/fixtures/ceo-questionless-w-native.json', 'test/eng-scope-y.test.ts', 'test/fixtures/eng-scope-y-calls.json', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/eng-binding-z.test.ts', 'test/fixtures/eng-binding-z-calls.json', 'test/eng-binding-retry-z.test.ts', 'test/fixtures/eng-binding-retry-z-calls.json', 'test/plan-count-permission-ac.test.ts', 'test/fixtures/plan-count-permission-ac.json', 'test/fixtures/plan-count-permission-ad.json', 'test/fixtures/plan-count-permission-ae.json', 'test/fixtures/plan-count-permission-target-ad-v2.json', 'test/helpers/eng-completion-handoff.ts', 'test/eng-count-ad-v2.test.ts', 'test/fixtures/eng-count-ad-v2.json', 'test/helpers/eng-seeded-coverage.ts', 'test/eng-seeded-coverage.test.ts', 'test/eng-seeded-packet-ae.test.ts', 'test/fixtures/eng-seeded-packet-ae.json', 'test/eng-first-category-af.test.ts', 'test/fixtures/eng-first-category-af.json', 'test/eng-regression-pinning-ag.test.ts', 'test/fixtures/eng-regression-pinning-ag.json', 'test/fixtures/plan-count-permission-ah.json', 'test/eng-next-handoff-ah.test.ts', 'test/fixtures/eng-next-handoff-ah.json', 'test/eng-declared-regression-ai.test.ts', 'test/fixtures/eng-declared-regression-ai.json', 'test/eng-snapshot-adapter-aj.test.ts', 'test/fixtures/eng-snapshot-adapter-aj.json', + 'test/eng-declared-suite-ak.test.ts', + 'test/fixtures/eng-declared-suite-ak.json', + 'test/plan-count-crop-ak.test.ts', + 'test/fixtures/plan-count-crop-ak.json', + 'test/plan-count-quoted-frame-ak.test.ts', + 'test/fixtures/plan-count-quoted-frame-ak.json', + ], + 'plan-design-finding-count': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-design-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/plan-count-native-input.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/plan-count-completion.test.ts', 'test/plan-count-dx-handoff.test.ts', 'test/fixtures/devex-handoff-n-call.json', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/autoplan-public-narration.test.ts', 'test/fixtures/autoplan-public-narration-ad.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/plan-count-timeout.test.ts', 'test/plan-count-navigation-r.test.ts', 'test/plan-count-prerequisite-n.test.ts', 'test/fixtures/ceo-prerequisite-n-call.json', 'test/helpers/design-count-review.ts', 'test/design-count-review.test.ts', 'test/fixtures/design-review-j-calls.json', 'test/helpers/design-count-outside.ts', 'test/design-count-outside.test.ts', 'test/skill-e2e-plan-design-finding-count.test.ts', 'test/fixtures/design-review-l-calls.json', 'test/design-completion-handoff.test.ts', 'test/fixtures/design-handoff-l-calls.json', 'test/fixtures/devex-review-l-calls.json', 'test/plan-count-checkbox.test.ts', 'test/fixtures/ceo-checkbox-l.screen.txt', 'test/fixtures/design-review-n-calls.json', 'test/design-completion-handoff-scored.test.ts', 'test/fixtures/design-handoff-n-calls.json', 'test/fixtures/design-handoff-q-calls.json', 'test/plan-count-empty-review.test.ts', 'test/fixtures/ceo-count-s-distinct.json', 'test/fixtures/plan-count-design-questionless-report.md', 'test/plan-count-dx-handoff-o.test.ts', 'test/fixtures/devex-handoff-o-call.json', 'test/helpers/plan-count-file-permission.ts', 'test/plan-count-file-permission.test.ts', 'test/fixtures/plan-count-edit-permission-t.json', 'test/plan-count-preview-footer.test.ts', 'test/fixtures/ceo-preview-u-call.json', 'test/fixtures/ceo-preview-u-screen.txt', 'test/design-completion-handoff-u.test.ts', 'test/fixtures/design-handoff-u-calls.json', 'test/fixtures/design-preview-v-screen.txt', 'test/plan-count-owned-permission.test.ts', 'test/fixtures/plan-count-owned-permission-v.json', 'test/fixtures/ceo-questionless-w-native.json', 'test/helpers/design-artifact-question.ts', 'test/design-artifact-question.test.ts', 'test/fixtures/design-artifacts-w-calls.json', 'test/fixtures/design-outside-y-calls.json', 'test/fixtures/design-boundaries-y-calls.json', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/fixtures/design-gap-z-calls.json', 'test/plan-count-permission-ac.test.ts', 'test/fixtures/plan-count-permission-ac.json', 'test/fixtures/plan-count-permission-ad.json', 'test/fixtures/plan-count-permission-ae.json', 'test/fixtures/plan-count-permission-target-ad-v2.json', 'test/design-count-ad-v2.test.ts', 'test/fixtures/design-count-ad-v2.json', 'test/design-first-decision-af.test.ts', 'test/fixtures/design-first-decision-af.json', 'test/fixtures/design-first-decision-af-retry.json', 'test/fixtures/plan-count-permission-ah.json', 'test/design-first-issue-ai.test.ts', 'test/fixtures/design-first-issue-ai.json', 'test/design-primary-action-aj.test.ts', 'test/fixtures/design-primary-action-aj.json', 'test/fixtures/design-future-todo-aj.json', + 'test/design-primary-contract-ak.test.ts', + 'test/fixtures/design-primary-contract-ak.json', + 'test/plan-count-crop-ak.test.ts', + 'test/fixtures/plan-count-crop-ak.json', + 'test/plan-count-quoted-frame-ak.test.ts', + 'test/fixtures/plan-count-quoted-frame-ak.json', + ], + 'plan-devex-finding-count': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-devex-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/plan-count-native-input.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/plan-count-completion.test.ts', 'test/plan-count-dx-handoff.test.ts', 'test/fixtures/devex-handoff-n-call.json', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/autoplan-public-narration.test.ts', 'test/fixtures/autoplan-public-narration-ad.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/plan-count-timeout.test.ts', 'test/plan-count-navigation-r.test.ts', 'test/plan-count-prerequisite-n.test.ts', 'test/fixtures/ceo-prerequisite-n-call.json', 'test/helpers/devex-count-fixture.ts', 'test/devex-count-fixture.test.ts', 'test/skill-e2e-plan-devex-finding-count.test.ts', 'test/fixtures/dx-prerequisite-r-call.json', 'test/fixtures/devex-review-l-calls.json', 'test/plan-count-checkbox.test.ts', 'test/fixtures/ceo-checkbox-l.screen.txt', 'test/fixtures/devex-review-n-calls.json', 'test/plan-count-empty-review.test.ts', 'test/fixtures/ceo-count-s-distinct.json', 'test/fixtures/plan-count-design-questionless-report.md', 'test/devex-output-o.test.ts', 'test/fixtures/devex-review-o-calls.json', 'test/fixtures/devex-output-o-retry-call.json', 'test/devex-setup-remedy-o.test.ts', 'test/fixtures/devex-review-o-retry-calls.json', 'test/plan-count-dx-handoff-o.test.ts', 'test/fixtures/devex-handoff-o-call.json', 'test/eng-devex-s-count.test.ts', 'test/fixtures/eng-devex-s-first-calls.json', 'test/fixtures/eng-devex-s-retry-calls.json', 'test/fixtures/devex-review-t-calls.json', 'test/helpers/plan-count-file-permission.ts', 'test/plan-count-file-permission.test.ts', 'test/fixtures/plan-count-edit-permission-t.json', 'test/plan-count-preview-footer.test.ts', 'test/fixtures/ceo-preview-u-call.json', 'test/fixtures/ceo-preview-u-screen.txt', 'test/fixtures/devex-count-u-calls.json', 'test/fixtures/devex-count-u-retry-calls.json', 'test/fixtures/devex-empathy-v-calls.json', 'test/fixtures/devex-handoff-v-call.json', 'test/fixtures/design-preview-v-screen.txt', 'test/plan-count-owned-permission.test.ts', 'test/fixtures/plan-count-owned-permission-v.json', 'test/fixtures/ceo-questionless-w-native.json', 'test/fixtures/devex-count-y-calls.json', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/fixtures/devex-count-z-calls.json', 'test/fixtures/devex-handoff-z-call.json', 'test/review-handoffs-aa.test.ts', 'test/fixtures/review-handoff-aa-ceo.json', 'test/fixtures/review-handoff-aa-dx.json', 'test/devex-empathy-ab.test.ts', 'test/fixtures/devex-empathy-ab-calls.json', 'test/devex-ac-accounting.test.ts', 'test/fixtures/devex-ac-first-attempt-calls.json', 'test/plan-count-permission-ac.test.ts', 'test/fixtures/plan-count-permission-ac.json', 'test/fixtures/plan-count-permission-ad.json', 'test/fixtures/plan-count-permission-ae.json', 'test/fixtures/plan-count-permission-target-ad-v2.json', 'test/devex-reconfirmation-ad-v2.test.ts', 'test/fixtures/devex-reconfirmation-ad-v2.json', 'test/plan-count-history.test.ts', 'test/helpers/devex-seed-coverage.ts', 'test/devex-seed-coverage.test.ts', 'test/fixtures/devex-seed-coverage-ad-v3.json', 'test/fixtures/plan-count-permission-ah.json', + 'test/dx-signature-identity-ak.test.ts', + 'test/fixtures/dx-signature-identity-ak.json', + 'test/plan-count-crop-ak.test.ts', + 'test/fixtures/plan-count-crop-ak.json', + 'test/plan-count-quoted-frame-ak.test.ts', + 'test/fixtures/plan-count-quoted-frame-ak.json', + ], // Gate-tier reviewCount-floor counterparts. Catch the May 2026 transcript // bug (model wrote a plan-mode plan and ExitPlanMode'd without firing any @@ -187,8 +235,18 @@ export const E2E_TOUCHFILES: Record = { // a model fires one AUQ then batches the rest into a "## Decisions to // confirm" plan write. runPlanSkillFloorCheck cannot detect that shape // (it exits on first AUQ); runPlanSkillCounting can. - 'plan-eng-multi-finding-batching': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-eng-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/review.ts', 'test/helpers/claude-pty-runner.ts', 'test/plan-count-native-input.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/autoplan-public-narration.test.ts', 'test/fixtures/autoplan-public-narration-ad.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/plan-count-timeout.test.ts', 'test/plan-count-navigation-r.test.ts', 'test/plan-count-prerequisite-n.test.ts', 'test/fixtures/ceo-prerequisite-n-call.json', 'test/fixtures/forcing-finding-seeds.ts', 'test/skill-e2e-plan-eng-multi-finding-batching.test.ts', 'test/plan-count-checkbox.test.ts', 'test/fixtures/ceo-checkbox-l.screen.txt', 'test/eng-devex-s-count.test.ts', 'test/fixtures/eng-devex-s-first-calls.json', 'test/fixtures/eng-devex-s-retry-calls.json', 'test/helpers/plan-count-file-permission.ts', 'test/plan-count-file-permission.test.ts', 'test/fixtures/plan-count-edit-permission-t.json', 'test/eng-first-review-t.test.ts', 'test/fixtures/eng-batching-t-calls.json', 'test/plan-count-preview-footer.test.ts', 'test/fixtures/ceo-preview-u-call.json', 'test/fixtures/ceo-preview-u-screen.txt', 'test/fixtures/design-preview-v-screen.txt', 'test/plan-count-owned-permission.test.ts', 'test/fixtures/plan-count-owned-permission-v.json', 'test/fixtures/ceo-questionless-w-native.json', 'test/eng-scope-y.test.ts', 'test/fixtures/eng-scope-y-calls.json', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/eng-binding-z.test.ts', 'test/fixtures/eng-binding-z-calls.json', 'test/eng-binding-retry-z.test.ts', 'test/fixtures/eng-binding-retry-z-calls.json', 'test/plan-count-permission-ac.test.ts', 'test/fixtures/plan-count-permission-ac.json', 'test/fixtures/plan-count-permission-ad.json', 'test/fixtures/plan-count-permission-ae.json', 'test/fixtures/plan-count-permission-target-ad-v2.json', 'test/eng-count-ad-v2.test.ts', 'test/fixtures/eng-count-ad-v2.json', 'test/eng-first-category-af.test.ts', 'test/fixtures/eng-first-category-af.json', 'test/eng-regression-pinning-ag.test.ts', 'test/fixtures/eng-regression-pinning-ag.json', 'test/fixtures/plan-count-permission-ah.json', 'test/eng-declared-regression-ai.test.ts', 'test/fixtures/eng-declared-regression-ai.json'], - 'plan-ceo-split-overflow': ['bin/gstack-config', 'plan-ceo-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'bin/gstack-question-preference', 'test/helpers/claude-pty-runner.ts', 'test/plan-count-native-input.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/autoplan-public-narration.test.ts', 'test/fixtures/autoplan-public-narration-ad.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/plan-count-timeout.test.ts', 'test/plan-count-navigation-r.test.ts', 'test/plan-count-prerequisite-n.test.ts', 'test/fixtures/ceo-prerequisite-n-call.json', 'test/fixtures/forcing-finding-seeds.ts', 'test/skill-e2e-plan-ceo-split-overflow.test.ts', 'test/plan-count-checkbox.test.ts', 'test/fixtures/ceo-checkbox-l.screen.txt', 'test/helpers/plan-count-file-permission.ts', 'test/plan-count-file-permission.test.ts', 'test/fixtures/plan-count-edit-permission-t.json', 'test/plan-count-owned-permission.test.ts', 'test/fixtures/plan-count-owned-permission-v.json', 'test/fixtures/ceo-questionless-w-native.json', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/plan-count-permission-ac.test.ts', 'test/fixtures/plan-count-permission-ac.json', 'test/fixtures/plan-count-permission-ad.json', 'test/fixtures/plan-count-permission-ae.json', 'test/fixtures/plan-count-permission-target-ad-v2.json', 'test/fixtures/plan-count-permission-ah.json'], + 'plan-eng-multi-finding-batching': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-eng-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/review.ts', 'test/helpers/claude-pty-runner.ts', 'test/plan-count-native-input.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/autoplan-public-narration.test.ts', 'test/fixtures/autoplan-public-narration-ad.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/plan-count-timeout.test.ts', 'test/plan-count-navigation-r.test.ts', 'test/plan-count-prerequisite-n.test.ts', 'test/fixtures/ceo-prerequisite-n-call.json', 'test/fixtures/forcing-finding-seeds.ts', 'test/skill-e2e-plan-eng-multi-finding-batching.test.ts', 'test/plan-count-checkbox.test.ts', 'test/fixtures/ceo-checkbox-l.screen.txt', 'test/eng-devex-s-count.test.ts', 'test/fixtures/eng-devex-s-first-calls.json', 'test/fixtures/eng-devex-s-retry-calls.json', 'test/helpers/plan-count-file-permission.ts', 'test/plan-count-file-permission.test.ts', 'test/fixtures/plan-count-edit-permission-t.json', 'test/eng-first-review-t.test.ts', 'test/fixtures/eng-batching-t-calls.json', 'test/plan-count-preview-footer.test.ts', 'test/fixtures/ceo-preview-u-call.json', 'test/fixtures/ceo-preview-u-screen.txt', 'test/fixtures/design-preview-v-screen.txt', 'test/plan-count-owned-permission.test.ts', 'test/fixtures/plan-count-owned-permission-v.json', 'test/fixtures/ceo-questionless-w-native.json', 'test/eng-scope-y.test.ts', 'test/fixtures/eng-scope-y-calls.json', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/eng-binding-z.test.ts', 'test/fixtures/eng-binding-z-calls.json', 'test/eng-binding-retry-z.test.ts', 'test/fixtures/eng-binding-retry-z-calls.json', 'test/plan-count-permission-ac.test.ts', 'test/fixtures/plan-count-permission-ac.json', 'test/fixtures/plan-count-permission-ad.json', 'test/fixtures/plan-count-permission-ae.json', 'test/fixtures/plan-count-permission-target-ad-v2.json', 'test/eng-count-ad-v2.test.ts', 'test/fixtures/eng-count-ad-v2.json', 'test/eng-first-category-af.test.ts', 'test/fixtures/eng-first-category-af.json', 'test/eng-regression-pinning-ag.test.ts', 'test/fixtures/eng-regression-pinning-ag.json', 'test/fixtures/plan-count-permission-ah.json', 'test/eng-declared-regression-ai.test.ts', 'test/fixtures/eng-declared-regression-ai.json', + 'test/plan-count-crop-ak.test.ts', + 'test/fixtures/plan-count-crop-ak.json', + 'test/plan-count-quoted-frame-ak.test.ts', + 'test/fixtures/plan-count-quoted-frame-ak.json', + ], + 'plan-ceo-split-overflow': ['bin/gstack-config', 'plan-ceo-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'bin/gstack-question-preference', 'test/helpers/claude-pty-runner.ts', 'test/plan-count-native-input.test.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/autoplan-public-narration.test.ts', 'test/fixtures/autoplan-public-narration-ad.json', 'test/helpers/plan-count-pending-exit.ts', 'test/plan-count-pending-exit.test.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/plan-count-timeout.test.ts', 'test/plan-count-navigation-r.test.ts', 'test/plan-count-prerequisite-n.test.ts', 'test/fixtures/ceo-prerequisite-n-call.json', 'test/fixtures/forcing-finding-seeds.ts', 'test/skill-e2e-plan-ceo-split-overflow.test.ts', 'test/plan-count-checkbox.test.ts', 'test/fixtures/ceo-checkbox-l.screen.txt', 'test/helpers/plan-count-file-permission.ts', 'test/plan-count-file-permission.test.ts', 'test/fixtures/plan-count-edit-permission-t.json', 'test/plan-count-owned-permission.test.ts', 'test/fixtures/plan-count-owned-permission-v.json', 'test/fixtures/ceo-questionless-w-native.json', 'test/plan-count-truncated-question.test.ts', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/plan-count-permission-ac.test.ts', 'test/fixtures/plan-count-permission-ac.json', 'test/fixtures/plan-count-permission-ad.json', 'test/fixtures/plan-count-permission-ae.json', 'test/fixtures/plan-count-permission-target-ad-v2.json', 'test/fixtures/plan-count-permission-ah.json', + 'test/plan-count-crop-ak.test.ts', + 'test/fixtures/plan-count-crop-ak.json', + 'test/plan-count-quoted-frame-ak.test.ts', + 'test/fixtures/plan-count-quoted-frame-ak.json', + ], 'brain-privacy-gate': ['bin/gstack-skill-start', 'bin/gstack-skill-end', 'scripts/resolvers/preamble/generate-brain-sync-block.ts', 'scripts/resolvers/preamble.ts', 'bin/gstack-brain-sync', 'bin/gstack-artifacts-init', 'bin/gstack-config', 'test/helpers/agent-sdk-runner.ts', 'test/skill-e2e-brain-privacy-gate.test.ts'], // /setup-gbrain Path 4 (Remote MCP) — happy + bad-token end-to-end via diff --git a/test/plan-count-crop-ak.test.ts b/test/plan-count-crop-ak.test.ts new file mode 100644 index 000000000..31beb92e1 --- /dev/null +++ b/test/plan-count-crop-ak.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import fixture from './fixtures/plan-count-crop-ak.json'; +import { currentFilePermissionEpoch } from './helpers/plan-count-file-permission'; +import { createPlanCountPermissionGuard } from './helpers/claude-pty-runner'; +import { E2E_TOUCHFILES, selectTests } from './helpers/touchfiles'; + +function replay(change: (f: any) => void = () => {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-crop-ak-')); + const expected = path.join(dir, 'report.md'), record = path.join(dir, 'record.json'); + const f: any = { expected, record, screen: fixture.screen.replaceAll(path.dirname(fixture.hook.expected), dir) + .replaceAll(path.basename(fixture.hook.expected), 'report.md'), state: { ...structuredClone(fixture.hook), expected }, + before: fixture.ownedBefore, cwd: fixture.cwd, config: fixture.config, startedAt: fixture.startedAt, + transcript: structuredClone(fixture.transcript), fileKind: 'file' }; + try { + change(f); fs.writeFileSync(record, JSON.stringify(f.state)); + if (f.fileKind === 'file') fs.writeFileSync(expected, f.before); + if (f.fileKind === 'directory') fs.mkdirSync(expected); + if (f.fileKind === 'symlink') { const other = path.join(dir, 'other.md'); fs.writeFileSync(other, f.before); fs.symlinkSync(other, expected); } + const epoch = currentFilePermissionEpoch(record, f.expected, f.cwd, f.config, f.startedAt, f.transcript, f.screen); + return { epoch, screen: f.screen, guard: createPlanCountPermissionGuard(), run: () => createPlanCountPermissionGuard()(f.screen, '', epoch) }; + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +} + +test('actual owned bare continuation uses the preceding original line and grants once', () => { + const r = replay(); + expect(r.epoch?.pendingId).toBe(fixture.hook.pendingId); + expect(r.guard(r.screen, '', r.epoch)).toBe('grant'); + expect(r.guard(r.screen, '', r.epoch)).toBe('handled'); +}); + +test('harmless preceding line number relocation keeps the exact content binding', () => { + const r = replay(f => { + f.before = 'Added unchanged line\n' + f.before; + f.screen = f.screen.replace(/^ {0,3}82 +$/m, ' 83 '); + }); + expect(r.epoch?.pendingId).toBe(fixture.hook.pendingId); +}); + +const negatives: Array<[string, (f: any) => void]> = [ + ['wrong preceding line', f => { f.screen = f.screen.replace(/^ {0,3}82 +$/m, ' 83 '); }], + ['unrelated prose continuation', f => { f.screen = f.screen.replace(/^.*\n/, ' Apply this edit now\n'); }], + ['source quotation continuation', f => { f.screen = f.screen.replace(/^.*\n/, ' > Example: apply this edit now\n'); }], + ['foreign continuation suffix', f => { f.screen = f.screen.replace('f2 (~7.6:1)', 'foreign (~7.6:1)'); }], + ['five-space unnumbered gutter', f => { f.screen = f.screen.slice(1); }], + ['seven-space unnumbered gutter', f => { f.screen = ' ' + f.screen; }], + ['no following unchanged numbered row', f => { f.screen = f.screen.replace(/^ {0,3}82 +$/m, ' 82 +'); }], + ['two arbitrary continuation rows', f => { f.screen = f.screen.split('\n')[0] + '\n' + f.screen; }], + ['missing original file', f => { f.fileKind = 'missing'; }], + ['directory in place of original file', f => { f.fileKind = 'directory'; }], + ['oversized original file', f => { f.before += 'x'.repeat(65537); }], + ['foreign displayed directory', f => { f.screen = f.screen.replace(path.dirname(f.expected) + ' for this session', path.join(path.dirname(f.expected), 'foreign') + ' for this session'); }], + ['foreign hook expected path', f => { f.state.expected += '.foreign'; }], + ['no current native request', f => { f.state.pendingId = null; }], + ['completed native request', f => { f.state.completedId = f.state.pendingId; }], + ['stale native timestamp', f => { f.state.timestamp = new Date(f.startedAt - 1).toISOString(); }], + ['future native timestamp', f => { f.state.timestamp = new Date(Date.now() + 60000).toISOString(); }], + ['foreign session transcript', f => { f.transcript.assistantMessages[0].sessionId = 'foreign'; }], + ['unavailable native transcript', f => { f.transcript.status = 'error'; }], + ['missing complete menu footer', f => { f.screen = f.screen.replace('Esc to cancel · Tab to amend', ''); }], + ['selected policy change', f => { f.screen = f.screen.replace('❯ 1. Yes', '❯ 1. Yes, always allow'); }], + ['unrelated prompt prepended', f => { f.screen = '☐ Review this example\n' + f.screen; }], + ['historical pane is not current viewport', f => { f.screen = 'Waiting for current tool'; }], +]; +test.each(negatives)('%s cannot supply an owned epoch', (_, change) => { + const r = replay(change); expect(r.epoch).not.toBeTruthy(); expect(r.run()).not.toBe('grant'); +}); +test.skipIf(process.platform === 'win32')('a symlink cannot supply current original content', () => { + expect(replay(f => { f.fileKind = 'symlink'; }).epoch).toBeNull(); +}); +test('new dependencies have the exact existing file-permission consumers', () => { + for (const dependency of ['test/plan-count-crop-ak.test.ts', 'test/fixtures/plan-count-crop-ak.json']) { + expect(selectTests([dependency], E2E_TOUCHFILES).selected.sort()).toEqual( + selectTests(['test/helpers/plan-count-file-permission.ts'], E2E_TOUCHFILES).selected.sort()); + } +}); diff --git a/test/plan-count-quoted-frame-ak.test.ts b/test/plan-count-quoted-frame-ak.test.ts new file mode 100644 index 000000000..8a9695f81 --- /dev/null +++ b/test/plan-count-quoted-frame-ak.test.ts @@ -0,0 +1,82 @@ +import {expect,test} from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {pathToFileURL} from 'node:url'; +import {classifyPlanCountFrame,createPlanCountPermissionGuard} from './helpers/claude-pty-runner'; +import exact from './fixtures/plan-count-quoted-frame-ak.json'; +import native from './fixtures/plan-count-permission-ac.json'; +import owned from './fixtures/plan-count-owned-permission-v.json'; +import {E2E_TOUCHFILES,selectTests} from './helpers/touchfiles'; +const quote=(s:string)=>s.split('\n').map(row=>'> '+row).join('\n'); + +test('the exact wholly quoted AK pane is handled so the dispatcher sends no fallback',()=>{ + const screen=quote(exact.screen); + expect(classifyPlanCountFrame(screen)).toBe('permission'); + expect(createPlanCountPermissionGuard()(screen,'',undefined)).toBe('handled'); +}); +test('quoted whole native frames remain inert across redraw, indentation, CRLF and terminal color',()=>{ + for(const source of [exact.screen,native.rows[0]!.screen,owned.screen])for(const transform of [ + (s:string)=>quote(s),(s:string)=>'\n'+quote(s)+'\n',(s:string)=>quote(s).replace(/^>/gm,' >'), + (s:string)=>quote(s).replaceAll('\n','\r\n'),(s:string)=>'\x1b[31m'+quote(s)+'\x1b[0m', + ]){const s=transform(source),g=createPlanCountPermissionGuard(); + const expected=classifyPlanCountFrame(s)==='permission'?'handled':null; + expect(g(s,'')).toBe(expected);expect(g(s,'⎿ Wrote 4 lines\n')).toBe(expected);} +}); +test('quoted history cannot consume or authorize the current native grant',()=>{ + const s=native.rows[0]!.screen,g=createPlanCountPermissionGuard(),q=quote(s); + expect(g(q,'')).toBe('handled');expect(g(s,q)).toBe('grant');expect(g(q,s)).toBe('handled');expect(g(s,q)).toBe('handled'); + expect(createPlanCountPermissionGuard()(s,'',null)).toBe('handled'); + expect(createPlanCountPermissionGuard()(s,'',{pendingId:'main:first',completedId:null})).toBe('grant'); + expect(createPlanCountPermissionGuard()(q,'',{pendingId:'main:first',completedId:null})).toBe('handled'); +}); +test('unquoted current native frames retain their policy even with quote characters in diff text or history',()=>{ + for(const s of native.rows.map(row=>row.screen))expect(createPlanCountPermissionGuard()(s,quote(s))).toBe('grant'); + expect(createPlanCountPermissionGuard()('> An old note\n'+native.rows[0]!.screen,'')).toBe('grant'); + expect(createPlanCountPermissionGuard()('No current pane',quote(exact.screen))).toBeNull(); + expect(createPlanCountPermissionGuard()('> Ordinary quoted prose, without a permission menu')).toBeNull(); +}); +test('the regression selects exactly the existing permission consumers',()=>{ + for(const dependency of ['test/plan-count-quoted-frame-ak.test.ts','test/fixtures/plan-count-quoted-frame-ak.json']) + expect(selectTests([dependency],E2E_TOUCHFILES).selected.sort()).toEqual(selectTests(['test/plan-count-permission-ac.test.ts'],E2E_TOUCHFILES).selected.sort()); +}); + +test.skipIf(process.platform==='win32')('real dispatcher ignores quoted pane then grants the fresh owned native request once',async()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'count-quoted-frame-')),fake=path.join(dir,'fake-claude'),worker=path.join(dir,'worker.ts'),events=path.join(dir,'events.jsonl'),output=path.join(dir,'result.json'),report=path.join(dir,'report.md'); + fs.writeFileSync(report,'original'); + fs.writeFileSync(fake,`#!${process.execPath}\n`+String.raw` +import fs from 'node:fs';import path from 'node:path'; +const item=JSON.parse(process.env.QUOTED_FRAME_CASE),sid='quoted-frame-main',log=e=>fs.appendFileSync(item.events,JSON.stringify(e)+'\n'); +const transcript=path.join(process.env.CLAUDE_CONFIG_DIR,'projects','owned',sid+'.jsonl');fs.mkdirSync(path.dirname(transcript),{recursive:true}); +const native=(role,content,extra={})=>fs.appendFileSync(transcript,JSON.stringify({cwd:process.cwd(),sessionId:sid,isSidechain:false,timestamp:new Date().toISOString(),message:{role,content},...extra})+'\n'); +native('assistant',[{type:'text',text:'Reviewing the owned fixture.'}]);log({type:'start',pid:process.pid,cwd:process.cwd()}); +const settings=JSON.parse(process.argv[process.argv.indexOf('--settings')+1]); +const hook=async name=>{for(const entry of settings.hooks[name]??[]){if(entry.matcher!=='^(Write|Edit)$')continue; + const event={hook_event_name:name,tool_name:'Edit',session_id:sid,tool_use_id:'current',cwd:process.cwd(),transcript_path:transcript,tool_input:{file_path:item.report}}; + const p=Bun.spawn(['bash','-c',entry.hooks[0].command],{stdin:new Blob([JSON.stringify(event)]),stdout:'pipe',stderr:'pipe'}); + const [code,out,err]=await Promise.all([p.exited,new Response(p.stdout).text(),new Response(p.stderr).text()]);if(code||out||err)throw Error('Hook failed');}}; +const pane=item.screen.replaceAll('PLAN.md',item.report),paint=s=>process.stdout.write('\x1b[2J\x1b[H'+s.replaceAll('\n','\r\n')); +let stage='startup';process.stdin.setRawMode?.(true);process.stdin.on('data',async data=>{ + const input=data.toString();log({type:'input',stage,input}); + if(stage==='startup'){stage='quoted';paint(item.quotedScreen);setTimeout(async()=>{await hook('PreToolUse');stage='current';paint(pane);},4200);return;} + if(stage!=='current'){log({type:'unexpected'});return;} + if(input!=='1\r')throw Error('One-time grant changed');stage='done';await hook('PostToolUse'); + const q={header:'Finding',question:'Apply the reviewed fix?',options:[{label:'Fix'},{label:'Keep'}]}; + native('assistant',[{type:'tool_use',name:'AskUserQuestion',id:'finding',input:{questions:[q]}}]);native('user',[{type:'tool_result',tool_use_id:'finding',content:'Answered'}],{toolUseResult:{answers:{[q.question]:'Fix'}}});paint('Done.\n'); +});process.on('SIGINT',()=>process.exit(0));process.stdin.resume(); +`);fs.chmodSync(fake,0o755); + // Keep every physical terminal row inside the quote; adding a prefix to an + // already120-column capture would otherwise wrap an unquoted continuation. + const quotedScreen=exact.screen.split('\n').flatMap(row=>row.trimEnd().match(/.{1,116}/gu)??['']).map(row=>'> '+row).join('\n'); + const args={skillName:'plan-ceo-review',slashCommand:'/plan-ceo-review',followUpPrompt:'Review the disposable fixture.',expectedPlanPath:report,reviewCountCeiling:1,timeoutMs:25000,env:{QUOTED_FRAME_CASE:JSON.stringify({events,report,screen:owned.screen,quotedScreen})}}; + fs.writeFileSync(worker,`import {runPlanSkillCounting} from ${JSON.stringify(pathToFileURL(path.join(import.meta.dir,'helpers/claude-pty-runner.ts')).href)};const result=await runPlanSkillCounting({...${JSON.stringify(args)},isLastStep0AUQ:()=>false,isReviewAUQ:()=>true});await Bun.write(${JSON.stringify(output)},JSON.stringify(result));`); + const child=Bun.spawn([process.execPath,worker],{env:{...process.env,BROWSE_TERMINAL_BINARY:fake,EVALS_HERMETIC:'1'},stdout:'pipe',stderr:'pipe'}),timer=setTimeout(()=>child.kill('SIGKILL'),30000); + try{const [code,out,err]=await Promise.all([child.exited,new Response(child.stdout).text(),new Response(child.stderr).text()]);expect(code,out+err).toBe(0); + const result=JSON.parse(fs.readFileSync(output,'utf8')),rows=fs.readFileSync(events,'utf8').trim().split('\n').map(s=>JSON.parse(s)); + expect(result.outcome,JSON.stringify(result)).toBe('ceiling_reached');expect(result.reviewCount).toBe(1); + expect(rows.filter(r=>r.type==='input').map(r=>[r.stage,r.input])).toEqual([['startup','/plan-ceo-review\r'],['current','1\r']]);expect(rows.some(r=>r.type==='unexpected')).toBe(false); + expect(()=>process.kill(rows[0].pid,0)).toThrow();expect(fs.existsSync(rows[0].cwd)).toBe(false); + }finally{clearTimeout(timer);child.kill('SIGKILL');await child.exited; + if(fs.existsSync(events)){const first=JSON.parse(fs.readFileSync(events,'utf8').split('\n')[0]!);try{if(fs.readFileSync('/proc/'+first.pid+'/cmdline','utf8').split('\0').includes(fake))process.kill(first.pid,'SIGKILL');}catch{}} + fs.rmSync(dir,{recursive:true,force:true});} +},32000);