import { afterAll, afterEach, beforeAll, describe, expect, jest, test } from 'bun:test'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; import { initializePlan, prepareMethodology, createSnapshot, preparePhaseClose, prepareAmendedInput } from '../bin/gstack-autoplan-snapshot'; import { evaluateAutoplanPublication, runPublicationHook, autoplanReadRange, type PublicationHookInput } from '../autoplan/bin/phase-publication-hook.ts'; import { readOwnedClaudePublicTranscript, readPlanCountTranscript, transcriptReadLimit, OWNED_TRANSCRIPT_MAX_BYTES, nativePathSpelling, ownedNativePath, sameNativePath, type ClaudeParentPublicEvent } from '../lib/claude-public-transcript'; import { prematureAutoplanPhaseEntry } from './helpers/autoplan-method-read-audit'; import captured from './fixtures/autoplan-publication-boundary-361c.json'; import consumption from './fixtures/autoplan-phase-consumption-491.json'; import { OWNED_READ_APPROVAL, renderSectionBase } from '../autoplan/bin/owned-read'; const ROOT = fs.realpathSync(path.join(import.meta.dir, '..')); // Every guarded decision appends to the guard log; keep it out of the real state root. let previousGstackHome: string | undefined; let guardLogHome = ''; beforeAll(() => { previousGstackHome = process.env.GSTACK_HOME; guardLogHome = fs.mkdtempSync(path.join(tmpdir(), 'autoplan-guard-log-')); process.env.GSTACK_HOME = guardLogHome; }); afterAll(() => { if (previousGstackHome === undefined) delete process.env.GSTACK_HOME; else process.env.GSTACK_HOME = previousGstackHome; fs.rmSync(guardLogHome, { recursive: true, force: true }); }); const dirs: string[] = []; afterEach(() => { for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); }); const phaseNumber = { ceo: 1, design: 2, dx: 2.5, eng: 3 }; type Phase = keyof typeof phaseNumber; const clock = Date.parse('2026-09-17T04:00:00Z'); async function withNativeProjectDirectory(cwd: string | undefined, work: () => Promise): Promise { const previous = process.env.CLAUDE_PROJECT_DIR; if (cwd === undefined) delete process.env.CLAUDE_PROJECT_DIR; else process.env.CLAUDE_PROJECT_DIR = cwd; try { return await work(); } finally { if (previous === undefined) delete process.env.CLAUDE_PROJECT_DIR; else process.env.CLAUDE_PROJECT_DIR = previous; } } async function withPublicationClock(work: () => Promise): Promise { expect(jest.isFakeTimers()).toBe(false); const immediate = globalThis.setImmediate; jest.useFakeTimers(); try { let settled = false; const pending = Promise.resolve().then(work); void pending.then(() => { settled = true; }, () => { settled = true; }); for (let elapsed = 0; elapsed <= 2_000; elapsed += 50) { await new Promise(resolve => immediate(resolve)); if (settled) return await pending; if (elapsed < 2_000) jest.advanceTimersByTime(50); } throw new Error('Publication hook did not settle within its 2000ms polling budget.'); } finally { jest.clearAllTimers(); jest.useRealTimers(); } } function fixture(phase: Phase = 'ceo', next = 'design') { const cwd = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-publication-'))); dirs.push(cwd); const source = path.join(cwd, 'source.md'), active = path.join(cwd, 'active.md'), restore = path.join(cwd, 'restore.md'); fs.writeFileSync(source, '# Current plan\nKeep documented behavior.\n'); const init = initializePlan(source, active, restore); const skill = path.join(cwd, 'SKILL.md'); fs.writeFileSync(skill, `---\nname: plan-${phase === 'dx' ? 'devex' : phase}-review\n---\n## Review Sections\nApply every current review criterion.\n`); const method = prepareMethodology(phase, skill, restore).methodologyPath; const checkpoint = createSnapshot(phase, active, restore, method).snapshotPath; fs.appendFileSync(active, `\nNone: retain the current behavior.\n\n`); const packet = preparePhaseClose(phase, active, checkpoint, restore, method); const sessionId = randomUUID(), events: ClaudeParentPublicEvent[] = []; const add = (event: any) => { const full = { sessionId, timestamp: new Date(clock + events.length).toISOString(), order: events.length, ...event }; events.push(full); return full; }; const use = (id: string, name: string, input: object) => add({ kind: 'use', toolUseId: id, name, input }); const result = (id: string, extra: object) => add({ kind: 'result', toolUseId: id, isError: false, ...extra }); const read = (id: string, file: string, offset = 1, limit?: number) => { const text = fs.readFileSync(fs.realpathSync(file), 'utf8'), lines = text.split('\n'); const count = Math.min(limit ?? lines.length, lines.length - offset + 1); use(id, 'Read', { file_path: file, offset, ...(limit === undefined ? {} : { limit }) }); result(id, { file: { filePath: file, content: lines.slice(offset - 1, offset - 1 + count).join('\n'), startLine: offset, numLines: count, totalLines: lines.length } }); }; use('init', 'Bash', { command: `cd '${cwd}'\nbun "${ROOT}/bin/gstack-autoplan-snapshot.ts" init \\\n "${source}" "${active}" "${restore}"` }); result('init', { content: JSON.stringify(init) }); read('entry', path.join(ROOT, 'autoplan', 'sections', `${phase}-phase.md`)); read('close', packet.closePacketPath); const message = (text = `Phase ${phaseNumber[phase]} complete.`) => add({ kind: 'message', text }); const target = path.join(ROOT, 'autoplan', 'sections', next === 'tasks' ? 'tasks-aggregator.md' : `${next}-phase.md`); const input: PublicationHookInput = { hook_event_name: 'PreToolUse', session_id: sessionId, cwd, transcript_path: path.join(cwd, 'config', 'projects', 'fixture', `${sessionId}.jsonl`), tool_name: 'Read', tool_use_id: 'next', tool_input: { file_path: target } }; const current = () => use('next', 'Read', input.tool_input); const evaluate = () => evaluateAutoplanPublication(input, ROOT, events); const reorder = () => events.forEach((e, i) => { e.order = i; }); const journal = () => { fs.mkdirSync(path.dirname(input.transcript_path), { recursive: true }); let parent: string | null = null; const record = (role: string, content: unknown, extra: object = {}) => { const uuid = randomUUID(); const r = { uuid, parentUuid: parent, cwd, sessionId, isSidechain: false, timestamp: new Date(clock).toISOString(), type: role, message: { role, content }, ...extra }; parent = uuid; return r; }; const rows = [record('user', 'autoplan\n/autoplan', { origin: { kind: 'human' }, promptId: randomUUID() })]; for (const e of events) { if (e.kind === 'use') rows.push(record('assistant', [{ type: 'tool_use', id: e.toolUseId, name: e.name, input: e.input }], { timestamp: e.timestamp })); else if (e.kind === 'result') rows.push(record('user', [{ type: 'tool_result', tool_use_id: e.toolUseId, content: e.content ?? 'Read complete.', is_error: e.isError }], { timestamp: e.timestamp, toolUseResult: { file: e.file } })); else if (e.kind === 'message') rows.push(record('assistant', [{ type: 'text', text: e.text }], { timestamp: e.timestamp })); } fs.writeFileSync(input.transcript_path, rows.map(x => JSON.stringify(x)).join('\n') + '\n'); return { rows, record }; }; return { cwd, source, active, restore, init, method, checkpoint, packet, sessionId, events, input, add, use, result, read, message, current, evaluate, reorder, journal }; } describe('Autoplan parent publication guard', () => { function checkpointWriter(f: ReturnType) { const create = (phase: Phase, id: string) => { const skill = path.join(f.cwd, id, 'SKILL.md'); fs.mkdirSync(path.dirname(skill)); fs.writeFileSync(skill, `---\nname: plan-${phase === 'dx' ? 'devex' : phase}-review\n---\n## Review Sections\nApply every current review criterion.\n`); const method = prepareMethodology(phase, skill, f.restore).methodologyPath; f.use(id, 'Bash', { command: `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" create ${phase} "${f.active}" "${f.restore}" "${method}"` }); const snapshot = createSnapshot(phase, f.active, f.restore, method); f.result(id, { content: JSON.stringify(snapshot) }); return { method, snapshot }; }; return create; } function completedCycle() { const f = fixture(); f.message(); const create = checkpointWriter(f), first = new Map>(); let unread: ReturnType; for (const phase of ['design', 'dx', 'eng'] as const) { f.read(`${phase}-entry`, path.join(ROOT, 'autoplan/sections', `${phase}-phase.md`)); const generation = create(phase, `${phase}-create`); first.set(phase, generation); f.read(`${phase}-snapshot`, path.join(path.dirname(generation.snapshot.snapshotPath), 'snapshot.json')); // This old snapshot is acknowledged before closing, but never Read. if (phase === 'eng') unread = create('eng', 'unread-historical-create'); fs.appendFileSync(f.active, `\nNone: retain the current behavior.\n\n`); const close = preparePhaseClose(phase, f.active, generation.snapshot.snapshotPath, f.restore, generation.method); f.read(`${phase}-close`, close.closePacketPath); f.message(`Phase ${phaseNumber[phase]} complete.`); } const tasks = path.join(ROOT, 'autoplan/sections/tasks-aggregator.md'); f.read('first-tasks', tasks); return { f, create, first, unread: unread!, tasks }; } test('a fresh Eng checkpoint after tasks requires a new close and parent publication', async () => { const { f, create, tasks } = completedCycle(); const rerun = create('eng', 'rerun-create'); f.read('rerun-snapshot', path.join(path.dirname(rerun.snapshot.snapshotPath), 'snapshot.json')); f.input.tool_input = { file_path: tasks }; f.current(); f.journal(); const output: any = await withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT)); expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); f.events.pop(); const close = preparePhaseClose('eng', f.active, rerun.snapshot.snapshotPath, f.restore, rerun.method); f.read('rerun-close', close.closePacketPath); f.current(); f.journal(); expect(await withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT))) .toMatchObject({ hookSpecificOutput: { permissionDecisionReason: expect.stringContaining('Publish the filled Phase 3') } }); f.events.pop(); f.message('Phase 3 complete.'); f.current(); f.journal(); expect(await withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT))).toEqual(OWNED_READ_APPROVAL); }); for (const phase of ['ceo', 'design', 'dx', 'eng'] as const) test(`a fresh ${phase} checkpoint reopens its own publication boundary after tasks`, () => { const { f, create } = completedCycle(), rerun = create(phase, 'rerun-create'); // Creation supplies no forward-phase credit, but the owned new checkpoint // invalidates the earlier cycle even before its first snapshot Read. const next = { ceo: 'design-phase', design: 'eng-phase', dx: 'eng-phase', eng: 'tasks-aggregator' }[phase]; f.input.tool_input = { file_path: path.join(ROOT, 'autoplan/sections', `${next}.md`) }; f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining(`Phase ${phaseNumber[phase]} close procedure`) }); f.events.pop(); f.use('rerun-review', 'Agent', { prompt: rerun.snapshot.nativeDispatchPrompt }); f.result('rerun-review', { content: 'Native reviewer launched.' }); const close = preparePhaseClose(phase, f.active, rerun.snapshot.snapshotPath, f.restore, rerun.method); f.read('rerun-close', close.closePacketPath); f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining(`Publish the filled Phase ${phaseNumber[phase]}`) }); f.events.pop(); f.message(`Phase ${phaseNumber[phase]} complete.`); f.current(); expect(f.evaluate()).toEqual({ allow: true }); }); test('historical driver, methodology and previously unread snapshot recovery does not reopen a phase', () => { const { f, first, unread, tasks } = completedCycle(); for (const [id, file] of [['driver', path.join(ROOT, 'autoplan/sections/ceo-phase.md')], ['methodology', first.get('design')!.method], ['snapshot', unread.snapshot.snapshotPath]] as const) f.read(`history-${id}`, file); f.input.tool_input = { file_path: tasks }; f.current(); expect(f.evaluate()).toEqual({ allow: true }); }); test('an old close and a new report cannot close a different rerun checkpoint', () => { const { f, tasks } = completedCycle(), create = checkpointWriter(f); const oldClose = f.events.find(e => e.kind === 'use' && e.toolUseId === 'eng-close')!; create('eng', 'new-generation'); f.read('old-close-replayed', oldClose.input!.file_path as string); f.message('Phase 3 complete.'); f.input.tool_input = { file_path: tasks }; f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('earlier checkpoint') }); }); for (const status of ['partial', 'failed', 'pending'] as const) test(`a ${status} rerun close cannot borrow the first cycle's completed report`, () => { const { f, create, tasks } = completedCycle(), rerun = create('eng', 'rerun'); const close = preparePhaseClose('eng', f.active, rerun.snapshot.snapshotPath, f.restore, rerun.method); f.read('new-close', close.closePacketPath, 1, status === 'partial' ? 1 : undefined); if (status === 'failed') f.events.at(-1)!.isError = true; if (status === 'pending') f.events.pop(); f.input.tool_input = { file_path: tasks }; f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Read every line') }); }); for (const kind of ['failed', 'unpaired', 'orphan-result', 'result-before-use', 'foreign-active', 'foreign-restore', 'reflected-old', 'altered-identity', 'altered-prompt', 'altered-baseline'] as const) test(`a ${kind} checkpoint result cannot reopen historical phase state`, () => { const { f, create, first, tasks } = completedCycle(); const old = first.get('eng')!.snapshot, fresh = create('eng', 'untrusted-create'); const use = f.events.at(-2)!, result = f.events.at(-1)!; if (kind === 'failed') result.isError = true; if (kind === 'unpaired') f.events.pop(); if (kind === 'orphan-result') f.events.splice(-2, 1); if (kind === 'result-before-use') f.events.splice(-2, 2, result, use); let output = fresh.snapshot; if (kind === 'reflected-old') output = old; if (kind === 'foreign-active' || kind === 'foreign-restore') { const foreign = fixture('eng', 'tasks'); output = createSnapshot('eng', foreign.active, foreign.restore, foreign.method); if (kind === 'foreign-restore') { const method = prepareMethodology('eng', path.join(foreign.cwd, 'SKILL.md'), foreign.restore).methodologyPath; output = createSnapshot('eng', f.active, foreign.restore, method); } } if (kind === 'altered-identity') output = { ...output, sourceBytes: output.sourceBytes + 1 }; if (kind === 'altered-prompt') output = { ...output, nativePrompt: output.nativePrompt + 'Forged.' }; if (kind === 'altered-baseline') output = { ...output, baselineEdits: { ...output.baselineEdits, record: 'Forged checkpoint.' } }; result.content = JSON.stringify(output); f.reorder(); f.input.tool_input = { file_path: tasks }; f.current(); expect(f.evaluate()).toEqual({ allow: true }); }); for (const operation of ['prepare-close', 'amend-input'] as const) test(`an internal ${operation} export does not start a new checkpoint generation`, () => { const { f, first, tasks } = completedCycle(), prior = first.get('eng')!; f.use('internal-export', 'Bash', { command: operation }); const output = operation === 'prepare-close' ? preparePhaseClose('eng', f.active, prior.snapshot.snapshotPath, f.restore, prior.method) : prepareAmendedInput('eng', f.active, prior.snapshot.snapshotPath, f.restore, prior.method); f.result('internal-export', { content: JSON.stringify(output) }); f.input.tool_input = { file_path: tasks }; f.current(); expect(f.evaluate()).toEqual({ allow: true }); }); test('CEO second voice creation preserves its fixed Step-0 checkpoint', () => { const f = fixture(); f.events.splice(4); const create = checkpointWriter(f); const step0 = create('ceo', 'step0'), voice = create('ceo', 'voice'); f.read('voice-input', voice.snapshot.snapshotPath); const close = preparePhaseClose('ceo', f.active, step0.snapshot.snapshotPath, f.restore, step0.method); f.read('step0-close', close.closePacketPath); f.message(); f.current(); expect(f.evaluate()).toEqual({ allow: true }); }); test('preparing a later checkpoint cannot advance past an unpublished rerun', () => { const { f, create, tasks } = completedCycle(); const design = create('design', 'design-rerun'); create('eng', 'prepared-eng'); f.input.tool_input = { file_path: tasks }; f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Phase 2 close procedure') }); f.events.pop(); f.read('current-recovery', design.method); f.input.tool_input = { file_path: design.method }; f.current(); expect(f.evaluate()).toEqual({ allow: true }); }); function changedDirectory(publish = true, next = 'design-phase.md') { const f = fixture(); const registry = path.join(f.cwd, 'registry'); fs.mkdirSync(registry); fs.symlinkSync(path.join(ROOT, 'autoplan'), path.join(registry, 'autoplan')); const currentCwd = fs.realpathSync(path.join(registry, 'autoplan/sections')); f.use('cd', 'Bash', { command: `cd '${registry}/autoplan/sections' && pwd` }); f.result('cd', { content: currentCwd }); if (publish) f.message(); f.input.cwd = currentCwd; f.input.tool_input = { file_path: next }; f.current(); const { rows } = f.journal(); const change = rows.findIndex(r => Array.isArray(r.message.content) && r.message.content.some((b: any) => b.type === 'tool_result' && b.tool_use_id === 'cd')); for (const row of rows.slice(change)) row.cwd = currentCwd; const save = () => fs.writeFileSync(f.input.transcript_path, rows.map(r => JSON.stringify(r)).join('\n') + '\n'); save(); return { f, rows, save }; } test('native project ownership survives a Bash cd through the installed skill link', async () => { const { f } = changedDirectory(); expect(readOwnedClaudePublicTranscript(f.input.transcript_path, f.input.cwd, f.sessionId).transcript.status).toBe('missing'); expect(await withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT))).toEqual(OWNED_READ_APPROVAL); }); test('native project ownership still requires publication and permits same-phase repair after cd', async () => { const { f } = changedDirectory(false); const output: any = await withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT)); expect(output.hookSpecificOutput.permissionDecisionReason).toContain('Publish the filled Phase 1'); const same = changedDirectory(false, 'ceo-phase.md'); expect(await withNativeProjectDirectory(same.f.cwd, () => runPublicationHook(same.f.input, ROOT))).toEqual(OWNED_READ_APPROVAL); }); test('the literal init parser retains Windows drive and UNC identities', () => { const source = fs.readFileSync(path.join(ROOT, 'autoplan/bin/phase-publication-hook.ts'), 'utf8'); const fn = source.slice(source.indexOf('function initArguments('), source.indexOf('\nfunction invocation(')); const parse = new Function('path', 'fs', 'process', 'ownPath', 'nativePathSpelling', 'samePath', new Bun.Transpiler({ loader: 'ts' }).transformSync(fn) + '\nreturn initArguments;')( path.win32, { realpathSync: (file: string) => file }, { platform: 'win32' }, (value: unknown) => ownedNativePath(value, path.win32), nativePathSpelling, (a: unknown, b: unknown) => sameNativePath(a, b, path.win32)); for (const root of [String.raw`C:\repo`, String.raw`\\server\share\repo`]) { const args = ['source.md', 'active.md', 'restore.md'].map(file => path.win32.join(root, file)); const script = path.win32.join(root, 'bin/gstack-autoplan-snapshot.ts'); const singleQuoted = [script, ...args].map(value => `'${value}'`); const command = `bun ${singleQuoted[0]} init ${singleQuoted.slice(1).join(' ')}`; expect(parse(command, root)).toEqual(args); const forward = [script, ...args].map(value => '"' + value.replaceAll('\\', '/') + '"'); expect(parse(`bun ${forward[0]} init ${forward.slice(1).join(' ')}`, root)).toEqual(args); if (!root.startsWith('\\\\')) { const native = [script, ...args].map(value => '"' + value + '"'); expect(parse(`bun ${native[0]} init ${native.slice(1).join(' ')}`, root)).toEqual(args); // gstack-paths prints /c/...; Git Bash hands bun C:/... for the same file. const msys = [script, ...args].map(value => '"/c/' + value.slice(3).replaceAll('\\', '/') + '"'); expect(parse(`bun ${msys[0]} init ${msys.slice(1).join(' ')}`, root)).toEqual(args); const lower = [script, ...args].map(value => `'c${value.slice(1)}'`); expect(parse(`bun ${lower[0]} init ${lower.slice(1).join(' ')}`, root)).toEqual(args); for (const bad of ['/cc/repo/source.md', '/c', '/c/repo/../source.md']) expect(parse(`bun "${msys[0]!.slice(1, -1)}" init "${bad}" ${msys.slice(2).join(' ')}`, root)?.[0]).not.toBe(args[0]); } for (const bad of [command + ' && true', command.replace('source.md', String.raw`..\source.md`), command.replace('source.md', 'nested/../source.md'), command.replace(script, path.win32.join(root, 'foreign.ts'))]) { expect(parse(bad, root)).toBeUndefined(); } } }); const withReadLimit = async (bytes: number, run: () => T | Promise): Promise => { const prior = process.env.GSTACK_TRANSCRIPT_TEST_MAX_BYTES; process.env.GSTACK_TRANSCRIPT_TEST_MAX_BYTES = String(bytes); try { return await run(); } finally { if (prior === undefined) delete process.env.GSTACK_TRANSCRIPT_TEST_MAX_BYTES; else process.env.GSTACK_TRANSCRIPT_TEST_MAX_BYTES = prior; } }; test('CEO-2 + UC1: a journal larger than the record bound reads; one record one byte over it is an unverified allow naming its size, code and anchor', async () => { const f = fixture(); f.message(); f.current(); f.journal(); const size = fs.statSync(f.input.transcript_path).size; const record = Math.max(...fs.readFileSync(f.input.transcript_path, 'utf8').split('\n').map(line => Buffer.byteLength(line))); expect(size).toBeGreaterThan(record); await withReadLimit(record, async () => { expect(readOwnedClaudePublicTranscript(f.input.transcript_path, f.input.cwd, f.sessionId).transcript.status).toBe('ready'); expect(await withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT))).toEqual(OWNED_READ_APPROVAL); }); const output: any = await withReadLimit(record - 1, async () => { expect(readOwnedClaudePublicTranscript(f.input.transcript_path, f.input.cwd, f.sessionId).transcript.reason).toBe('too_large'); return withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT)); }); // Claude Code wrote a journal the guard cannot verify: allow, warn visibly, never deny. expect(output.hookSpecificOutput.permissionDecision).toBeUndefined(); const warning: string = output.systemMessage; expect(warning).toContain('phase publication was NOT verified'); expect(warning).toMatch(/[\d.]+ MiB, over the [\d.]+ MiB record limit/); expect(warning).not.toContain(' 0 MiB'); expect(warning).toContain('code too_large'); expect(warning).toContain('docs/autoplan-guard-troubleshooting.md#too-large'); expect(warning).toContain('Phase-report enforcement was skipped'); expect(output.hookSpecificOutput.additionalContext).toContain('true autoplan-published '); }); test('#3050: the batch read and the narrowed parent loop report too_large, not a plain error', async () => { const f = fixture(); f.message(); f.current(); f.journal(); const size = fs.statSync(f.input.transcript_path).size; const config = path.dirname(path.dirname(path.dirname(f.input.transcript_path))); await withReadLimit(size - 1, () => { const batch = readPlanCountTranscript(config, f.input.cwd, () => {}); expect([batch.status, batch.reason]).toEqual(['error', 'too_large']); const narrowed = readPlanCountTranscript(config, f.input.cwd, () => {}, f.input.transcript_path); expect([narrowed.status, narrowed.reason]).toEqual(['error', 'too_large']); }); }); test('#3050: the test seam can only lower the cap', async () => { await withReadLimit(OWNED_TRANSCRIPT_MAX_BYTES * 4, () => expect(transcriptReadLimit()).toBe(OWNED_TRANSCRIPT_MAX_BYTES)); }); test('#3050: a real identity failure still reports identity', () => { const f = fixture(); f.message(); f.current(); f.journal(); const other = path.join(path.dirname(f.input.transcript_path), 'not-this-session.jsonl'); fs.copyFileSync(f.input.transcript_path, other); expect(readOwnedClaudePublicTranscript(other, f.input.cwd, f.sessionId).transcript.reason).toBe('identity'); }); test('native hook accepts platform paths without evaluating shell escapes', async () => { // The same actual hook receives native separators in Windows CI. const f = fixture(); f.message(); f.current(); f.journal(); expect(await withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT))).toEqual(OWNED_READ_APPROVAL); const command = f.events[0]!.input!.command as string; for (const suffix of ['; true', ' && true', ' | cat']) { f.events[0]!.input!.command = command + suffix; f.journal(); const output: any = await withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT)); expect(output.hookSpecificOutput.permissionDecision).toBe('deny'); } for (const escaped of ['\\$HOME', '\\`whoami\\`', '\\"quoted']) { f.events[0]!.input!.command = command.replace(f.source, f.source + escaped); f.journal(); const output: any = await withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT)); expect(output.hookSpecificOutput.permissionDecision).toBe('deny'); } }); test('native project ownership preserves the absent-env same-directory adapter', async () => { const f = fixture(); f.message(); f.current(); f.journal(); expect(await withNativeProjectDirectory(undefined, () => runPublicationHook(f.input, ROOT))).toEqual(OWNED_READ_APPROVAL); }); for (const project of ['absent', 'empty', 'relative', 'foreign', 'unnormalized'] as const) { test.serial(`native project ownership rejects ${project} original-directory evidence after cd`, async () => { const { f } = changedDirectory(); const value = project === 'absent' ? undefined : project === 'empty' ? '' : project === 'relative' ? 'relative' : project === 'foreign' ? path.join(f.cwd, 'foreign') : f.cwd + '/.'; const output: any = await withNativeProjectDirectory(value, () => withPublicationClock(() => runPublicationHook(f.input, ROOT))); expect(output.hookSpecificOutput.permissionDecision).toBe('deny'); }); } for (const mutation of ['foreign-root', 'sidechain', 'dangling', 'competing-root', 'duplicate-root', 'foreign-session', 'wrong-current-input', 'incomplete-current'] as const) { test.serial(`native project ownership retains ${mutation} rejection after cd`, async () => { const { f, rows, save } = changedDirectory(); const current = rows.at(-1)!; if (mutation === 'foreign-root') rows[0]!.cwd = path.join(f.cwd, 'foreign'); if (mutation === 'sidechain') current.isSidechain = true; if (mutation === 'dangling') current.parentUuid = randomUUID(); if (mutation === 'competing-root') rows.push({ ...rows[0]!, uuid: randomUUID() }); if (mutation === 'duplicate-root') rows.push({ ...rows[0]! }); if (mutation === 'foreign-session') current.sessionId = randomUUID(); if (mutation === 'wrong-current-input') f.input.tool_input = { file_path: 'eng-phase.md' }; save(); if (mutation === 'incomplete-current') { const bytes = fs.readFileSync(f.input.transcript_path, 'utf8'); fs.writeFileSync(f.input.transcript_path, bytes.slice(0, -1)); } const output: any = await withNativeProjectDirectory(f.cwd, () => withPublicationClock(() => runPublicationHook(f.input, ROOT))); expect(output.hookSpecificOutput.permissionDecision).toBe('deny'); }); } for (const [phase, next] of [['ceo', 'design'], ['design', 'dx'], ['design', 'eng'], ['dx', 'eng'], ['eng', 'tasks']] as const) { test(`${phase} closes before ${next}; only the parent publication unlocks entry`, () => { const f = fixture(phase, next); f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Publish the filled') }); f.events.pop(); f.message(); f.current(); expect(f.evaluate()).toEqual({ allow: true }); }); } for (const capture of captured.cases) test(`original ${capture.phase} omission remains a failure through the complete guard adapter`, () => { const next = capture.native.next[0] as any, ack = capture.native.next[1] as any; const close = capture.native.close[1] as any; expect(close.file.startLine).toBe(1); expect(close.file.numLines).toBe(close.file.totalLines); const actualTranscript = { status: 'ready' as const, calls: [], assistantMessages: capture.messages }; expect(prematureAutoplanPhaseEntry([...capture.native.close, ...capture.native.next] as any, actualTranscript, [{ phase: capture.nextPhase as any, requiredPhase: phaseNumber[capture.phase as Phase] as any, paths: [next.input.file_path], content: ack.file.content }], 0)).toMatchObject({ readToolUseId: next.toolUseId }); // Explicit adapter: current filesystem identities come from actual snapshot // APIs. Every captured parent message remains complete and unchanged; no // original artifact or paid outcome is rewritten to manufacture a success. const f = fixture(capture.phase as Phase, capture.nextPhase); const nativeInit: any = capture.native.init[0], initResult = JSON.parse((capture.native.init[1] as any).content); (f.events[0] as any).input.command = nativeInit.input.command .replace('/home/vercel-sandbox/gstack/bin/gstack-autoplan-snapshot.ts', `${ROOT}/bin/gstack-autoplan-snapshot.ts`) .replace(initResult.sourcePlan, f.source).replace(initResult.activePlan, f.active).replace(initResult.restorePath, f.restore); [...capture.native.init, ...capture.native.entry, ...capture.native.close].forEach((e, i) => { f.events[i]!.timestamp = e.timestamp; }); for (const m of capture.messages) f.add({ kind: 'message', text: m.text, timestamp: m.timestamp }); f.events.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); f.reorder(); const closeOrder = f.events.find(e => e.kind === 'result' && e.toolUseId === 'close')!.order; expect(capture.messages.filter(m => m.timestamp > close.timestamp && m.timestamp < next.timestamp)).toEqual([]); f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Publish the filled') }); f.events.pop(); f.message(`Phase ${phaseNumber[capture.phase as Phase]} complete.`); f.current(); expect(f.events.filter(e => e.kind === 'message').at(-1)!.order).toBeGreaterThan(closeOrder); expect(f.evaluate()).toEqual({ allow: true }); // Counterfactual recovery only. expect(capture.behaviorCredit).toBe(0); }); for (const text of ['Phase 2 complete.', 'Phase 1 will be complete.', 'Phase 1 complete if the reviewer succeeds.', '> Phase 1 complete.', '```\nPhase 1 complete.\n```', ' Phase 1 complete.', 'Example:\nPhase 1 complete.', 'Phase 1 is not complete.', 'Phase 1 complete with all review work finished.\nCorrection: this phase is withdrawn.']) { test(`does not use noncurrent publication: ${JSON.stringify(text)}`, () => { const f = fixture(); f.message(text); f.current(); expect(f.evaluate().allow).toBe(false); }); } for (const mutation of ['no-close', 'partial-close', 'error-close', 'missing-ack', 'foreign-session', 'duplicate-use', 'pre-close-publication', 'after-current-publication', 'changed-plan', 'modified-immutable-packet', 'aliased-packet', 'wrong-init', 'failed-init', 'missing-init', 'forged-init-command', 'wrong-restore', 'pending-phase', 'ambiguous-order'] as const) { test(`rejects ${mutation}`, () => { const f = fixture(); f.message(); f.current(); if (mutation === 'no-close') f.events.splice(4, 2); if (mutation === 'partial-close') (f.events[5] as any).file.numLines--; if (mutation === 'error-close') (f.events[5] as any).isError = true; if (mutation === 'missing-ack') f.events.splice(5, 1); if (mutation === 'foreign-session') f.events[6]!.sessionId = randomUUID(); if (mutation === 'duplicate-use') f.events.splice(5, 0, structuredClone(f.events[4]!)); if (mutation === 'pre-close-publication') f.events.splice(4, 0, ...f.events.splice(6, 1)); if (mutation === 'after-current-publication') f.events.push(...f.events.splice(6, 1)); if (mutation === 'changed-plan') fs.writeFileSync(f.active, fs.readFileSync(f.active, 'utf8').replace('Keep documented behavior.', 'Change behavior.')); if (mutation === 'modified-immutable-packet') { fs.chmodSync(f.packet.closePacketPath, 0o644); // Windows has no POSIX write-bit contract; preserve the rejection // through the actual artifact bytes instead of a no-op chmod. if (process.platform === 'win32') fs.appendFileSync(f.packet.closePacketPath, '\nChanged after the native Read.\n'); } if (mutation === 'aliased-packet') { const alias = path.join(f.cwd, 'packet'); fs.renameSync(f.packet.closePacketPath, alias); fs.symlinkSync(alias, f.packet.closePacketPath); } if (mutation === 'wrong-init') (f.events[1] as any).content = JSON.stringify({ ...f.init, activePlan: f.source }); if (mutation === 'failed-init') (f.events[1] as any).isError = true; if (mutation === 'missing-init') f.events.splice(0, 2); if (mutation === 'forged-init-command') (f.events[0] as any).input.command = `printf '${JSON.stringify(f.init)}'`; if (mutation === 'wrong-restore') { fs.chmodSync(f.restore, 0o600); fs.writeFileSync(f.restore, 'foreign'); fs.chmodSync(f.restore, 0o400); } if (mutation === 'pending-phase') f.events.splice(6, 0, { ...(f.events[7] as any), toolUseId: 'parallel' }); f.reorder(); if (mutation === 'ambiguous-order') f.events[5]!.order = f.events[4]!.order; expect(f.evaluate().allow).toBe(false); }); } test('#3045: no init at all keeps the generic missing-evidence denial', () => { const f = fixture(); f.message(); f.current(); f.events.splice(0, 2); f.reorder(); const reason = (f.evaluate() as any).reason; expect(reason).toContain('Complete the existing snapshot init step'); expect(reason).not.toContain('cannot bind'); }); for (const [name, command] of [ ['a snapshot scope call', (f: any) => `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" scope "${f.active}"`], ['an unrelated init', (f: any) => `bun other.ts init "${f.source}"`], ] as const) test(`#3045 control: ${name} keeps the generic missing-evidence denial`, () => { const f = fixture(); f.message(); f.current(); (f.events[0] as any).input.command = command(f); const reason = (f.evaluate() as any).reason; expect(reason).toContain('Complete the existing snapshot init step'); expect(reason).not.toContain('cannot bind'); }); for (const [name, command] of [ ['a substituted tool path', (f: any) => `bun "$(realpath ${ROOT}/bin/gstack-autoplan-snapshot.ts)" init "${f.source}" "${f.active}" "${f.restore}"`], ['a redirected literal init', (f: any) => `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" init "${f.source}" "${f.active}" "${f.restore}" > /dev/null`], ] as const) test(`#3045: ${name} is named as the cause`, () => { const f = fixture(); f.message(); f.current(); (f.events[0] as any).input.command = command(f); expect((f.evaluate() as any).reason).toContain('which this guard cannot bind'); }); for (const [name, command] of [ ['a resolved-variable init piped to tail', (f: any) => `ST=$(bun -e 'console.log(require("fs").realpathSync(process.argv[1]))' "${ROOT}/bin/gstack-autoplan-snapshot.ts"); bun "$ST" init "${f.source}" "${f.active}" "${f.restore}" 2>&1 | tail -5`], ['an init through a variable from an earlier call', (f: any) => `bun "$SNAPSHOT_TOOL" init "${f.source}" "${f.active}" "${f.restore}"`], ['a chained literal init', (f: any) => `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" init "${f.source}" "${f.active}" "${f.restore}" && echo done`], ] as const) test(`#3045: ${name} is named as the cause with the literal re-run; only the literal re-run binds`, () => { const f = fixture(); f.message(); f.current(); (f.events[0] as any).input.command = command(f); const reason = (f.evaluate() as any).reason; expect(reason).toContain('which this guard cannot bind'); expect(reason).toContain('`bun "" init "" "" ""`'); f.events.splice(2, 0, { ...(structuredClone(f.events[0]!) as any), toolUseId: 're-init', input: { command: `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" init "${f.source}" "${f.active}" "${f.restore}"` } }, { ...(structuredClone(f.events[1]!) as any), toolUseId: 're-init', content: JSON.stringify({ ...f.init, reused: true }) }); f.reorder(); expect(f.evaluate()).toEqual({ allow: true }); }); test('split successful close ranges through EOF are sufficient', () => { const f = fixture(); f.events.splice(4, 2); f.read('part1', f.packet.closePacketPath, 1, 10); f.read('part2', f.packet.closePacketPath, 11); f.message(); f.current(); expect(f.evaluate()).toEqual({ allow: true }); }); test('a later incomplete close supersedes the earlier complete close and publication', () => { const f = fixture(); f.message(); const newer = preparePhaseClose('ceo', f.active, f.checkpoint, f.restore, f.method); f.read('new-close', newer.closePacketPath, 1, 1); f.current(); expect(f.evaluate().allow).toBe(false); }); test('an ordinary foreign close-packet filename cannot supersede the owned phase close', () => { const f = fixture(), foreign = path.join(f.cwd, 'project', 'close-packet.md'); fs.mkdirSync(path.dirname(foreign)); fs.writeFileSync(foreign, 'Ordinary project data.\n'); f.read('unrelated', foreign); f.message(); f.current(); expect(f.evaluate()).toEqual({ allow: true }); f.events.splice(4, 2); f.reorder(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Finish the existing Phase 1 close procedure') }); }); test('a malformed later packet inside the owned phase still invalidates old publication', () => { const f = fixture(); f.message(); const dir = fs.mkdtempSync(path.join(f.cwd, 'autoplan-ceo-')), packet = path.join(dir, 'close-packet.md'); fs.writeFileSync(packet, 'Malformed current packet.\n', { mode: 0o444 }); f.read('malformed-owned', packet); f.current(); expect(f.evaluate().allow).toBe(false); }); for (const outcome of ['success', 'pending', 'failed'] as const) test(`active plan mutation after close: ${outcome}`, () => { const f = fixture(); f.message(); f.use('late-edit', 'Edit', { file_path: f.active, old_string: 'current', new_string: 'changed' }); if (outcome !== 'pending') f.result('late-edit', { isError: outcome === 'failed', content: outcome }); f.current(); expect(f.evaluate().allow).toBe(outcome === 'failed'); }); test('a new initialization excludes earlier phase publications', () => { const f = fixture(); f.message(); // Same initialized plan can be a new source, but a new external restore // identity is required by the actual init API. No old completion carries. const active = path.join(f.cwd, 'new-active.md'), restore = path.join(f.cwd, 'new-restore.md'); const init = initializePlan(f.source, active, restore); f.use('new-init', 'Bash', { command: `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" init "${f.source}" "${active}" "${restore}"` }); f.result('new-init', { content: JSON.stringify(init) }); f.read('new-entry', path.join(ROOT, 'autoplan/sections/ceo-phase.md')); f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Finish the existing Phase 1 close procedure') }); }); test('cached native Read reenters the current invocation without carrying its prior publication', () => { const f = fixture(); f.message(); const active = path.join(f.cwd, 'fresh-active.md'), restore = path.join(f.cwd, 'fresh-restore.md'); const fresh = initializePlan(f.source, active, restore); f.add({ kind: 'end_turn' }); f.add({ kind: 'user_turn', autoplan: true }); f.use('fresh-init', 'Bash', { command: `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" init "${f.source}" "${active}" "${restore}"` }); f.result('fresh-init', { content: JSON.stringify(fresh) }); const file = path.join(ROOT, 'autoplan/sections/ceo-phase.md'); f.use('cached-entry', 'Read', { file_path: file }); f.result('cached-entry', { content: 'Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.', file: { filePath: file } }); f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Finish the existing Phase 1 close procedure') }); }); for (const [phase, next] of [['ceo', 'design'], ['design', 'dx'], ['dx', 'eng'], ['eng', 'tasks']] as const) { test(`cached ${phase} entry retains that phase's publication barrier`, () => { const f = fixture(phase, next), priorUse: any = structuredClone(f.events[2]), priorResult: any = structuredClone(f.events[3]); priorUse.toolUseId = priorResult.toolUseId = 'prior-entry'; const result: any = f.events[3]; result.content = 'Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.'; result.file = { filePath: priorResult.file.filePath }; f.events.unshift(priorUse, priorResult); f.reorder(); f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining(`Publish the filled Phase ${phaseNumber[phase]}`) }); f.events.pop(); f.message(); f.current(); expect(f.evaluate()).toEqual({ allow: true }); }); test(`cached ${phase} close still needs publication after its new ACK`, () => { const f = fixture(phase, next); f.message(); f.use('cache-close', 'Read', { ...(f.events[4] as any).input }); f.result('cache-close', { content: 'Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.', file: { filePath: f.packet.closePacketPath } }); f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining(`Publish the filled Phase ${phaseNumber[phase]}`) }); f.events.pop(); f.message(); f.current(); expect(f.evaluate()).toEqual({ allow: true }); }); } test('cached incomplete close ranges never turn into a complete close', () => { const f = fixture(), prior: any = f.events[5], request: any = f.events[4]; request.input.limit = 1; prior.file.content = prior.file.content.split('\n')[0]; prior.file.numLines = 1; f.use('cache-close', 'Read', { ...request.input }); f.result('cache-close', { content: 'Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.', file: { filePath: f.packet.closePacketPath } }); f.message(); f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Read every line') }); }); for (const mutation of ['none', 'cache-chain', 'same-range', 'duplicate-prior-use', 'duplicate-prior-result', 'conflicting-prior-use', 'missing-prior', 'stale-body', 'missing-body', 'wrong-total', 'foreign-path', 'foreign-session', 'pending-prior', 'failed-prior', 'ambiguous-prior', 'late-prior-ACK', 'changed-offset', 'changed-limit', 'failed-cache', 'unknown-cache', 'quoted-cache', 'seeded-cache', 'extra-cache-metadata'] as const) { test(`native cached Read range authentication: ${mutation}`, () => { const f = fixture(), use: any = structuredClone(f.events[2]), result: any = structuredClone(f.events[3]); const content = fs.readFileSync(use.input.file_path, 'utf8'); let prior: any[] = [use, result]; const current: any = { ...structuredClone(use), toolUseId: 'cache', order: 10 }; const ack: any = { kind: 'result', sessionId: f.sessionId, toolUseId: 'cache', isError: false, order: 11, content: 'Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.', file: { filePath: use.input.file_path } }; if (mutation === 'cache-chain') prior.push({ ...structuredClone(current), toolUseId: 'prior-cache', order: 5 }, { ...structuredClone(ack), toolUseId: 'prior-cache', order: 6 }); if (mutation === 'same-range') { use.input.offset = current.input.offset = 2; use.input.limit = current.input.limit = 1; result.file.startLine = 2; result.file.numLines = 1; result.file.content = content.split('\n')[1]; } if (mutation === 'duplicate-prior-use') prior.push({ ...structuredClone(use), order: 4 }); if (mutation === 'duplicate-prior-result') prior.push({ ...structuredClone(result), order: 4 }); if (mutation === 'conflicting-prior-use') prior.push({ ...structuredClone(use), input: { file_path: use.input.file_path + '.foreign' }, order: 4 }); if (mutation === 'missing-prior') prior = []; if (mutation === 'stale-body') result.file.content += 'changed'; if (mutation === 'missing-body') delete result.file.content; if (mutation === 'wrong-total') result.file.totalLines++; if (mutation === 'foreign-path') use.input.file_path += '.foreign'; if (mutation === 'foreign-session') use.sessionId = result.sessionId = 'foreign'; if (mutation === 'pending-prior') prior.pop(); if (mutation === 'failed-prior') result.isError = true; if (mutation === 'ambiguous-prior') prior.push({ ...result, isError: true, order: 4 }); if (mutation === 'late-prior-ACK') result.order = 12; if (mutation === 'changed-offset') current.input.offset = 2; if (mutation === 'changed-limit') current.input.limit = 1; if (mutation === 'failed-cache') ack.isError = true; if (mutation === 'unknown-cache') delete ack.isError; if (mutation === 'quoted-cache') ack.content = `"${ack.content}"`; if (mutation === 'seeded-cache') ack.content = 'This file is already in your context (see "Contents" above) and has not changed on disk. Use that content instead of re-reading.'; if (mutation === 'extra-cache-metadata') ack.file.content = 'unverified'; const range = autoplanReadRange(current, ack, content, prior); if (['none', 'cache-chain', 'same-range', 'duplicate-prior-use', 'duplicate-prior-result'].includes(mutation)) expect(range).toEqual(mutation === 'same-range' ? { start: 2, end: 2 } : { start: 1, end: content.split('\n').length }); else expect(range).toBeUndefined(); }); } for (const mutation of ['missing-prior', 'stale-body', 'foreign-body', 'failed-cache', 'seeded-cache'] as const) test(`unverified cached initial entry cannot bypass the next phase: ${mutation}`, () => { const f = fixture(), oldUse: any = structuredClone(f.events[2]), oldResult: any = structuredClone(f.events[3]); oldUse.toolUseId = oldResult.toolUseId = 'old-read'; f.events.splice(4); (f.events[3] as any).file = { filePath: oldUse.input.file_path }; (f.events[3] as any).content = 'Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.'; if (mutation === 'stale-body') oldResult.file.content += 'different'; if (mutation === 'foreign-body') oldUse.input.file_path += '.foreign'; if (mutation === 'failed-cache') (f.events[3] as any).isError = true; if (mutation === 'seeded-cache') (f.events[3] as any).content = 'This file is already in your context'; if (mutation !== 'missing-prior') f.events.unshift(oldUse, oldResult); f.reorder(); f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Phase 1') }); f.input.tool_input.file_path = path.join(ROOT, 'autoplan/sections/ceo-phase.md'); (f.events.at(-1) as any).input = f.input.tool_input; expect(f.evaluate()).toEqual({ allow: true }); }); test('an invocation with no observed phase cannot skip its initial CEO entry', () => { const f = fixture(); f.events.splice(2); f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Phase 1') }); }); for (const field of ['methodology', 'checkpoint', 'packet-phase'] as const) test(`a ${field} identity from another invocation cannot grant entry`, () => { const f = fixture(); f.message(); f.current(); if (field === 'methodology') { const file = path.join(path.dirname(f.method), 'methodology.json'), manifest = JSON.parse(fs.readFileSync(file, 'utf8')); manifest.restorePath = f.source; fs.chmodSync(file, 0o644); fs.writeFileSync(file, JSON.stringify(manifest) + '\n'); fs.chmodSync(file, 0o444); } else if (field === 'checkpoint') { fs.chmodSync(f.checkpoint, 0o644); fs.writeFileSync(f.checkpoint, 'different baseline'); fs.chmodSync(f.checkpoint, 0o444); } else { const file = f.packet.closePacketPath; fs.chmodSync(file, 0o644); fs.writeFileSync(file, fs.readFileSync(file, 'utf8').replace('"phase":"ceo"', '"phase":"design"')); fs.chmodSync(file, 0o444); } expect(f.evaluate().allow).toBe(false); }); test('same-phase and ordinary methodology reads do not require a close', () => { const f = fixture(); f.events.splice(4, 2); f.input.tool_input.file_path = path.join(ROOT, 'autoplan/sections/ceo-phase.md'); f.current(); expect(f.evaluate()).toEqual({ allow: true }); f.input.tool_input.file_path = f.method; expect(f.evaluate()).toEqual({ allow: true }); }); test('installed symlink identity is accepted, a different installation is denied', () => { const f = fixture(); f.message(); const registry = path.join(f.cwd, 'registry'); fs.mkdirSync(registry); fs.symlinkSync(path.join(ROOT, 'autoplan'), path.join(registry, 'autoplan')); f.input.tool_input.file_path = path.join(registry, 'autoplan/sections/design-phase.md'); f.current(); expect(f.evaluate().allow).toBe(true); fs.unlinkSync(path.join(registry, 'autoplan')); fs.mkdirSync(path.join(registry, 'autoplan/sections'), { recursive: true }); fs.copyFileSync(path.join(ROOT, 'autoplan/sections/design-phase.md'), f.input.tool_input.file_path as string); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('different or unavailable installation') }); }); test('an explicit child is outside parent publication authority', () => { const f = fixture(); f.current(); f.input.agent_id = 'reviewer-child'; expect(f.evaluate()).toEqual({ allow: true }); }); test('same native timestamp still uses append order for report then Read', () => { const f = fixture(); f.message(); f.current(); f.events.forEach(e => { e.timestamp = new Date(clock).toISOString(); }); expect(f.evaluate()).toEqual({ allow: true }); }); test('a terminal turn followed by an authenticated new human request releases the old invocation', () => { const f = fixture(); f.add({ kind: 'end_turn' }); f.add({ kind: 'user_turn', autoplan: false }); f.current(); expect(f.evaluate()).toEqual({ allow: true }); (f.events[7] as any).autoplan = true; expect(f.evaluate().allow).toBe(false); }); test('end_turn alone and metadata-free tool results cannot release the invocation', () => { const f = fixture(); f.add({ kind: 'end_turn' }); f.result('other', { content: 'continue' }); f.current(); expect(f.evaluate().allow).toBe(false); }); test('a real same-restore init re-arms without erasing an outstanding publication', () => { const f = fixture(); f.add({ kind: 'end_turn' }); f.add({ kind: 'user_turn', autoplan: false }); f.use('re-init', 'Bash', (f.events[0] as any).input); f.result('re-init', { content: JSON.stringify({ ...f.init, reused: true }) }); f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Publish the filled') }); }); test.serial('unavailable journal polls every 50ms for the full 2000ms budget', async () => { const f = fixture(); await withNativeProjectDirectory(f.cwd, () => withPublicationClock(async () => { const started = performance.now(), timer = jest.spyOn(globalThis, 'setTimeout'); try { const output: any = await runPublicationHook(f.input, ROOT); expect(performance.now() - started).toBe(2_000); expect(timer.mock.calls.map(([, delay]) => delay)).toEqual(Array(40).fill(50)); expect(output.hookSpecificOutput.permissionDecision).toBe('deny'); expect(output.hookSpecificOutput.permissionDecisionReason).toContain('no missing-publication conclusion'); } finally { timer.mockRestore(); } })); }); test.serial('the publication clock restores native timers after success and failure', async () => { const timeout = globalThis.setTimeout, immediate = globalThis.setImmediate, now = performance.now; const failure = new Error('fixture failure'); const project = process.env.CLAUDE_PROJECT_DIR; for (const outcome of ['success', 'throw', 'over-budget']) { const pending = withNativeProjectDirectory(undefined, () => withPublicationClock(async () => { await new Promise(resolve => setTimeout(resolve, outcome === 'over-budget' ? 2_050 : 50)); if (outcome === 'throw') throw failure; return 'settled'; })); const result = await pending.then(value => value, error => error); if (outcome === 'throw') expect(result).toBe(failure); else if (outcome === 'over-budget') { expect(result).toBeInstanceOf(Error); expect(result.message).toContain('did not settle within its 2000ms polling budget'); } else expect(result).toBe('settled'); expect(jest.isFakeTimers()).toBe(false); expect(globalThis.setTimeout).toBe(timeout); expect(globalThis.setImmediate).toBe(immediate); expect(performance.now).toBe(now); expect(process.env.CLAUDE_PROJECT_DIR).toBe(project); } }); test.serial('the owned journal can arrive asynchronously on the last poll before the deadline', async () => { const f = fixture(); f.message(); f.current(); await withNativeProjectDirectory(f.cwd, () => withPublicationClock(async () => { const started = performance.now(); setTimeout(() => f.journal(), 1_950); expect(await runPublicationHook(f.input, ROOT)).toEqual(OWNED_READ_APPROVAL); expect(performance.now() - started).toBe(1_950); const decoded = readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId); expect(decoded.transcript.status).toBe('ready'); expect(decoded.events.some(e => e.kind === 'use' && e.toolUseId === 'next')).toBe(true); })); }); test('the actual owned native reader and asynchronous hook admit a flushed same-response report', async () => { const f = fixture(); f.message(); f.current(); setTimeout(() => f.journal(), 100); expect(await runPublicationHook(f.input, ROOT)).toEqual(OWNED_READ_APPROVAL); const decoded = readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId); expect(decoded.transcript.status).toBe('ready'); const report = decoded.events.find(e => e.kind === 'message')!, current = decoded.events.find(e => e.kind === 'use' && e.toolUseId === 'next')!; expect(report.order).toBeLessThan(current.order); }); test('a native in-flight range Read uses the established phase without inventing a journal record', async () => { const f = fixture(); const skill = path.join(f.cwd, 'streamed-skill', 'SKILL.md'); fs.mkdirSync(path.dirname(skill)); fs.writeFileSync(skill, '---\nname: plan-ceo-review\n---\n## Review Sections\n' + Array.from({ length: 1_200 }, (_, i) => `Review criterion ${i + 1}`).join('\n') + '\n'); const method = prepareMethodology('ceo', skill, f.restore).methodologyPath; f.input.tool_input = { file_path: method, offset: 601, limit: 600 }; f.journal(); const before = readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId); expect(before.transcript.status).toBe('ready'); expect(before.events.some(e => e.kind === 'use' && e.toolUseId === f.input.tool_use_id)).toBe(false); // Pinned Claude dispatches the native hook after the complete tool block, // before message_stop makes that current use available in the journal. expect(await withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT))).toEqual({}); expect(readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId)).toEqual(before); }); test('an in-flight Read can revisit an earlier established phase', async () => { const f = fixture('design', 'ceo'); f.journal(); expect(await withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT))).toEqual(OWNED_READ_APPROVAL); }); // CEO-1 replaced the pending-Read rule: the payload stands in for an unflushed // current call, so a first Phase 1 entry is allowed from it (it needs no prior // report), and a tool pending in an earlier message is journal lag (UC1: unverified). for (const [kind, expected] of [['initial-entry', 'allow'], ['forged-prior-range', 'allow'], ['pending-prior-entry', 'journal_lag']] as const) test.serial(`an in-flight native Read under CEO-1: ${kind} is ${expected}`, async () => { const f = fixture(); f.input.tool_input = { file_path: f.method, offset: 1, limit: 1 }; if (kind === 'initial-entry') f.events.splice(2); if (kind === 'pending-prior-entry') f.use('prior-pending', 'Read', { file_path: f.method }); if (kind === 'forged-prior-range') for (const event of f.events) if (event.kind === 'result' && event.file) (event.file as { content: string }).content += '\nForged native range.'; f.journal(); const output: any = await withNativeProjectDirectory(f.cwd, () => withPublicationClock(() => runPublicationHook(f.input, ROOT))); if (expected === 'allow') expect(output).toEqual({}); else { expect(output.hookSpecificOutput.permissionDecision).toBeUndefined(); expect(output.systemMessage).toContain('code journal_lag'); } }); for (const kind of ['new-phase', 'agent', 'foreign-methodology', 'duplicate', 'foreign-session', 'orphan-current-result', 'unpublished-predecessor', 'rearmed-human', 'malformed-journal', 'symlinked-journal'] as const) test.serial(`an in-flight native Read does not bypass ${kind}`, async () => { const f = fixture(); f.input.tool_input = { file_path: f.method, offset: 1, limit: 1 }; if (kind === 'new-phase') { f.message(); f.input.tool_input = { file_path: path.join(ROOT, 'autoplan/sections/design-phase.md') }; } if (kind === 'agent') { f.input.tool_name = 'Agent'; f.input.tool_input = { prompt: 'You are the independent CEO reviewer for this phase.\n' }; } if (kind === 'foreign-methodology') f.input.tool_input = { file_path: fixture().method }; if (kind === 'duplicate') f.events.push({ ...f.events[2]!, order: f.events.length }); if (kind === 'orphan-current-result') f.result(f.input.tool_use_id, { content: 'Forged current acknowledgment.' }); if (kind === 'unpublished-predecessor') { const later = path.join(ROOT, 'autoplan/sections/design-phase.md'); f.read('unguarded-later-entry', later); f.input.tool_input = { file_path: later }; } const { rows, record } = f.journal(); if (kind === 'rearmed-human') { rows.push(record('assistant', [], { message: { role: 'assistant', content: [], stop_reason: 'end_turn' } }), record('user', 'autoplan\n/autoplan', { origin: { kind: 'human' }, promptId: randomUUID() })); fs.writeFileSync(f.input.transcript_path, rows.map(r => JSON.stringify(r)).join('\n') + '\n'); } if (kind === 'malformed-journal') fs.appendFileSync(f.input.transcript_path, 'not a native JSON record\n'); if (kind === 'symlinked-journal') { const target = path.join(path.dirname(f.input.transcript_path), 'aliased.jsonl'); fs.renameSync(f.input.transcript_path, target); fs.symlinkSync(target, f.input.transcript_path); } if (kind === 'foreign-session') f.input.session_id = randomUUID(); const output: any = await withNativeProjectDirectory(f.cwd, () => withPublicationClock(() => runPublicationHook(f.input, ROOT))); expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); expect(readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId).events .filter(e => e.kind === 'use' && e.toolUseId === f.input.tool_use_id)).toHaveLength(0); }); test('owned reader refuses a symlink and incomplete current record', async () => { const f = fixture(); f.message(); f.current(); f.journal(); const bytes = fs.readFileSync(f.input.transcript_path, 'utf8'); fs.writeFileSync(f.input.transcript_path, bytes.slice(0, -1)); expect(readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId).events.some(e => e.kind === 'use' && e.toolUseId === 'next')).toBe(false); const other = path.join(f.cwd, 'other.jsonl'); fs.renameSync(f.input.transcript_path, other); fs.symlinkSync(other, f.input.transcript_path); expect(readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId).transcript.status).toBe('error'); }); for (const kind of ['human', 'missing-origin', 'meta', 'tool-result', 'no-end-turn', 'compact-summary'] as const) test(`native lifecycle projection: ${kind}`, async () => { const f = fixture(), { rows, record } = f.journal(); if (kind !== 'no-end-turn') rows.push(record('assistant', [{ type: 'text', text: 'Work ended.' }], { message: { role: 'assistant', id: 'msg_terminal', stop_reason: 'end_turn', content: [{ type: 'text', text: 'Work ended.' }] }, })); rows.push(record('system', undefined, { type: 'system', subtype: 'turn_duration', message: undefined })); const user = record('user', 'Read the driver for a new unrelated task.', { origin: { kind: 'human' }, promptSource: 'typed', promptId: randomUUID(), }) as any; if (kind === 'missing-origin') delete user.origin; if (kind === 'meta') user.isMeta = true; if (kind === 'tool-result') { delete user.origin; user.message.content = [{ type: 'tool_result', tool_use_id: 'answer', content: 'Continue' }]; } if (kind === 'compact-summary') { delete user.origin; user.isMeta = true; user.message.content = 'Phase 1 complete.'; } rows.push(user, record('assistant', [{ type: 'tool_use', id: 'next', name: 'Read', input: f.input.tool_input }])); fs.writeFileSync(f.input.transcript_path, rows.map(x => JSON.stringify(x)).join('\n') + '\n'); const decoded = readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId); expect(decoded.events.filter(e => e.kind === 'user_turn')).toHaveLength(kind === 'human' || kind === 'no-end-turn' ? 2 : 1); const result: any = await runPublicationHook(f.input, ROOT); if (kind === 'human') expect(result).toEqual(OWNED_READ_APPROVAL); else expect(result.hookSpecificOutput?.permissionDecision).toBe('deny'); }); test('compaction keeps real parent ancestry; its summary supplies no publication', async () => { const f = fixture(), { rows, record } = f.journal(); const prior = rows.at(-1)!; const boundary: any = record('system', undefined, { type: 'system', subtype: 'compact_boundary', parentUuid: null, logicalParentUuid: prior.uuid, message: undefined }); rows.push(boundary, record('user', 'Phase 1 complete.', { isMeta: true })); const current: any = record('assistant', [{ type: 'tool_use', id: 'next', name: 'Read', input: f.input.tool_input }]); rows.push(current); fs.writeFileSync(f.input.transcript_path, rows.map(x => JSON.stringify(x)).join('\n') + '\n'); const before: any = await runPublicationHook(f.input, ROOT); expect(before.hookSpecificOutput?.permissionDecision).toBe('deny'); // A real publication follows the retained close ACK, even with compaction. const message: any = { ...current, uuid: randomUUID(), message: { role: 'assistant', content: [{ type: 'text', text: 'Phase 1 complete.' }] } }; current.parentUuid = message.uuid; rows.splice(-1, 0, message); fs.writeFileSync(f.input.transcript_path, rows.map(x => JSON.stringify(x)).join('\n') + '\n'); expect(await runPublicationHook(f.input, ROOT)).toEqual(OWNED_READ_APPROVAL); boundary.logicalParentUuid = randomUUID(); fs.writeFileSync(f.input.transcript_path, rows.map(x => JSON.stringify(x)).join('\n') + '\n'); expect(readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId).events.some(e => e.kind === 'use' && e.toolUseId === 'next')).toBe(false); }); }); describe('Autoplan authenticated phase consumption', () => { function nextSnapshot(f: ReturnType) { const dir = path.join(f.cwd, 'next-skill'); fs.mkdirSync(dir); const skill = path.join(dir, 'SKILL.md'); fs.writeFileSync(skill, '---\nname: plan-eng-review\n---\n## Review Sections\nApply every engineering criterion.\n'); const method = prepareMethodology('eng', skill, f.restore).methodologyPath; return { method, snapshot: createSnapshot('eng', f.active, f.restore, method) }; } test('captured complete Bash driver delivery preserves the original earliest DX omission', () => { const request = consumption.events.find(e => e.kind === 'use' && e.name === 'Bash')!; const pair = consumption.events.filter(e => e.toolUseId === request.toolUseId); const transcript = { status: 'ready' as const, calls: [], assistantMessages: consumption.messages }; expect(prematureAutoplanPhaseEntry(pair as any, transcript, [{ phase: 'eng', requiredPhase: 2.5, paths: [path.join(ROOT, 'autoplan/sections/eng-phase.md')], content: consumption.driverContent }], 0)) .toMatchObject({ readToolUseId: request.toolUseId, requiredPhase: 2.5 }); }); test('an owned next methodology Read cannot bypass the missing current parent publication', () => { const f = fixture('dx', 'eng'), next = nextSnapshot(f); f.input.tool_input = { file_path: next.method }; f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Publish the filled Phase 2.5') }); }); test('an exact next native Agent dispatch cannot bypass the missing current parent publication', () => { const f = fixture('dx', 'eng'), next = nextSnapshot(f); f.input.tool_name = 'Agent'; f.input.tool_input = { prompt: next.snapshot.nativeDispatchPrompt }; f.use('next', 'Agent', f.input.tool_input); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Publish the filled Phase 2.5') }); }); const nextInput = (f: ReturnType, file: string) => { f.input.tool_input = { file_path: file }; f.current(); }; function target(f: ReturnType, kind: string) { const n = nextSnapshot(f); return kind === 'methodology' ? n.method : kind === 'methodology.json' ? path.join(path.dirname(n.method), kind) : path.join(path.dirname(n.snapshot.snapshotPath), kind === 'implementation' ? 'eng-implementation.md' : kind); } for (const kind of ['methodology', 'methodology.json', 'native-prompt.md', 'snapshot.json', 'source-implementation.md', 'implementation']) { test(`owned ${kind} consumption needs close and publication, then permits recovery`, () => { const f = fixture('dx', 'eng'), file = target(f, kind); const close = f.events.splice(4, 2); f.reorder(); nextInput(f, file); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Finish the existing Phase 2.5 close') }); f.events.pop(); f.events.push(...close); f.reorder(); f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Publish the filled Phase 2.5') }); f.events.pop(); f.message(); f.current(); expect(f.evaluate()).toEqual({ allow: true }); }); } test('actual captured parent wording does not publish DX or become tool-output credit', () => { const f = fixture('dx', 'eng'), next = nextSnapshot(f); for (const message of consumption.messages) f.message(message.text); f.use('print-report', 'Bash', { command: 'print-report' }); f.result('print-report', { content: 'Phase 2.5 complete.' }); nextInput(f, next.method); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Publish the filled Phase 2.5') }); }); for (const by of ['methodology', 'snapshot', 'close', 'Agent'] as const) test(`an owned ${by} delivery infers the actual phase without a driver Read`, () => { const f = fixture('dx', 'eng'); f.events.splice(2, 2); f.reorder(); if (by !== 'close') { const prior = f.events.splice(2); if (by === 'Agent') { const manifest = JSON.parse(fs.readFileSync(path.join(path.dirname(f.checkpoint), 'snapshot.json'), 'utf8')); f.use('native-dx', 'Agent', { prompt: manifest.nativeDispatchPrompt }); f.result('native-dx', { content: 'Native reviewer launched.' }); } else f.read('owned-entry', by === 'methodology' ? f.method : path.join(path.dirname(f.checkpoint), 'snapshot.json')); f.events.push(...prior); f.reorder(); } f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Publish the filled Phase 2.5') }); f.events.pop(); f.message(); f.current(); expect(f.evaluate()).toEqual({ allow: true }); }); test('an already delivered unguarded future methodology does not erase the unpublished predecessor', () => { const f = fixture('dx', 'eng'), next = nextSnapshot(f); f.read('unguarded-future', next.method); f.input.tool_name = 'Agent'; f.input.tool_input = { prompt: next.snapshot.nativeDispatchPrompt }; f.use('next', 'Agent', f.input.tool_input); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Publish the filled Phase 2.5') }); f.events.pop(); f.message(); f.use('next', 'Agent', f.input.tool_input); expect(f.evaluate()).toEqual({ allow: true }); }); for (const kind of ['pending', 'error', 'foreign-session', 'incomplete'] as const) test(`${kind} future delivery cannot advance phase state`, () => { const f = fixture('dx', 'eng'), next = nextSnapshot(f); f.read('future', next.method); const ack = f.events.at(-1)! as any; if (kind === 'pending') f.events.pop(); if (kind === 'error') ack.isError = true; if (kind === 'foreign-session') ack.sessionId = 'foreign'; if (kind === 'incomplete') ack.file.content += 'not-delivered'; f.input.tool_name = 'Agent'; f.input.tool_input = { prompt: next.snapshot.nativeDispatchPrompt }; f.use('next', 'Agent', f.input.tool_input); expect(f.evaluate().allow).toBe(false); }); for (const invalid of ['denied-Agent', 'failed-Read', 'malformed-close'] as const) test(`${invalid} cannot poison same-phase repair`, () => { const f = fixture('dx', 'eng'); if (invalid === 'denied-Agent') { f.use('bad', 'Agent', { prompt: 'You are the independent ENG reviewer for this phase.\nRead file: "/foreign/native-prompt.md"' }); f.result('bad', { isError: true }); } if (invalid === 'failed-Read') { f.use('bad', 'Read', { file_path: path.join(f.cwd, 'autoplan-eng-foreign', 'methodology.md') }); f.result('bad', { isError: true }); } if (invalid === 'malformed-close') { const d = fs.mkdtempSync(path.join(f.cwd, 'autoplan-dx-')), file = path.join(d, 'close-packet.md'); fs.writeFileSync(file, 'Malformed owned close.\n', { mode: 0o444 }); f.read('bad', file); } nextInput(f, f.method); expect(f.evaluate()).toEqual({ allow: true }); f.events.pop(); f.input.tool_input = { file_path: path.join(ROOT, 'autoplan/sections/eng-phase.md') }; f.current(); expect(f.evaluate().allow).toBe(false); f.events.pop(); const repaired = preparePhaseClose('dx', f.active, f.checkpoint, f.restore, f.method); f.read('repaired-close', repaired.closePacketPath); f.message(); f.current(); expect(f.evaluate()).toEqual({ allow: true }); }); for (const invalid of ['foreign-root', 'foreign-restore', 'foreign-active', 'mutable', 'aliased', 'wrong-native', 'partial-prompt'] as const) test(`${invalid} current phase consumption remains denied`, () => { const f = fixture('dx', 'eng'), next = nextSnapshot(f); f.message(); if (invalid === 'foreign-root') { nextInput(f, path.join(f.cwd, 'foreign', 'autoplan-eng-methodology-data', 'methodology.md')); } else if (invalid === 'partial-prompt' || invalid === 'wrong-native') { f.input.tool_name = 'Agent'; f.input.tool_input = { prompt: next.snapshot.nativeDispatchPrompt + (invalid === 'partial-prompt' ? '\nChanged' : '') }; if (invalid === 'wrong-native') { fs.chmodSync(next.snapshot.nativePromptPath, 0o644); fs.appendFileSync(next.snapshot.nativePromptPath, 'Changed'); fs.chmodSync(next.snapshot.nativePromptPath, 0o444); } f.use('next', 'Agent', f.input.tool_input); } else { const manifest = path.join(path.dirname(next.method), 'methodology.json'); if (invalid === 'foreign-restore') { const x = JSON.parse(fs.readFileSync(manifest, 'utf8')); x.restorePath = f.source; fs.chmodSync(manifest, 0o644); fs.writeFileSync(manifest, JSON.stringify(x)); fs.chmodSync(manifest, 0o444); } if (invalid === 'foreign-active') { const file = path.join(path.dirname(next.snapshot.snapshotPath), 'snapshot.json'); const x = JSON.parse(fs.readFileSync(file, 'utf8')); x.activePlan = f.source; fs.chmodSync(file, 0o644); fs.writeFileSync(file, JSON.stringify(x)); fs.chmodSync(file, 0o444); } if (invalid === 'mutable') { fs.chmodSync(next.method, 0o644); // The manifest hash remains mandatory on Windows, where mode bits do not. if (process.platform === 'win32') fs.appendFileSync(next.method, '\nChanged after methodology preparation.\n'); } if (invalid === 'aliased') { const file = next.method + '.saved'; fs.renameSync(next.method, file); fs.symlinkSync(file, next.method); } nextInput(f, invalid === 'foreign-active' ? next.snapshot.nativePromptPath : next.method); } expect(f.evaluate().allow).toBe(false); }); test('captured report-only Edit preserves implementation and accepted records, but still needs publication', () => { const f = fixture('dx', 'eng'), edit = consumption.events.find(e => e.kind === 'use' && e.name === 'Edit')!.input as any; fs.appendFileSync(f.active, edit.old_string + '\n'); f.use('report-edit', 'Edit', { ...edit, file_path: f.active }); fs.writeFileSync(f.active, fs.readFileSync(f.active, 'utf8').replace(edit.old_string, edit.new_string)); f.result('report-edit', { content: 'File updated.' }); f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Publish the filled Phase 2.5') }); f.events.pop(); f.message(); f.current(); expect(f.evaluate()).toEqual({ allow: true }); }); for (const change of ['accepted-None', 'accepted-unapplied', 'implementation', 'ambiguous-new', 'replace-all', 'missing-input', 'Write', 'pending-Edit', 'unknown-result'] as const) test(`post-close ${change} requires a new verified close`, () => { const f = fixture(); f.message(); let old = 'None: retain the current behavior.', next = 'None: silently changed decision.'; if (change === 'implementation') { old = 'Keep documented behavior.'; next = 'Implement new behavior.'; } if (change === 'accepted-unapplied') next = '- Add a new capability.\n'; if (change === 'ambiguous-new') next = 'Review record'; const input: any = { file_path: f.active, old_string: old, new_string: next }; if (change === 'replace-all') input.replace_all = true; if (change === 'missing-input') delete input.old_string; if (change === 'Write') { delete input.old_string; delete input.new_string; input.content = fs.readFileSync(f.active, 'utf8'); } f.use('mutation', change === 'Write' ? 'Write' : 'Edit', input); if (change !== 'Write' && change !== 'pending-Edit') fs.writeFileSync(f.active, fs.readFileSync(f.active, 'utf8').replace(old, next)); if (change !== 'pending-Edit') f.result('mutation', { ...(change === 'unknown-result' ? { isError: undefined } : {}), content: 'Updated.' }); f.current(); expect(f.evaluate().allow).toBe(false); }); test('multiple exact report-only Edits replay in reverse native order', () => { const f = fixture(); fs.appendFileSync(f.active, 'Original report note.\n'); for (const [i, old, next] of [[1, 'Original report note.', 'Revised report note.'], [2, 'Revised report note.', 'Final report note.']] as const) { f.use(`edit-${i}`, 'Edit', { file_path: f.active, old_string: old, new_string: next }); fs.writeFileSync(f.active, fs.readFileSync(f.active, 'utf8').replace(old, next)); f.result(`edit-${i}`, { content: 'Updated.' }); } f.message(); f.current(); expect(f.evaluate()).toEqual({ allow: true }); }); test('ordinary repair tools, same-phase artifacts and unrelated child Agents remain available', () => { const f = fixture('dx', 'eng'); fs.writeFileSync(f.active, fs.readFileSync(f.active, 'utf8').replace('Keep documented behavior.', 'Repair current behavior.')); nextInput(f, f.method); expect(f.evaluate()).toEqual({ allow: true }); f.input.tool_name = 'Bash'; f.input.tool_input = { command: 'current-phase repair' }; expect(f.evaluate()).toEqual({ allow: true }); f.input.tool_name = 'Agent'; f.input.tool_input = { prompt: 'Investigate this current-phase prerequisite.' }; expect(f.evaluate()).toEqual({ allow: true }); }); test('rearm excludes disarmed future Reads and still requires the original outstanding publication', () => { const f = fixture('dx', 'eng'), next = nextSnapshot(f); f.add({ kind: 'end_turn' }); f.add({ kind: 'user_turn', autoplan: false }); f.read('unrelated-future', next.method); f.use('re-init', 'Bash', (f.events[0] as any).input); f.result('re-init', { content: JSON.stringify({ ...f.init, reused: true }) }); nextInput(f, next.method); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('Publish the filled Phase 2.5') }); }); test('bound future native dispatch reaches the actual asynchronous hook reader', async () => { const f = fixture('dx', 'eng'), next = nextSnapshot(f); f.input.tool_name = 'Agent'; f.input.tool_input = { prompt: next.snapshot.nativeDispatchPrompt }; f.use('next', 'Agent', f.input.tool_input); f.journal(); expect(await runPublicationHook(f.input, ROOT)).toMatchObject({ hookSpecificOutput: { permissionDecision: 'deny' } }); f.events.pop(); f.message(); f.use('next', 'Agent', f.input.tool_input); f.journal(); expect(await runPublicationHook(f.input, ROOT)).toEqual({}); }); function audit(f: ReturnType) { return prematureAutoplanPhaseEntry(f.events.filter(e => e.kind === 'use' || e.kind === 'result') as any, { status: 'ready', calls: [], assistantMessages: f.events.filter(e => e.kind === 'message') as any }, [{ phase: 'eng', requiredPhase: 2.5, paths: [path.join(ROOT, 'autoplan/sections/eng-phase.md')], content: fs.readFileSync(path.join(ROOT, 'autoplan/sections/eng-phase.md'), 'utf8') }], 0); } test('Bash terminal whitespace transport preserves complete driver delivery', () => { const f = fixture('dx', 'eng'); const file = path.join(ROOT, 'autoplan/sections/eng-phase.md'); f.use('native-cat', 'Bash', { command: `cat "${file}"` }); f.result('native-cat', { content: fs.readFileSync(file, 'utf8').trimEnd() }); expect(audit(f)).toMatchObject({ phase: 'eng', readToolUseId: 'native-cat' }); }); for (const ending of ['', '\n', '\r\n', ' \t\r\n', '\n\n\n']) test(`complete Bash driver accepts only terminal whitespace normalization: ${JSON.stringify(ending)}`, () => { const f = fixture('dx', 'eng'), content = fs.readFileSync(path.join(ROOT, 'autoplan/sections/eng-phase.md'), 'utf8'); f.use('transport', 'Bash', { command: 'opaque-native-driver-loader' }); f.result('transport', { content: content.trimEnd() + ending }); expect(audit(f)).toMatchObject({ readToolUseId: 'transport' }); f.message(); expect(audit(f)).toMatchObject({ readToolUseId: 'transport' }); }); for (const change of ['missing-first', 'missing-last', 'internal-space', 'leading-space', 'same-line-prefix', 'same-line-suffix'] as const) test(`meaningful Bash driver content remains strict: ${change}`, () => { const f = fixture('dx', 'eng'), original = fs.readFileSync(path.join(ROOT, 'autoplan/sections/eng-phase.md'), 'utf8').trimEnd(); const text = change === 'missing-first' ? original.slice(1) : change === 'missing-last' ? original.slice(0, -1) : change === 'internal-space' ? original.replace('Read ', 'Read ') : change === 'leading-space' ? ' ' + original : change === 'same-line-prefix' ? 'PREFIX' + original : original + 'SUFFIX'; expect(text).not.toBe(original); f.use('invalid-transport', 'Bash', { command: 'opaque-loader' }); f.result('invalid-transport', { content: text }); expect(audit(f)).toBeNull(); }); test('cached future driver delivery is visible to the detector at the current native request', () => { const f = fixture('dx', 'eng'), file = path.join(ROOT, 'autoplan/sections/eng-phase.md'); f.read('earlier-body', file); const prior = f.events.splice(-2); f.events.unshift(...prior); f.reorder(); // Earlier body predates this audit window. The new native cache ACK is the // current entry; an eventual report does not erase that earlier violation. prior.forEach(e => { e.timestamp = new Date(clock - 100).toISOString(); }); f.use('cached-future', 'Read', { file_path: file, offset: 1 }); f.result('cached-future', { content: 'Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.', file: { filePath: file } }); const detect = () => prematureAutoplanPhaseEntry(f.events.filter(e => e.kind === 'use' || e.kind === 'result') as any, { status: 'ready', calls: [], assistantMessages: f.events.filter(e => e.kind === 'message') as any }, [{ phase: 'eng', requiredPhase: 2.5, paths: [file], content: fs.readFileSync(file, 'utf8') }], clock); expect(detect()).toMatchObject({ readToolUseId: 'cached-future' }); f.message(); expect(detect()).toMatchObject({ readToolUseId: 'cached-future' }); }); for (const kind of ['Read', 'Agent'] as const) test(`the actual detector authenticates ${kind} phase consumption through the current snapshot APIs`, () => { const f = fixture('dx', 'eng'), next = nextSnapshot(f); if (kind === 'Read') f.read('eng-consumer', next.method, 2, 1); else { f.use('eng-consumer', 'Agent', { prompt: next.snapshot.nativeDispatchPrompt }); f.result('eng-consumer', { content: 'Native child launched.' }); } expect(audit(f)).toMatchObject({ phase: 'eng', requiredPhase: 2.5, readToolUseId: 'eng-consumer' }); f.message(); expect(audit(f)).toMatchObject({ readToolUseId: 'eng-consumer', reportAt: f.events.at(-1)!.timestamp }); const message = f.events.pop()!; message.timestamp = new Date(clock - 1).toISOString(); f.events.unshift(message); expect(audit(f)).toBeNull(); }); for (const mutation of ['missing-result', 'error', 'foreign-session', 'bad-content', 'wrong-restore', 'wrong-active', 'wrong-prompt', 'conflicting-result'] as const) test(`detector ${mutation} supplies no authenticated consumption`, () => { const f = fixture('dx', 'eng'), next = nextSnapshot(f); const agent = ['wrong-active', 'wrong-prompt'].includes(mutation); if (agent) { f.use('consumer', 'Agent', { prompt: next.snapshot.nativeDispatchPrompt + (mutation === 'wrong-prompt' ? '\nForeign change.' : '') }); f.result('consumer', { content: 'Launched.' }); } else f.read('consumer', next.method); const result = f.events.at(-1)! as any; if (mutation === 'missing-result') f.events.pop(); if (mutation === 'error') result.isError = true; if (mutation === 'foreign-session') result.sessionId = 'foreign'; if (mutation === 'bad-content') result.file.content += 'Changed.'; if (mutation === 'conflicting-result') f.add({ ...result, isError: true }); if (mutation === 'wrong-restore' || mutation === 'wrong-active') { const file = mutation === 'wrong-restore' ? path.join(path.dirname(next.method), 'methodology.json') : path.join(path.dirname(next.snapshot.snapshotPath), 'snapshot.json'); const m = JSON.parse(fs.readFileSync(file, 'utf8')); m[mutation === 'wrong-restore' ? 'restorePath' : 'activePlan'] = f.source; fs.chmodSync(file, 0o644); fs.writeFileSync(file, JSON.stringify(m)); fs.chmodSync(file, 0o444); } expect(audit(f)).toBeNull(); }); for (const mode of ['literal-command', 'opaque-command', 'text-block', 'late-report', 'partial', 'changed', 'error', 'foreign-session', 'backward-result', 'conflicting-result', 'quoted-report', 'earlier-report'] as const) test(`captured Bash driver delivery: ${mode}`, () => { const request = structuredClone(consumption.events.find(e => e.kind === 'use' && e.name === 'Bash')!) as any; const result = structuredClone(consumption.events.find(e => e.kind === 'result' && e.toolUseId === request.toolUseId)!) as any; const transcript: any = { status: 'ready', calls: [], assistantMessages: structuredClone(consumption.messages) }; if (mode === 'opaque-command') request.input.command = 'run-current-script'; if (mode === 'text-block') result.content = [{ type: 'text', text: result.content }]; if (mode === 'partial') result.content = result.content.replace(consumption.driverContent, consumption.driverContent.slice(1)); if (mode === 'changed') result.content = result.content.replace(consumption.driverContent, consumption.driverContent.replace('Phase 3', 'Phase 9')); if (mode === 'error') result.isError = true; if (mode === 'foreign-session') result.sessionId = 'foreign'; if (mode === 'backward-result') result.timestamp = new Date(Date.parse(request.timestamp) - 1).toISOString(); if (['late-report', 'quoted-report', 'earlier-report'].includes(mode)) transcript.assistantMessages.push({ sessionId: request.sessionId, timestamp: new Date(Date.parse(request.timestamp) + (mode === 'late-report' ? 1 : -1)).toISOString(), text: mode === 'quoted-report' ? '```\nPhase 2.5 complete.\n```' : 'Phase 2.5 complete.' }); const events = [request, result]; if (mode === 'conflicting-result') events.push({ ...result, isError: true }); const detected = prematureAutoplanPhaseEntry(events, transcript, [{ phase: 'eng', requiredPhase: 2.5, paths: [path.join(ROOT, 'autoplan/sections/eng-phase.md')], content: consumption.driverContent }], 0); const expected = ['literal-command', 'opaque-command', 'text-block', 'late-report', 'quoted-report'].includes(mode); expect(detected !== null).toBe(expected); if (expected) expect(detected!.readToolUseId).toBe(request.toolUseId); }); }); describe('Autoplan native journal roots and Windows spellings', () => { const windows = process.platform === 'win32'; function published(publish = true) { const f = fixture(); if (publish) f.message(); f.current(); const { rows } = f.journal(); const save = () => fs.writeFileSync(f.input.transcript_path, rows.map(r => JSON.stringify(r)).join('\n') + '\n'); return { f, rows, save }; } const attachment = (f: ReturnType, parentUuid: string | null, extra: object = {}): any => ({ uuid: randomUUID(), parentUuid, cwd: f.cwd, sessionId: f.sessionId, isSidechain: false, timestamp: new Date(clock).toISOString(), type: 'attachment', attachment: { type: 'hook_success', hookEvent: 'SessionStart' }, ...extra }); const hook = (f: ReturnType, project = f.cwd) => withNativeProjectDirectory(project, () => runPublicationHook(f.input, ROOT)); const status = (f: ReturnType) => readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId).transcript.status; for (const shape of ['one', 'chained', 'stray-before'] as const) test(`a SessionStart attachment root owns the journal beneath it (${shape})`, async () => { const { f, rows, save } = published(); const root = attachment(f, null), chain = shape === 'chained' ? [root, attachment(f, root.uuid)] : [root]; rows[0].parentUuid = chain.at(-1)!.uuid; rows.unshift(...(shape === 'stray-before' ? [attachment(f, null)] : []), ...chain); save(); expect(status(f)).toBe('ready'); expect(await hook(f)).toEqual(OWNED_READ_APPROVAL); }); test('an attachment root still requires the parent publication', async () => { const { f, rows, save } = published(false); const root = attachment(f, null); rows[0].parentUuid = root.uuid; rows.unshift(root); save(); expect(await hook(f)).toMatchObject({ hookSpecificOutput: { permissionDecisionReason: expect.stringContaining('Publish the filled Phase 1') } }); }); test('a message-bearing attachment root is an unrecognized shape: advisory, never ownership', async () => { const { f, rows, save } = published(); const root = attachment(f, null, { message: { role: 'system', content: [] } }); rows[0].parentUuid = root.uuid; rows.unshift(root); save(); const read = readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId); expect(read.transcript.status).not.toBe('ready'); expect(read.transcript.reason).toBe('unrecognized_shape:first_turn:attachment:hook_success'); expect(read.events).toEqual([]); const home = fs.mkdtempSync(path.join(tmpdir(), 'autoplan-guard-home-')); dirs.push(home); const previous = process.env.GSTACK_HOME; process.env.GSTACK_HOME = home; try { const output: any = await hook(f); expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); expect(output.systemMessage).toContain('phase publication was NOT verified for this session'); } finally { if (previous === undefined) delete process.env.GSTACK_HOME; else process.env.GSTACK_HOME = previous; } }); for (const kind of ['foreign-cwd', 'sidechain', 'competing-conversation', 'competing-user-root'] as const) test(`an attachment root cannot supply ownership: ${kind}`, async () => { const { f, rows, save } = published(); const root = attachment(f, null, kind === 'foreign-cwd' ? { cwd: path.join(f.cwd, 'foreign') } : kind === 'sidechain' ? { isSidechain: true } : {}); rows[0].parentUuid = root.uuid; rows.unshift(root); if (kind === 'competing-conversation' || kind === 'competing-user-root') { const other = attachment(f, null); const prompt = { ...structuredClone(rows[1]), uuid: randomUUID(), parentUuid: kind === 'competing-user-root' ? null : other.uuid }; rows.push(...(kind === 'competing-conversation' ? [other] : []), prompt); } save(); expect(status(f)).not.toBe('ready'); expect((await hook(f) as any).hookSpecificOutput?.permissionDecision).toBe('deny'); }); test.if(windows)('CLAUDE_PROJECT_DIR spelled C:/ or c:\\ names the native journal cwd', async () => { const { f } = published(); expect(await hook(f, f.cwd.replaceAll('\\', '/'))).toEqual(OWNED_READ_APPROVAL); expect(await hook(f, f.cwd[0]!.toLowerCase() + f.cwd.slice(1))).toEqual(OWNED_READ_APPROVAL); expect((await hook(f, f.cwd + '\\.') as any).hookSpecificOutput?.permissionDecision).toBe('deny'); }); test.if(windows)('an init command spelled with Git Bash /c/ paths binds the same invocation', () => { const f = fixture(); f.message(); f.current(); const msys = (file: string) => '/' + file[0]!.toLowerCase() + file.slice(2).replaceAll('\\', '/'); (f.events[0] as any).input.command = `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" init "${msys(f.source)}" "${msys(f.active)}" "${msys(f.restore)}"`; expect(f.evaluate()).toEqual({ allow: true }); }); test.if(windows)('a close packet Read spelled C:/ is still the close Read', () => { const f = fixture(), close = f.events.find(e => e.kind === 'use' && e.toolUseId === 'close') as any; const forward = (close.input!.file_path as string).replaceAll('\\', '/'); close.input!.file_path = forward; (f.events.find(e => e.kind === 'result' && e.toolUseId === 'close') as any).file.filePath = forward; f.message(); f.current(); expect(f.evaluate()).toEqual({ allow: true }); }); test.if(windows)('an active-plan Edit spelled C:/ after the close Read is still a mutation', () => { const f = fixture(); f.message(); f.use('edit', 'Edit', { file_path: f.active.replaceAll('\\', '/'), old_string: 'Keep', new_string: 'Drop' }); f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('mutation is pending') }); }); }); describe('Autoplan phase entry through the gbrain :user render (#2569)', () => { const generator = fs.readFileSync(path.join(ROOT, 'scripts/gen-skill-docs.ts'), 'utf8'); const rewriteSectionBase: (content: string, linkRoot: string | null) => string = new Function( new Bun.Transpiler({ loader: 'ts' }).transformSync(generator.slice(generator.indexOf('function rewriteSectionBase('), generator.indexOf('\n}\n', generator.indexOf('function rewriteSectionBase(')) + 2)) + '\nreturn rewriteSectionBase;')(); const sections = ['ceo-phase.md', 'design-phase.md', 'dx-phase.md', 'eng-phase.md', 'tasks-aggregator.md']; /** Writes what setup's swap-in render writes: the generator's rewrite with --link-root = the final render root. */ function render(root: string, linkRoot = root, edit = (text: string) => text) { fs.mkdirSync(path.join(root, 'autoplan', 'sections'), { recursive: true }); for (const name of sections) fs.writeFileSync(path.join(root, 'autoplan', 'sections', name), edit(rewriteSectionBase(fs.readFileSync(path.join(ROOT, 'autoplan/sections', name), 'utf8'), linkRoot))); return root; } function withEnv(env: Record, work: () => T): T { const previous = Object.fromEntries(Object.keys(env).map(k => [k, process.env[k]])); for (const [k, v] of Object.entries(env)) if (v === undefined) delete process.env[k]; else process.env[k] = v; try { return work(); } finally { for (const [k, v] of Object.entries(previous)) if (v === undefined) delete process.env[k]; else process.env[k] = v; } } /** CEO entered through `entry` and published; the next phase entry is the request under judgment. */ function entered(entry: string, next?: string) { const f = fixture(), text = fs.readFileSync(entry, 'utf8'), lines = text.split('\n'); const use = f.events.find(e => e.kind === 'use' && e.toolUseId === 'entry') as any; const result = f.events.find(e => e.kind === 'result' && e.toolUseId === 'entry') as any; use.input = { file_path: entry, offset: 1 }; (result as any).file = { filePath: entry, content: text, startLine: 1, numLines: lines.length, totalLines: lines.length }; f.message(); if (next) f.input.tool_input = { file_path: next }; f.current(); return f; } const scratch = () => { const dir = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-render-'))); dirs.push(dir); return dir; }; const denied = { allow: false, reason: expect.stringContaining('different or unavailable installation') }; test('the hook rewrite is the generator rewrite', () => { const linkRoot = path.join(scratch(), 'render', 'claude'); for (const name of [...sections, 'phase-close.md']) { const text = fs.readFileSync(path.join(ROOT, 'autoplan/sections', name), 'utf8'); expect(renderSectionBase(text, linkRoot)).toBe(rewriteSectionBase(text, linkRoot)); } }); for (const via of ['GSTACK_USER_RENDER_DIR', 'GSTACK_HOME', 'HOME', 'empty GSTACK_USER_RENDER_DIR'] as const) test(`the render root setup resolves through ${via} is this installation`, () => { const home = scratch(), root = render(via === 'GSTACK_USER_RENDER_DIR' ? path.join(home, 'custom') : via === 'HOME' ? path.join(home, '.gstack', 'render', 'claude') : path.join(home, 'render', 'claude')); const env = via === 'GSTACK_USER_RENDER_DIR' ? { GSTACK_USER_RENDER_DIR: root, GSTACK_HOME: undefined } : via === 'HOME' ? { GSTACK_USER_RENDER_DIR: undefined, GSTACK_HOME: undefined, HOME: home } : { GSTACK_USER_RENDER_DIR: via === 'GSTACK_HOME' ? undefined : '', GSTACK_HOME: home }; const f = entered(path.join(root, 'autoplan/sections/ceo-phase.md'), path.join(root, 'autoplan/sections/design-phase.md')); expect(withEnv(env, () => f.evaluate())).toEqual({ allow: true }); }); for (const kind of ['sibling-root', 'other-home', 'linked-out', 'changed-bytes', 'stale-link-root', 'no-render-root'] as const) test(`a render lookalike is a different installation: ${kind}`, () => { const home = scratch(), root = path.join(home, 'render', 'claude'); let next = path.join(root, 'autoplan/sections/design-phase.md'), env: Record = { GSTACK_USER_RENDER_DIR: undefined, GSTACK_HOME: home }; if (kind === 'sibling-root') { render(root); next = path.join(render(path.join(home, 'render', 'claude2')), 'autoplan/sections/design-phase.md'); } if (kind === 'other-home') { const other = scratch(); render(root); next = path.join(render(path.join(other, 'render', 'claude')), 'autoplan/sections/design-phase.md'); } if (kind === 'linked-out') { const outside = render(path.join(scratch(), 'outside'), root); fs.mkdirSync(path.join(root, 'autoplan'), { recursive: true }); fs.symlinkSync(path.join(outside, 'autoplan', 'sections'), path.join(root, 'autoplan', 'sections'), 'junction'); } if (kind === 'changed-bytes') render(root, root, text => text + 'Forged instruction.\n'); if (kind === 'stale-link-root') render(root, path.join(home, 'previous-render')); if (kind === 'no-render-root') { render(root); env = { GSTACK_USER_RENDER_DIR: path.join(home, 'missing'), GSTACK_HOME: home }; } const f = entered(path.join(ROOT, 'autoplan/sections/ceo-phase.md'), next); expect(withEnv(env, () => f.evaluate())).toMatchObject(denied); }); }); describe('Autoplan current-use identity: journal spelling vs PreToolUse spelling', () => { // Claude journals the model's file_path verbatim and hands PreToolUse its resolved form. const spellings = (file: string) => [ path.join(path.dirname(file), '.') + path.sep + '.' + path.sep + path.basename(file), ...(process.platform === 'win32' ? [path.dirname(file) + '/' + path.basename(file)] : []), ]; for (const kind of ['dot-segment', 'forward-slash'] as const) test.if(kind === 'dot-segment' || process.platform === 'win32')(`a ${kind} journal spelling is the same current Read`, () => { const f = fixture(); f.message(); const target = f.input.tool_input.file_path as string, journal = spellings(target)[kind === 'dot-segment' ? 0 : 1]!; f.use('next', 'Read', { file_path: journal }); expect(f.evaluate()).toEqual({ allow: true }); }); for (const kind of ['other-file', 'extra-field'] as const) test(`a current Read that differs beyond spelling is not the same identity: ${kind}`, () => { const f = fixture(); f.message(); const target = f.input.tool_input.file_path as string; f.use('next', 'Read', kind === 'other-file' ? { file_path: path.join(path.dirname(target), 'eng-phase.md') } : { file_path: spellings(target)[0], limit: 5 }); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('identity is unavailable') }); }); }); describe('Autoplan Read coverage of a CRLF artifact', () => { // A CRLF plan carries CRLF into the close packet; Claude's Read reports those lines without CR. const disk = 'Binding: {}\r\n## Implementation plan\r\nKeep it.\nlast line\r\n'; const use: any = { kind: 'use', sessionId: 's', toolUseId: 't', name: 'Read', order: 1, input: { file_path: 'C:\\x\\close-packet.md' } }; const result = (content: string): any => ({ kind: 'result', sessionId: 's', toolUseId: 't', isError: false, order: 2, file: { filePath: 'C:\\x\\close-packet.md', content, startLine: 1, numLines: 5, totalLines: 5 } }); for (const [kind, reported, credited] of [ ['crlf-stripped', disk.replace(/\r\n/g, '\n'), true], ['verbatim', disk, true], ['changed-text', disk.replace(/\r\n/g, '\n').replace('Keep it.', 'Drop it.'), false], ['mid-line-cr-dropped', 'Binding: {}\n## Implementation plan\nKeep it.\nlast line\n', true], ] as const) test(`a ${kind} report ${credited ? 'covers' : 'does not cover'} every line`, () => { expect(autoplanReadRange(use, result(reported), disk)).toEqual(credited ? { start: 1, end: 5 } : undefined); }); test('a CR inside a line is content, not a line ending', () => { const inner = 'a\rb\nc'; const r: any = { ...result('ab\nc'), file: { filePath: 'C:\\x\\close-packet.md', content: 'ab\nc', startLine: 1, numLines: 2, totalLines: 2 } }; expect(autoplanReadRange(use, r, inner)).toBeUndefined(); }); }); describe('Autoplan restore-point header line ending', () => { // init writes an LF header above a CRLF plan; Claude's Edit then rewrites every ending as CRLF. for (const [kind, ending, allowed] of [['crlf', '\r\n', true], ['lf', '\n', true], ['trailing-text', ' forged\n', false], ['no-newline', '', false]] as const) test(`a restore-point header ending ${kind} ${allowed ? 'binds' : 'does not bind'} the invocation`, () => { const f = fixture(); f.message(); f.current(); const text = fs.readFileSync(f.active, 'utf8'), end = text.indexOf('-->') + 3; fs.writeFileSync(f.active, text.slice(0, end) + ending + text.slice(text.indexOf('\n', end) + 1)); expect(f.evaluate()).toEqual(allowed ? { allow: true } : { allow: false, reason: expect.stringContaining('initialization artifacts do not match') }); }); }); describe('Autoplan ownership in a linked git worktree session', () => { const posix = (file: string) => file.replaceAll('\\', '/'); /** git worktree add's two-way link: /.git/worktrees//gitdir <-> /.git. */ function admin(repo: string, worktree: string) { const entry = path.join(repo, '.git', 'worktrees', 'session'); fs.mkdirSync(entry, { recursive: true }); fs.writeFileSync(path.join(entry, 'gitdir'), posix(path.join(worktree, '.git')) + '\n'); return entry; } const pointer = (worktree: string, entry: string) => fs.writeFileSync(path.join(worktree, '.git'), `gitdir: ${posix(entry)}\n`); const repo = () => { const dir = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-repo-'))); dirs.push(dir); return dir; }; const run = (f: ReturnType, root: string) => withNativeProjectDirectory(root, () => runPublicationHook(f.input, ROOT)); test('the repository root owns its linked worktree journal', async () => { const f = fixture(); f.message(); f.current(); f.journal(); const root = repo(); pointer(f.cwd, admin(root, f.cwd)); expect(await run(f, root)).toEqual(OWNED_READ_APPROVAL); }); test('a linked worktree journal still requires the parent publication', async () => { const f = fixture(); f.current(); f.journal(); const root = repo(); pointer(f.cwd, admin(root, f.cwd)); expect(await run(f, root)).toMatchObject({ hookSpecificOutput: { permissionDecisionReason: expect.stringContaining('Publish the filled Phase 1') } }); }); test.if(process.platform === 'win32')('a forward-slash, lowercase-drive gitdir names the same worktree', async () => { const f = fixture(); f.message(); f.current(); f.journal(); const root = repo(), lower = (file: string) => posix(file[0]!.toLowerCase() + file.slice(1)); const entry = path.join(root, '.git', 'worktrees', 'session'); fs.mkdirSync(entry, { recursive: true }); fs.writeFileSync(path.join(entry, 'gitdir'), lower(path.join(f.cwd, '.git')) + '\n'); fs.writeFileSync(path.join(f.cwd, '.git'), `gitdir: ${lower(entry)}\n`); expect(await run(f, lower(root))).toEqual(OWNED_READ_APPROVAL); }); test('worktree.useRelativePaths links resolve against each file\'s own directory', async () => { const f = fixture(); f.message(); f.current(); f.journal(); const root = repo(), entry = path.join(root, '.git', 'worktrees', 'session'); fs.mkdirSync(entry, { recursive: true }); // Exactly what git 2.55 `-c worktree.useRelativePaths=true worktree add` writes. fs.writeFileSync(path.join(entry, 'gitdir'), posix(path.relative(entry, path.join(f.cwd, '.git'))) + '\n'); fs.writeFileSync(path.join(f.cwd, '.git'), `gitdir: ${posix(path.relative(f.cwd, entry))}\n`); expect(await run(f, root)).toEqual(OWNED_READ_APPROVAL); }); test('an unrelated linked worktree leaves the exact project-directory owner unchanged', async () => { const f = fixture(); f.message(); f.current(); f.journal(); const elsewhere = repo(); fs.mkdirSync(path.join(f.cwd, '.git', 'worktrees', 'session'), { recursive: true }); fs.writeFileSync(path.join(f.cwd, '.git', 'worktrees', 'session', 'gitdir'), posix(path.join(elsewhere, '.git')) + '\n'); expect(await run(f, f.cwd)).toEqual(OWNED_READ_APPROVAL); }); for (const kind of ['no-link', 'worktree-file-only', 'admin-entry-only', 'other-repository', 'submodule-gitdir', 'project-is-itself-a-worktree', 'bare-repository'] as const) test(`an unlinked directory cannot own the journal: ${kind}`, async () => { const f = fixture(); f.message(); f.current(); f.journal(); const root = repo(); if (kind === 'worktree-file-only') pointer(f.cwd, path.join(root, '.git', 'worktrees', 'session')); if (kind === 'admin-entry-only') admin(root, f.cwd); // The root claims the worktree, but the worktree names another repository. if (kind === 'other-repository') { admin(root, f.cwd); pointer(f.cwd, admin(repo(), f.cwd)); } if (kind === 'submodule-gitdir') { admin(root, f.cwd); const modules = path.join(root, '.git', 'modules', 'session'); fs.mkdirSync(modules, { recursive: true }); pointer(f.cwd, modules); } // A project directory whose own .git is a file has no worktree registry of its own. if (kind === 'project-is-itself-a-worktree') { fs.writeFileSync(path.join(root, '.git'), 'gitdir: elsewhere\n'); pointer(f.cwd, path.join(root, '.git', 'worktrees', 'session')); } if (kind === 'bare-repository') { const entry = path.join(root, 'worktrees', 'session'); fs.mkdirSync(entry, { recursive: true }); fs.writeFileSync(path.join(entry, 'gitdir'), posix(path.join(f.cwd, '.git')) + '\n'); pointer(f.cwd, entry); } const output: any = await run(f, root); expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); expect(output.hookSpecificOutput.permissionDecisionReason).toContain('code foreign_cwd'); }); }); describe('PAX-3638: symlinked plan directory and superseded failed inits', () => { const skipWin = process.platform === 'win32'; /** The init Bash command spells active/restore through a symlinked directory; the journaled JSON stays canonical. */ function aliasFixture() { const f = fixture(); const aliasDir = `${f.cwd}-alias`; dirs.push(aliasDir); fs.symlinkSync(f.cwd, aliasDir, 'dir'); const aliasActive = path.join(aliasDir, 'active.md'), aliasRestore = path.join(aliasDir, 'restore.md'); const command = `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" init "${f.source}" "${aliasActive}" "${aliasRestore}"`; (f.events[0] as any).input.command = command; expect(f.init.activePlan).toBe(f.active); // canonical, unlike the argv above expect(aliasActive).not.toBe(f.active); const erroredInit = (id: string) => [ { ...(structuredClone(f.events[0]!) as any), toolUseId: id }, { ...(structuredClone(f.events[1]!) as any), toolUseId: id, isError: true, content: 'Error: Initialization source must be nonempty UTF-8 text' }, ]; const ready = () => { f.message(); f.current(); f.reorder(); }; return { ...f, aliasDir, aliasActive, aliasRestore, command, erroredInit, ready }; } test.skipIf(skipWin)('PAX-3638 Fix A: alias argv binds to init canonical paths', () => { const f = aliasFixture(); f.ready(); expect(f.evaluate()).toEqual({ allow: true }); }); test.skipIf(skipWin)('PAX-3638 Fix B: errored first init followed by a correct init binds', () => { const f = aliasFixture(); f.events.unshift(...f.erroredInit('bad-init') as any); f.ready(); expect(f.evaluate()).toEqual({ allow: true }); }); test.skipIf(skipWin)('PAX-3638 Fix C: errored init with no later success is init_failed and says re-run', () => { const f = aliasFixture(); (f.events[1] as any).isError = true; f.ready(); const decision = f.evaluate() as any, reason: string = decision.reason; expect(decision.allow).toBe(false); expect(reason).toContain('(code init_failed,'); expect(reason).toContain('re-run'); expect(reason).toContain('The most recent snapshot init call failed'); }); test.skipIf(skipWin)('PAX-3638 Fix B: a trailing errored init after a good bind is init_failed', () => { const f = aliasFixture(); f.events.splice(2, 0, ...f.erroredInit('late-bad-init') as any); f.ready(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('(code init_failed,') }); }); test.skipIf(skipWin)('PAX-3638 Fix B: a later good init supersedes a trailing errored one', () => { const f = aliasFixture(); f.events.splice(2, 0, ...f.erroredInit('late-bad-init') as any); f.events.splice(4, 0, { ...(structuredClone(f.events[0]!) as any), toolUseId: 'again' }, { ...(structuredClone(f.events[1]!) as any), toolUseId: 'again', content: JSON.stringify({ ...f.init, reused: true }) }); f.ready(); expect(f.evaluate()).toEqual({ allow: true }); }); for (const field of ['activePlan', 'restorePath'] as const) test.skipIf(skipWin)(`PAX-3638 Fix C: retargeted ${field} is init_mismatch without a re-run hint`, () => { const f = aliasFixture(); (f.events[1] as any).content = JSON.stringify({ ...f.init, [field]: f.source }); f.ready(); const decision = f.evaluate() as any, reason: string = decision.reason; expect(decision.allow).toBe(false); expect(reason).toContain('(code init_mismatch,'); expect(reason).toContain('do not match the canonical form of its own command arguments'); expect(reason).not.toContain('re-run'); }); test.skipIf(skipWin)('PAX-3638 Fix B: zero results for an init use still denies', () => { const f = aliasFixture(); f.events.splice(1, 1); f.ready(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('(code init_mismatch,') }); }); test.skipIf(skipWin)('PAX-3638 Fix A: an argv that cannot be canonicalized is init_mismatch, not a hook crash', () => { const f = aliasFixture(); (f.events[0] as any).input.command = `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" init "${f.source}" "${f.source}/not-a-dir/active.md" "${f.aliasRestore}"`; f.ready(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('(code init_mismatch,') }); }); test.skipIf(skipWin)('PAX-3638 Fix A: a mistyped tool path is unbindable, and a later correct init still binds', () => { const f = aliasFixture(); const bad = { ...(structuredClone(f.events[0]!) as any), toolUseId: 'typo' }; bad.input.command = bad.input.command.replace('gstack-autoplan-snapshot.ts', 'gstack-autoplan-snapshto.ts'); f.events.unshift(bad); f.ready(); expect(f.evaluate()).toEqual({ allow: true }); }); test.skipIf(skipWin)('PAX-3638 Fix A2: a pending Edit through the alias spelling is the same file', () => { const f = aliasFixture(); f.message(); f.use('alias-edit', 'Edit', { file_path: f.aliasActive, old_string: 'Keep documented behavior.', new_string: 'Implement new behavior.' }); f.current(); f.reorder(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('(code mutation_pending,') }); }); });