diff --git a/autoplan/bin/guard-journal.ts b/autoplan/bin/guard-journal.ts new file mode 100644 index 000000000..418b879ac --- /dev/null +++ b/autoplan/bin/guard-journal.ts @@ -0,0 +1,115 @@ +/** + * What the publication guard reads of its parent journal (CEO-2, ENG-4, ENG-5). + * The bounded reader's index keeps every owned record's events without content. + * This policy reloads in full only what the evaluation reads: every literal + * init and its result, the current call, and inside the invocation window + * (from the latest human turn at or before the bound invocation chain's first + * init) the results of guarded and close-packet Reads, structured Bash results, + * error results, assistant text after the first close-packet Read and + * mutations of the active plan; plus the earlier Read a dedup reply cites, + * even before the window. + */ +import { readOwnedClaudePublicTranscript, type IndexedRecord, type JournalPrefix, type OwnedRead, + type OwnedReadMeasure } from '../../lib/claude-owned-journal'; +import { object, sameNativePath, type ClaudeParentPublicEvent, type NativePublicToolEvent } from '../../lib/claude-journal-records'; + +type Event = ClaudeParentPublicEvent; +type Use = NativePublicToolEvent & { order: number }; +/** Claude Code's reply to a Read of an unchanged file it already returned in full. */ +export const DEDUP_REPLY = 'Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.'; +/** The longest Read input or Bash command the index keeps; a literal init is far shorter. */ +const INDEX_INPUT_MAX = 4096; + +export function textResult(event: Event): string | undefined { + if (event.kind !== 'result' || event.isError !== false) return; + if (typeof event.content === 'string') return event.content; + if (Array.isArray(event.content) && event.content.length === 1 && event.content[0]?.type === 'text' && + typeof event.content[0].text === 'string') return event.content[0].text; +} + +/** The hook's own classifiers, passed in so this policy and the evaluation cannot disagree. */ +export interface GuardJournalTests { + /** A literal snapshot init's [source, active, restore], or undefined. */ + init(command: unknown): string[] | undefined; + /** A phase driver or artifact Read ('phase'), a close-packet Read ('close'), or neither. */ + read(file: unknown): 'phase' | 'close' | undefined; + reviewer(input: Record): boolean; +} + +function indexInput(tests: GuardJournalTests) { + return (name: string, input: Record): Record | undefined => { + if (name === 'Read') return JSON.stringify(input).length <= INDEX_INPUT_MAX ? input : undefined; + if (name === 'Bash') return typeof input.command === 'string' && input.command.length <= INDEX_INPUT_MAX ? { command: input.command } : undefined; + if (name === 'Agent') return tests.reviewer(input) ? input : undefined; + if (name === 'Write' || name === 'Edit') + return typeof input.file_path === 'string' && input.file_path.length <= INDEX_INPUT_MAX ? { file_path: input.file_path } : undefined; + }; +} + +function select(tests: GuardJournalTests, currentToolUseId: string) { + return (records: readonly IndexedRecord[], load: (records: readonly IndexedRecord[]) => void) => { + const useOf = new Map(), resultsOf = new Map(); + const reads: Array<{ use: Use; record: IndexedRecord }> = [], inits: Array<{ use: Use; record: number; args: string[] }> = []; + records.forEach((record, at) => { + for (const e of record.events) { + if (e.kind === 'use') { + const use = e as Use; + if (!useOf.has(use.toolUseId)) useOf.set(use.toolUseId, { use, record }); + if (use.name === 'Read') reads.push({ use, record }); + const args = use.name === 'Bash' ? tests.init(use.input?.command) : undefined; + if (args) inits.push({ use, record: at, args }); + } else if (e.kind === 'result') { const list = resultsOf.get(e.toolUseId) ?? []; list.push(record); resultsOf.set(e.toolUseId, list); } + } + }); + const withResults = (id: string) => [...(useOf.has(id) ? [useOf.get(id)!.record] : []), ...resultsOf.get(id) ?? []]; + // invocation() binds and checks every literal init, so all of them load, window or not. + load(inits.flatMap(init => resultsOf.get(init.use.toolUseId) ?? [])); + let bound: Record | undefined, chainStart: number | undefined; + for (const init of inits) { + const replies = (resultsOf.get(init.use.toolUseId) ?? []).flatMap(r => r.events) + .filter(e => e.kind === 'result' && e.toolUseId === init.use.toolUseId && e.order > init.use.order); + let reply: unknown; + try { reply = replies.length === 1 ? JSON.parse(textResult(replies[0]!) ?? 'null') : undefined; } catch { reply = undefined; } + // A reused:true init keeps the earlier binding, as invocation() does. + if (object(reply) && reply.reused === true && bound && bound.activePlan === reply.activePlan && + bound.restorePath === reply.restorePath) continue; + chainStart = init.record; bound = object(reply) ? reply : undefined; + } + let start = 0; + for (let i = chainStart ?? records.length - 1; i >= 0; i--) + if (records[i]!.events.some(e => e.kind === 'user_turn')) { start = i; break; } + const activePlans = inits.filter(init => init.record >= start).map(init => init.args[1]); + const wanted = new Set(withResults(currentToolUseId)); + let afterClose = false; + for (const record of records.slice(start)) for (const e of record.events) { + if (e.kind === 'use' && e.name === 'Read' && tests.read(e.input?.file_path) === 'close') afterClose = true; + if (e.kind === 'message' && afterClose) wanted.add(record); + if (e.kind === 'use' && (e.name === 'Write' || e.name === 'Edit') && activePlans.some(plan => sameNativePath(plan, e.input?.file_path))) + wanted.add(record); + if (e.kind !== 'result') continue; + const use = useOf.get(e.toolUseId)?.use; + if (e.isError || (use?.name === 'Read' && tests.read(use.input?.file_path)) || + (use?.name === 'Bash' && record.jsonResults?.includes(e.toolUseId))) wanted.add(record); + } + load([...wanted]); + // ENG-4: a dedup reply's cited earlier Read (and its own reply) loads even before the window. + for (let pending = [...wanted]; pending.length;) { + const next: IndexedRecord[] = []; + for (const record of pending) for (const e of record.events) { + const use = e.kind === 'result' ? useOf.get(e.toolUseId)?.use : undefined; + if (!use || use.name !== 'Read' || textResult(e) !== DEDUP_REPLY) continue; + const prior = reads.findLast(r => r.use.order < use.order && sameNativePath(r.use.input?.file_path, use.input?.file_path)); + if (prior) next.push(...withResults(prior.use.toolUseId).filter(r => !r.full)); + } + load(next); + pending = next; + } + }; +} + +/** The guard's owned read: the bounded reader under this policy, keeping the prefix witness across reads. */ +export function readGuardJournal(file: string, owners: readonly string[], sessionId: string, currentToolUseId: string, + tests: GuardJournalTests, prior?: JournalPrefix, measure?: OwnedReadMeasure): OwnedRead { + return readOwnedClaudePublicTranscript(file, owners, sessionId, + { prior, measure, policy: { indexInput: indexInput(tests), select: select(tests, currentToolUseId) } }); +} diff --git a/autoplan/bin/guard-reasons.ts b/autoplan/bin/guard-reasons.ts index 10d937e10..4093c914c 100644 --- a/autoplan/bin/guard-reasons.ts +++ b/autoplan/bin/guard-reasons.ts @@ -31,7 +31,7 @@ export const REASONS = { identity: { disposition: 'fallback', text: () => 'The session journal failed its identity checks (a link, a foreign session, the wrong directory layout, or a different or shrunken file).' }, malformed: { disposition: 'fallback', text: () => 'A complete session journal record is not valid JSON or UTF-8, or its records contradict each other.' }, journal_missing: { disposition: 'fallback', text: () => 'The session journal does not exist, so no missing-publication conclusion has been made.' }, - changing: { disposition: 'transient', text: () => 'Claude Code was still writing the session journal on every read. Wait for the current response to finish, then make this phase-entry call again; no missing-publication conclusion has been made.' }, + oversized_invocation: { disposition: 'fallback', text: d => `This /autoplan invocation is too large to verify: the journal records the guard must read in full exceed its ${d.limit ?? '32 MiB'} retained-data bound.` }, // Environment-unverifiable (UC1): allowed with a warning. unrecognized_shape: { disposition: 'unverified', text: d => `This Claude Code journal shape is not recognized (${d.cause}).` }, journal_lag: { disposition: 'unverified', text: () => 'Claude Code has not written the previous assistant message to the session journal.' }, @@ -75,7 +75,7 @@ export const REASONS = { export type ReasonCode = keyof typeof REASONS; /** Codes the October 7 wave removed; their anchors stay so old reports still resolve. */ -export const REMOVED_CODES = ['current_missing', 'pending_read'] as const; +export const REMOVED_CODES = ['current_missing', 'pending_read', 'changing'] as const; export const anchor = (code: string) => code.replace(/_/g, '-'); export const reasonCode = (code: string): ReasonCode => diff --git a/autoplan/bin/phase-publication-hook.ts b/autoplan/bin/phase-publication-hook.ts index f6de0de14..e069926a3 100644 --- a/autoplan/bin/phase-publication-hook.ts +++ b/autoplan/bin/phase-publication-hook.ts @@ -8,8 +8,9 @@ import { fileURLToPath } from 'node:url'; import { isDeepStrictEqual } from 'node:util'; import { extractImplementationPlan, checkPhaseImplementation, acceptedBlocks } from '../../bin/gstack-autoplan-snapshot'; import { autoplanPhaseCompletions } from '../../lib/autoplan-phase-publication'; -import { readOwnedClaudePublicTranscript, nativePathSpelling, ownedNativePath, sameNativePath, - type ClaudeParentPublicEvent, transcriptReadLimit } from '../../lib/claude-public-transcript'; +import { nativePathSpelling, ownedNativePath, sameNativePath, type ClaudeParentPublicEvent } from '../../lib/claude-journal-records'; +import { ownedRecordLimit, ownedRetainedLimit, type JournalPrefix, type OwnedRead, type OwnedReadMeasure } from '../../lib/claude-owned-journal'; +import { readGuardJournal, textResult, DEDUP_REPLY } from './guard-journal'; import { resolveStateRoot } from '../../lib/state-root'; import { REASONS, reasonCode, reasonText, type Detail, type ReasonCode } from './guard-reasons'; import { logGuardDecision } from './guard-log'; @@ -21,6 +22,16 @@ type Use = Event & { kind: 'use' }; type Tool = Extract; type Turn = Extract; const isUse = (e: Event): e is Use => e.kind === 'use'; +/** ENG-5: one pass groups each tool id's uses and results, so evaluation never rescans per tool. */ +function byTool(events: readonly Event[]) { + const uses = new Map(), results = new Map(); + for (const e of events) if (e.kind === 'use' || e.kind === 'result') { + const map = (e.kind === 'use' ? uses : results) as Map, list = map.get(e.toolUseId) ?? []; + list.push(e); map.set(e.toolUseId, list); + } + return { uses: (id: string) => uses.get(id) ?? [], results: (id: string) => results.get(id) ?? [] }; +} +type Tools = ReturnType; const number: Record = { ceo: 1, design: 2, dx: 2.5, eng: 3, tasks: 4 }; const object = (x: unknown): x is Record => x !== null && typeof x === 'object' && !Array.isArray(x); const positive = (x: unknown): x is number => Number.isSafeInteger(x) && (x as number) > 0; @@ -212,17 +223,10 @@ export function boundAutoplanPhaseConsumption(events: Event[], use: Use, cwd: st return consumption(use, cwd, root, invocation(events.filter(e => e.order < use.order), root)); } -function textResult(event: Event): string | undefined { - if (event.kind !== 'result' || event.isError !== false) return; - if (typeof event.content === 'string') return event.content; - if (Array.isArray(event.content) && event.content.length === 1 && event.content[0]?.type === 'text' && - typeof event.content[0].text === 'string') return event.content[0].text; -} - /** Authenticate the existing direct-create result; this does not prove its shell command's origin. */ -function checkpointResult(result: Event, entered: Event[], init: Invocation): { phase: Phase; path: string } | undefined { +function checkpointResult(result: Event, tools: Tools, init: Invocation): { phase: Phase; path: string } | undefined { if (result.kind !== 'result') return; - const use = entered.find((e): e is Use => isUse(e) && e.toolUseId === result.toolUseId); + const use = tools.uses(result.toolUseId)[0]; if (use?.name !== 'Bash' || use.order >= result.order) return; const text = textResult(result); if (text === undefined) return; @@ -261,11 +265,12 @@ function invocation(events: Event[], root: string): Invocation { let bound: Invocation | undefined; let chosen: Record | undefined; let unbindable = false; + const tools = byTool(events); for (const use of events) { if (use.kind !== 'use' || use.name !== 'Bash') continue; const args = initArguments(use.input?.command, root); if (!args) { unbindable ||= typeof use.input?.command === 'string' && UNBINDABLE_INIT.test(use.input.command) && /\.md\b/.test(use.input.command); continue; } - const results = events.filter(x => x.kind === 'result' && x.toolUseId === use.toolUseId && x.order > use.order); + const results = tools.results(use.toolUseId).filter(x => x.order > use.order); if (results.length !== 1) fail('init_mismatch'); const text = textResult(results[0]!); if (text === undefined) fail('init_failed'); @@ -295,7 +300,7 @@ export function autoplanReadRange(use: Use, result: Event, content: string, hist while (true) { if (use.name !== 'Read' || result.kind !== 'result' || result.toolUseId !== use.toolUseId || result.sessionId !== use.sessionId || result.isError !== false || result.order <= use.order || !object(result.file)) return; - if (textResult(result) !== 'Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.' || + if (textResult(result) !== DEDUP_REPLY || !isDeepStrictEqual(Object.keys(result.file), ['filePath']) || !samePath(result.file.filePath, use.input?.file_path)) break; // Pinned native dedup requires the same offset/limit and a non-truncated prior // Read. Seeded-context notices without that native delivery supply no range. @@ -388,10 +393,10 @@ function verifyCloseEdits(events: Event[], closeOrder: number, init: Invocation) const edits = events.filter((e): e is Use => e.kind === 'use' && e.order > closeOrder && ['Write', 'Edit'].includes(e.name ?? '') && samePath(e.input?.file_path, init.activePlan)); if (!edits.length) return; - const current = read(init.activePlan); + const current = read(init.activePlan), tools = byTool(events); let prior = current; for (const use of edits.toReversed()) { - const results = events.filter((e): e is Tool => e.kind === 'result' && e.toolUseId === use.toolUseId); + const results = tools.results(use.toolUseId); if (results.length !== 1) fail('mutation_pending'); if (results[0]!.isError === true) continue; const input = use.input; @@ -434,8 +439,9 @@ function requirePublication(phase: Phase, entryOrder: number, entered: Event[], if (checkpoint && JSON.parse(/^Binding: (.+)$/m.exec(content)![1]!).checkpointPath !== checkpoint) fail('close_stale', { cause: `The Phase ${number[phase]} close packet belongs to an earlier checkpoint` }); let closeOrder = -1; + const tools = byTool(entered); for (const use of closeReads.filter(e => native(e) === latestPath)) { - const results = entered.filter(e => e.kind === 'result' && e.toolUseId === use.toolUseId); + const results = tools.results(use.toolUseId); if (results.length !== 1) continue; const range = autoplanReadRange(use, results[0]!, content, entered); if (!range) continue; @@ -444,7 +450,7 @@ function requirePublication(phase: Phase, entryOrder: number, entered: Event[], } if (covered.size !== content.split('\n').length) fail('close_incomplete', { phase: number[phase] }); const pending = entered.some(e => e.kind === 'use' && e.order > closeOrder && ['Write', 'Edit'].includes(e.name ?? '') && - samePath(e.input?.file_path, init.activePlan) && !entered.some(r => r.kind === 'result' && r.toolUseId === e.toolUseId)); + samePath(e.input?.file_path, init.activePlan) && !tools.results(e.toolUseId).length); if (pending) fail('mutation_pending'); if (current) verifyCloseEdits(entered, closeOrder, init); const reports = entered.filter((e): e is Event & { kind: 'message' } => e.kind === 'message' && e.order > closeOrder && @@ -518,9 +524,8 @@ function payloadDecision(input: PublicationHookInput, root: string, events: Even /** ENG-2: a tool still waiting for its result outside the last journaled message means the journal lags more than one message. */ export function journalLagged(events: Event[]): boolean { - const lastMessage = events.findLast(e => e.messageId !== undefined && e.kind !== 'result')?.messageId; - return events.some(e => isUse(e) && (e.messageId === undefined || e.messageId !== lastMessage) && - !events.some(r => r.kind === 'result' && r.toolUseId === e.toolUseId)); + const lastMessage = events.findLast(e => e.messageId !== undefined && e.kind !== 'result')?.messageId, tools = byTool(events); + return events.some(e => isUse(e) && (e.messageId === undefined || e.messageId !== lastMessage) && !tools.results(e.toolUseId).length); } function consistentEvents(input: PublicationHookInput, events: Event[]): void { @@ -558,11 +563,11 @@ function phaseDecision(input: PublicationHookInput, root: string, before: Event[ const entered = before.filter((e, i) => e.order > init.start && !disarmed[i]); const target = consumption(requested, input.cwd, root, init)!.phase; let phase: Phase | undefined, entryOrder = init.start, checkpoint: string | undefined; - const seenCheckpoints = new Set(), preparedCheckpoints = new Map(); + const seenCheckpoints = new Set(), preparedCheckpoints = new Map(), tools = byTool(entered); for (const use of entered) { if (use.kind === 'result') { let created: ReturnType; - try { created = checkpointResult(use, entered, init); } catch { continue; } + try { created = checkpointResult(use, tools, init); } catch { continue; } if (!created || seenCheckpoints.has(created.path)) continue; seenCheckpoints.add(created.path); if (phase && number[created.phase] < number[phase]) { @@ -576,7 +581,7 @@ function phaseDecision(input: PublicationHookInput, root: string, before: Event[ continue; } if (!isUse(use) || !['Read', 'Agent'].includes(use.name ?? '')) continue; - const results = entered.filter((e): e is Tool => e.kind === 'result' && e.toolUseId === use.toolUseId); + const results = tools.results(use.toolUseId); if (results.length !== 1 || results[0]!.isError !== false || results[0]!.order <= use.order) continue; let next: Consumer | undefined; try { next = consumption(use, input.cwd, root, init, true); } catch { continue; } @@ -592,10 +597,10 @@ function phaseDecision(input: PublicationHookInput, root: string, before: Event[ } // One batch rule on both paths: guarded calls in the current call's message // that target the same phase are one batch; another phase is never batched. - const siblings = entered.filter(e => isUse(e) && candidate(e, input.cwd) && sameMessage(e, current)); - if (siblings.some(e => targetPhase(e as Use, input.cwd) !== target)) fail('cross_phase_batch'); - if (entered.some(e => e.kind === 'use' && candidate(e, input.cwd) && !siblings.includes(e) && - !entered.some(r => r.kind === 'result' && r.toolUseId === e.toolUseId))) fail('entry_pending'); + const siblings = new Set(entered.filter(e => isUse(e) && candidate(e, input.cwd) && sameMessage(e, current))); + if ([...siblings].some(e => targetPhase(e as Use, input.cwd) !== target)) fail('cross_phase_batch'); + if (entered.some(e => e.kind === 'use' && candidate(e, input.cwd) && !siblings.has(e) && + !tools.results(e.toolUseId).length)) fail('entry_pending'); if (!phase) { if (target !== 'ceo') fail('phase_order'); return { allow: true }; @@ -637,7 +642,6 @@ export function publicationHookOutput(decision: Decision | PublicationDecision): permissionDecisionReason: `[autoplan] ${decision.reason}` } }; } -type OwnedRead = ReturnType; type EvaluationPath = 'journal' | 'payload' | 'none'; const mib = (bytes: number) => { const m = bytes / (1024 * 1024); return `${m >= 10 ? Math.round(m) : Number(m.toPrecision(2))} MiB`; }; @@ -665,19 +669,23 @@ function decided(decision: Decision, via: EvaluationPath, version?: string): obj const denied = (code: ReasonCode, version?: string, detail: Detail = {}, via: EvaluationPath = 'none') => decided({ allow: false, code, reason: '', detail }, via, version); -/** Read every owner the hook accepts (project root, then its linked worktrees). */ -function ownedRead(journal: string, owners: string[], sessionId: string): OwnedRead { - const reads = [readOwnedClaudePublicTranscript(journal, owners[0]!, sessionId)]; - if (reads[0]!.transcript.reason === 'foreign_cwd') - for (const owner of owners.slice(1)) reads.push(readOwnedClaudePublicTranscript(journal, owner, sessionId)); - return reads.find(r => r.transcript.status === 'ready') ?? reads.find(r => r.transcript.reason !== 'foreign_cwd') ?? reads[0]!; +/** Read the journal for every owner the hook accepts (project root, then its linked worktrees), with the hook's own classifiers. */ +export function ownedRead(journal: string, owners: string[], input: PublicationHookInput, root: string, prior: JournalPrefix | undefined, + measure: OwnedReadMeasure): OwnedRead { + return readGuardJournal(journal, owners, input.session_id, input.tool_use_id, { + init: command => { try { return initArguments(command, root); } catch { return undefined; } }, + read: file => artifactName(file, input.cwd, true) && path.basename(requestedPath(input.cwd, file as string)) === 'close-packet.md' ? 'close' + : phaseName(file, input.cwd) || artifactName(file, input.cwd) ? 'phase' : undefined, + reviewer: toolInput => candidate({ name: 'Agent', input: toolInput }, input.cwd), + }, prior, measure); } /** * A ready journal holding the current call takes the journal path; one that * lacks it takes the payload path at once (Claude Code may write the record only * with its result). The 2-second window remains for a journal that does not exist - * yet, a journal lagging more than one message, and the unrecognized-shape double read. + * yet, a journal lagging more than one message, and the unrecognized-shape double + * read. Claude Code's appends never fail a read; a changed prefix is `rewritten`. */ export async function runPublicationHook(value: unknown, root: string): Promise { let version: string | undefined; @@ -695,9 +703,12 @@ export async function runPublicationHook(value: unknown, root: string): Promise< if (!ownPath(projectCwd)) return denied('hook_input'); const journal = nativePathSpelling(input.transcript_path), owners = [projectCwd, ...linkedWorktrees(projectCwd)]; const deadline = performance.now() + 2_000; - let read: OwnedRead, stable: string | undefined; + let read: OwnedRead, first: JournalPrefix | undefined, unrecognized = false; for (;;) { - read = ownedRead(journal, owners, input.session_id); + // Every later read must see the first read's bytes unchanged (CEO-2); a mismatch is `rewritten`. + const measure: OwnedReadMeasure = {}; + read = ownedRead(journal, owners, input, root, first, measure); + first ??= measure.prefix; version = read.claudeVersion ?? read.diagnostic?.claudeVersion; const code = read.transcript.reason, expired = performance.now() >= deadline; if (read.transcript.status === 'ready') { @@ -706,22 +717,23 @@ export async function runPublicationHook(value: unknown, root: string): Promise< if (!journalLagged(read.events)) return decided(payloadDecision(input, root, read.events), 'payload', version); if (expired) return unverifiedOutput('journal_lag', {}, version, 'payload'); } else if (code === 'too_large') { - let size: string | undefined; - try { size = mib(fs.statSync(journal).size); } catch { /* size stays generic */ } - return unverifiedOutput('too_large', { size, limit: mib(transcriptReadLimit()) }, version, 'none'); - } else if (code && code !== 'changing' && !code.startsWith('unrecognized_shape:')) { - return denied(code as ReasonCode, version); - } else if (code?.startsWith('unrecognized_shape:') && read.diagnostic?.complete) { - if (stable === read.diagnostic.sha256) return unverifiedOutput(code, { cause: code }, version, 'none', read.diagnostic.rootShape); - stable = read.diagnostic.sha256; - } else stable = undefined; - if (expired) break; + return unverifiedOutput('too_large', { size: measure.recordBytes ? mib(measure.recordBytes) : undefined, limit: mib(ownedRecordLimit()) }, + version, 'none'); + } else if (code === 'rewritten') { + return unverifiedOutput('rewritten', {}, version, 'none'); + } else if (code === 'oversized_invocation') { + return denied(code, version, { limit: mib(ownedRetainedLimit()) }); + } else if (code?.startsWith('unrecognized_shape:')) { + // A second read over the same prefix: the shape is Claude Code's, not a write in progress. + if (unrecognized) return unverifiedOutput(code, { cause: code }, version, 'none', read.diagnostic?.rootShape); + unrecognized = true; + } else if (code) return denied(code as ReasonCode, version); + if (expired && !code?.startsWith('unrecognized_shape:')) break; await new Promise(resolve => setTimeout(resolve, 50)); } - const code = read.transcript.reason; // A journal with bytes but no owned records yet: Claude Code has not written an ancestor. - if (!code && read.diagnostic) return unverifiedOutput('journal_lag', {}, version, 'none'); - return denied(code ? 'changing' : 'journal_missing', version); + if (read.diagnostic) return unverifiedOutput('journal_lag', {}, version, 'none'); + return denied('journal_missing', version); } catch { return denied('installation', version); } diff --git a/docs/autoplan-guard-troubleshooting.md b/docs/autoplan-guard-troubleshooting.md index 4c1c8f447..f2bbf073e 100644 --- a/docs/autoplan-guard-troubleshooting.md +++ b/docs/autoplan-guard-troubleshooting.md @@ -79,9 +79,11 @@ missing) and did not catch up within 2 seconds. ### `rewritten` -The journal bytes the guard had already read changed during one hook call. -Claude Code normally only appends. Rewrites between hook calls are not -detected. +The journal bytes the guard had already read changed during one hook call: +a later read's sha256 of bytes [0, first size) differs from the first read's, +or a record pass two reloads no longer parses to the record pass one indexed. +Claude Code normally only appends, and appends never fail a read. Rewrites +between hook calls are not detected (an accepted limit). ### `too_large` @@ -89,6 +91,7 @@ detected. A single journal record is over the 32 MiB record limit (`OWNED_RECORD_MAX_BYTES`). The guard reads long journals record by record, so a large journal alone never triggers this; one enormous record (for example a huge pasted file) does. +A record still being written (no newline yet) is not read at all. ### `unseen_version` @@ -127,6 +130,15 @@ The journal path, its directories or the file failed the identity checks: a symlink, a foreign session, the wrong directory layout, a different file, or a journal that shrank. A rerun hits the same check. + +### `oversized_invocation` +The current `/autoplan` invocation needs more journal records read in full +than the 32 MiB retained-data bound (`OWNED_RETAINED_MAX_BYTES`). The guard +indexes every record without its content and reloads in full only what it +evaluates: snapshot init results, guarded and close-packet Reads, structured +Bash results, error results, assistant text after the first close-packet Read +and edits of the active plan. A rerun reads the same records; use the fallback. + ### `malformed` A complete journal line is not valid JSON or UTF-8, or its records contradict @@ -263,12 +275,14 @@ A phase-entry call from an earlier message has no result yet. Wait for it. ### `mutation_pending` An edit to the active plan after the close Read has no result yet. Wait for it. +## Removed codes + ### `changing` -Claude Code was writing the journal on every read for 2 seconds. Wait for the -current response to finish. - -## Removed codes +Before the October 7, 2026 release, `changing` denied a call when Claude Code +appended to the journal while the guard read it. The bounded reader reads the +bytes present at open and ignores a trailing record still being written, so +appends never fail a read; a changed prefix is [`rewritten`](#rewritten). Before the October 7, 2026 release, "Native parent evidence has not reached diff --git a/lib/claude-journal-records.ts b/lib/claude-journal-records.ts new file mode 100644 index 000000000..2c6550efc --- /dev/null +++ b/lib/claude-journal-records.ts @@ -0,0 +1,374 @@ +/** + * One reading of a Claude Code journal record, shared by the batch reader + * (`readPlanCountTranscript`) and the guard's bounded owned reader + * (`lib/claude-owned-journal.ts`), so the two cannot drift: the public events a + * record yields, the metadata its ownership is judged on, and the ownership, + * ancestry and causal-order checks over that metadata. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** Optional public tool projection for the Autoplan delivery audit; never thinking. */ +export interface NativePublicToolEvent { + sessionId: string; + timestamp: string; + toolUseId: string; + kind: 'use' | 'result'; + name?: string; + /** Exact native message/request identity, used only for owned queued tools. */ + messageId?: string; + requestId?: string; + input?: Record; + content?: unknown; + file?: unknown; + isError?: boolean; + /** Owned reads only: Claude Code launched this Agent in the background (its result is a launch receipt). */ + async?: boolean; +} + +/** Hook-only verified causal order (physical order for independent ready records). The existing fixture projection remains unchanged. */ +export type ClaudeParentPublicEvent = (NativePublicToolEvent | { + kind: 'message'; sessionId: string; timestamp: string; text: string; +} | { + kind: 'end_turn' | 'user_turn'; sessionId: string; timestamp: string; autoplan?: boolean; +} | { + /** Claude Code's completion notice for a background Agent (origin kind task-notification). */ + kind: 'task_notification'; sessionId: string; timestamp: string; notifiedToolUseId: string; +}) & { order: number; messageId?: string; requestId?: string }; + +/** + * Why an owned journal supplied no owned lines (docs/autoplan-guard-troubleshooting.md). + * Positive identity conflicts are hard; only `unrecognized_shape:*` may degrade to an advisory. + */ +export type OwnedTranscriptReason = 'competing_root' | 'foreign_cwd' | 'sidechain' | 'agent' | 'cycle' | 'too_large' | + 'rewritten' | 'oversized_invocation' | 'identity' | 'malformed' | `unrecognized_shape:${string}`; + +export const object = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); +export const validTimestamp = (value: unknown): value is string => + typeof value === 'string' && Number.isFinite(Date.parse(value)); +/** A record's own Claude Code version string, shape-checked; never other content. */ +export const claudeVersionOf = (record: Record): string | undefined => + typeof record.version === 'string' && /^\d+\.\d+\.\d+[0-9A-Za-z.+-]{0,24}$/.test(record.version) ? record.version : undefined; +const versions = new Map(); +/** claudeVersionOf for a record's index entry: one check per distinct version string. */ +const indexedVersion = (record: Record): string | undefined => { + if (typeof record.version !== 'string') return; + if (!versions.has(record.version) && versions.size < 64) versions.set(record.version, claudeVersionOf(record)); + return versions.has(record.version) ? versions.get(record.version) : claudeVersionOf(record); +}; +/** A native record uuid: 8-4-4-4-12 hex digits (checked per character; every record has two). */ +export const nativeUuid = (value: unknown): value is string => { + if (typeof value !== 'string' || value.length !== 36) return false; + for (let i = 0; i < 36; i++) { + const c = value.charCodeAt(i); + if (i === 8 || i === 13 || i === 18 || i === 23) { if (c !== 45) return false; } + else if (!((c >= 48 && c <= 57) || (c >= 97 && c <= 102) || (c >= 65 && c <= 70))) return false; + } + return true; +}; + +/** + * Windows spells one native path C:\, c:\, C:/ or Git Bash /c/ (hook env, + * journal and tool input disagree). Fold separators, the MSYS drive prefix and + * drive-letter case only. No resolve: `.`, `..` and doubled separators stay + * distinct, so a normalize check after the fold still rejects them. The path + * module is a parameter so win32 call sites are unit-testable on any host. + */ +export function nativePathSpelling(value: string, p: typeof path = path): string { + if (p.sep !== '\\') return value; + return value.replace(/^\/([A-Za-z])(?=\/|$)/, '$1:').replaceAll('/', '\\') + .replace(/^[a-z](?=:)/, drive => drive.toUpperCase()); +} +/** Validation-then-compare: absolute, and its folded spelling is already normal. */ +export function ownedNativePath(value: unknown, p: typeof path = path): value is string { + if (typeof value !== 'string') return false; + const folded = nativePathSpelling(value, p); + return p.isAbsolute(folded) && p.normalize(folded) === folded; +} +export function sameNativePath(a: unknown, b: unknown, p: typeof path = path): boolean { + return typeof a === 'string' && typeof b === 'string' && nativePathSpelling(a, p) === nativePathSpelling(b, p); +} +function sameRealPath(a: unknown, b: unknown): boolean { + try { return typeof a === 'string' && typeof b === 'string' && sameNativePath(fs.realpathSync(a), fs.realpathSync(b)); } + catch { return false; } +} + +/** Read one length-delimited protobuf field, rejecting malformed/ambiguous input. */ +function signatureField(bytes: Uint8Array | undefined, wanted: number): Uint8Array | undefined { + if (!bytes) return; + let cursor = 0; + let result: Uint8Array | undefined; + let seen = false; + const integer = () => { + let value = 0; + for (let shift = 0; shift < 70; shift += 7) { + if (cursor >= bytes.length) throw new Error('truncated signature'); + const byte = bytes[cursor++]!; + value += (byte & 127) * 2 ** shift; + if (!Number.isSafeInteger(value)) throw new Error('signature integer overflow'); + if (!(byte & 128)) return value; + } + throw new Error('overlong signature integer'); + }; + while (cursor < bytes.length) { + const key = integer(); + const field = Math.floor(key / 8); + if (field < 1 || field > 0x1fffffff) throw new Error('invalid signature field'); + if (field === wanted) { + if (seen) throw new Error('duplicate signature field'); + seen = true; + } + switch (key % 8) { + case 0: integer(); break; + case 1: cursor += 8; break; + case 2: { + const length = integer(); + if (length > bytes.length - cursor) throw new Error('truncated signature field'); + if (field === wanted) result = bytes.subarray(cursor, cursor + length); + cursor += length; + break; + } + case 5: cursor += 4; break; + default: throw new Error('unsupported signature wire type'); + } + if (cursor > bytes.length) throw new Error('truncated signature field'); + } + return result; +} + +/** + * Claude's public narration renderer classifies signature fields 2→1→8 as + * block_kind="narration": summaries of inter-tool prose, not private reasoning. + * Match that metadata only in this already-owned native transcript. This is + * classification, not cryptographic signature verification. Never read the + * thinking text of an untagged, unknown, malformed or legacy block. + */ +function publicNarrationText(block: Record): string | undefined { + if (block.type !== 'thinking' || typeof block.signature !== 'string' || + block.signature.length > 64 * 1024 || !/^[A-Za-z0-9+/]+={0,2}$/.test(block.signature)) return; + try { + const bytes = Buffer.from(block.signature, 'base64'); + const canonical = bytes.toString('base64'); + if (block.signature !== canonical && block.signature !== canonical.replace(/=+$/, '')) return; + const tag = signatureField(signatureField(signatureField(bytes, 2), 1), 8); + if (!tag || Buffer.from(tag).toString('utf8') !== 'narration') return; + return typeof block.thinking === 'string' && block.thinking.trim() ? block.thinking : undefined; + } catch { return; } +} + +const AUTOPLAN_SLASH = /^autoplan<\/command-message>\n\/autoplan<\/command-name>(?:\n[\s\S]*<\/command-args>)?$/; + +/** + * The public events one record yields, for a record its reader already + * accepted (it carries a message object). `owned` adds the hook-only events + * (user turns, end_turn, background completions) and each event's position + * inside the record as `order`; the caller adds the record's base. The batch + * projection (`owned` false) keeps its validated batch identity and no order. + */ +export function recordEvents(record: Record, owned: boolean, + timestampValid = validTimestamp(record.timestamp)): { events: ClaudeParentPublicEvent[]; slots: number } { + const events: ClaudeParentPublicEvent[] = [], message = record.message, sessionId = record.sessionId, timestamp = record.timestamp; + let slot = 0; + if (owned && message.role === 'user') { + const notified = record.origin?.kind === 'task-notification' && typeof message.content === 'string' + ? /([A-Za-z0-9_-]{1,160})<\/tool-use-id>/.exec(message.content)?.[1] : undefined; + if (notified) events.push({ kind: 'task_notification', sessionId, timestamp, order: slot++, notifiedToolUseId: notified }); + if (record.origin?.kind === 'human' && record.isMeta !== true && nativeUuid(record.promptId) && typeof message.content === 'string') { + const autoplan = AUTOPLAN_SLASH.test(message.content); + if (record.promptSource === 'typed' || autoplan) events.push({ kind: 'user_turn', sessionId, timestamp, order: slot++, autoplan }); + } + } + if (!Array.isArray(message.content)) return { events, slots: slot }; + if (!timestampValid) slot += message.content.length; + else for (const block of message.content) { + const order = slot++; + if (!object(block)) continue; + let event: Record | undefined; + if (message.role === 'assistant' && block.type === 'tool_use' && + typeof block.id === 'string' && typeof block.name === 'string' && object(block.input)) { + event = { sessionId, timestamp, toolUseId: block.id, kind: 'use', name: block.name, input: block.input }; + if (!owned && typeof message.id === 'string' && /^msg_[A-Za-z0-9_-]{1,160}$/.test(message.id) && + typeof record.requestId === 'string' && /^req_[A-Za-z0-9_-]{1,160}$/.test(record.requestId)) { + event.messageId = message.id; event.requestId = record.requestId; + } + } else if (message.role === 'user' && block.type === 'tool_result' && typeof block.tool_use_id === 'string') { + event = { sessionId, timestamp, toolUseId: block.tool_use_id, kind: 'result', content: block.content, + file: record.toolUseResult?.file, isError: block.is_error === true }; + if (owned && record.toolUseResult?.isAsync === true) event.async = true; + } else if (message.role === 'assistant') { + const text = block.type === 'text' && typeof block.text === 'string' && block.text.trim() ? block.text : publicNarrationText(block); + if (text) event = { kind: 'message', sessionId, text, timestamp }; + } + if (!event) continue; + if (owned) { + event.order = order; + if (typeof message.id === 'string') event.messageId = message.id; + if (typeof record.requestId === 'string') event.requestId = record.requestId; + } + events.push(event as unknown as ClaudeParentPublicEvent); + } + if (owned && message.role === 'assistant' && message.stop_reason === 'end_turn') + events.push({ kind: 'end_turn', sessionId, timestamp, order: slot++, ...(typeof message.id === 'string' ? { messageId: message.id } : {}) }); + return { events, slots: slot }; +} + +/** What the ownership checks read from one record: metadata, never content. */ +export interface RecordMeta { + /** Position in the metas array (physical order among the journal's records); breaks causal-order ties. */ + index: number; + uuid?: string; + /** parentUuid, or a compact boundary's logicalParentUuid. */ + parent?: string; + parentNull: boolean; + /** Parent-session metadata: no agentId, isSidechain false, absolute cwd, native uuid, valid timestamp. */ + node: boolean; + agent: boolean; + sidechain: boolean; + /** The record opens a user or assistant turn outside any subagent. */ + conversation: boolean; + message: boolean; + role?: string; + sessionStart: boolean; + timestampValid: boolean; + cwd?: string; + type?: string; + subtype?: string; + attachmentType?: string; + version?: string; +} + +/** Claude writes each SessionStart hook result as a message-less attachment chained above the first user turn. */ +const sessionStartRecord = (r: Record): boolean => r.type === 'attachment' && r.message == null && + object(r.attachment) && r.attachment.hookEvent === 'SessionStart' && + typeof r.attachment.type === 'string' && /^hook_[a-z_]{1,48}$/.test(r.attachment.type); + +/** Record type names only, never content: `attachment:hook_success`, `system:compact_boundary`. */ +export const shapeOf = (m: RecordMeta): string => [m.type, m.subtype, m.attachmentType] + .filter(x => typeof x === 'string' && x).map(x => x!.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 40)).join(':') || 'untyped'; +/** Why a record that is not a parent-session node cannot carry ownership. */ +const excludedOf = (m: RecordMeta): OwnedTranscriptReason => + m.agent ? 'agent' : m.sidechain ? 'sidechain' : 'unrecognized_shape:record_metadata'; +const cwds = new Map(); + +export function recordMeta(r: Record, index: number): RecordMeta { + const message = object(r.message); + // One shared string per distinct cwd keeps a long journal's index small. + let cwd: string | undefined = typeof r.cwd === 'string' ? cwds.get(r.cwd) : undefined; + if (cwd === undefined && typeof r.cwd === 'string') { cwd = r.cwd; if (cwds.size < 256) cwds.set(r.cwd, r.cwd); } + const uuid = nativeUuid(r.uuid) ? r.uuid : undefined, timestampValid = validTimestamp(r.timestamp); + return { + index, uuid, + parent: nativeUuid(r.parentUuid) ? r.parentUuid : r.parentUuid === null && r.type === 'system' && r.subtype === 'compact_boundary' && + r.message == null && nativeUuid(r.logicalParentUuid) ? r.logicalParentUuid : undefined, + parentNull: r.parentUuid === null, + node: r.agentId == null && r.isSidechain === false && cwd !== undefined && path.isAbsolute(cwd) && uuid !== undefined && timestampValid, + agent: r.agentId != null, sidechain: r.isSidechain !== false, + conversation: r.agentId == null && message && (r.message.role === 'user' || r.message.role === 'assistant'), + message, role: message && typeof r.message.role === 'string' ? r.message.role : undefined, + sessionStart: sessionStartRecord(r), timestampValid, cwd, + type: typeof r.type === 'string' ? r.type : undefined, subtype: typeof r.subtype === 'string' ? r.subtype : undefined, + attachmentType: object(r.attachment) && typeof r.attachment.type === 'string' ? r.attachment.type : undefined, + version: indexedVersion(r), + }; +} + +export type OwnedOrder = { order: number[]; root?: string } | { reason: OwnedTranscriptReason; shape: string[] }; + +/** + * Native journal writes can flush children before parents. Owned readers use + * causal order (the physical indices of the owned records); ordinary readers + * only recover membership, keeping physical order. `metas` are the records of + * this session's own file, in physical order. + */ +export function ownedCausalOrder(metas: readonly RecordMeta[], cwd: string): OwnedOrder { + const first = metas.find(m => m.conversation); + if (!first) return { order: [] }; + const nodes = metas.filter(m => m.node); + // One map: each uuid's parent-session node, else the first record carrying it. + const byUuid = new Map(); + for (const m of metas) if (m.uuid) { + const prior = byUuid.get(m.uuid); + if (prior?.node && m.node) return { reason: 'competing_root', shape: ['duplicate_uuid'] }; + if (!prior || (m.node && !prior.node)) byUuid.set(m.uuid, m); + } + const byId = { get: (id: string) => { const m = byUuid.get(id); return m?.node ? m : undefined; } }; + if (byId.get(first.uuid ?? '') !== first) return { reason: excludedOf(first), shape: [shapeOf(first)] }; + // Anchor through the first observed conversation node, never an unrelated + // later root. An unflushed ancestor supplies no ownership yet. + const chain: RecordMeta[] = [first], seen = new Set(chain); + const shape = () => chain.map(shapeOf).reverse().slice(0, 32); + let genesis = false; + for (let at = first; ;) { + const id = at.parent; + if (!id) break; + const next = byId.get(id); + if (!next) { + const other = byUuid.get(id); + if (other) return { reason: excludedOf(other), shape: [shapeOf(other), ...shape()] }; + // A resumed fork begins with a boundary whose logical parent stayed in the + // source session. It is a root only as this file's first record; mid-file + // it may be an unflushed ancestor, so it supplies no ownership yet. + if (!at.parentNull || at !== metas.find(m => m.uuid)) return { order: [] }; + genesis = true; + break; + } + if (seen.has(next)) return { reason: 'cycle', shape: shape() }; + chain.push(next); seen.add(next); + at = next; + } + const top = chain.toReversed(), root = top[0]!; + const turn = top.findIndex(n => n.message); + const children = new Map(); + for (const node of nodes) if (node.parent) { + const list = children.get(node.parent) ?? []; list.push(node); children.set(node.parent, list); + } + // Uniqueness over every root candidate: a null-parent user, a SessionStart + // chain head carrying a conversation, and the resumed-genesis boundary. + const carriesTurn = (head: RecordMeta): boolean => { + for (const pending = [head]; pending.length;) for (const child of children.get(pending.pop()!.uuid!) ?? []) { + if (child.message) return true; + if (child.sessionStart) pending.push(child); + } + return false; + }; + const candidates = new Set([root, ...nodes.filter(m => m.parentNull && + (m.message ? m.role === 'user' : m.sessionStart)).filter(n => n.message || carriesTurn(n))]); + if (candidates.size > 1) return { reason: 'competing_root', shape: shape() }; + for (const node of genesis ? [root] : top.slice(0, turn + 1)) if (!sameNativePath(node.cwd, cwd)) + return { reason: sameRealPath(node.cwd, cwd) ? 'unrecognized_shape:cwd_spelling' : 'foreign_cwd', shape: shape() }; + if (!genesis) { + const preamble = top.slice(0, turn).find(n => !n.sessionStart); + const odd = !root.parentNull ? `root_parent:${shapeOf(root)}` + : preamble ? `preamble:${shapeOf(preamble)}` + : top[turn]!.role !== 'user' ? `first_turn:${shapeOf(top[turn]!)}` : undefined; + if (odd) return { reason: `unrecognized_shape:${odd}`, shape: shape() }; + } + // Stable topological traversal preserves physical order whenever two ready + // records have no parent dependency. No timestamp provides ordering credit. + const ready: number[] = []; + const offer = (value: number) => { + let i = ready.length; ready.push(value); + while (i > 0) { const p = (i - 1) >> 1; if (ready[p]! <= value) break; + ready[i] = ready[p]!; i = p; } + ready[i] = value; + }; + const take = () => { + const result = ready[0]!, value = ready.pop()!; + if (ready.length) { let i = 0; + while (i * 2 + 1 < ready.length) { let c = i * 2 + 1; + if (c + 1 < ready.length && ready[c + 1]! < ready[c]!) c++; + if (ready[c]! >= value) break; ready[i] = ready[c]!; i = c; } + ready[i] = value; + } + return result; + }; + const order: number[] = []; + offer(root.index); + while (ready.length) { + const node = metas[take()]!; + order.push(node.index); + for (const child of children.get(node.uuid!) ?? []) offer(child.index); + } + return { order, root: root.uuid }; +} diff --git a/lib/claude-owned-journal.ts b/lib/claude-owned-journal.ts new file mode 100644 index 000000000..b11894d7f --- /dev/null +++ b/lib/claude-owned-journal.ts @@ -0,0 +1,296 @@ +/** + * The publication guard's bounded, append-tolerant read of one parent session + * journal (CEO-2). Pass one streams the bytes present at open, up to the last + * complete line, and keeps a small index per record: ownership metadata, byte + * range, and the record's public events with their content stripped. Ownership, + * ancestry and causal order are decided on that index. Pass two (the policy's + * `select`) reloads in full only the records the evaluation reads, under a named + * retained-data bound. Batch readers keep `readPlanCountTranscript`'s whole-file + * read and its cap. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { createHash } from 'node:crypto'; +import { isUtf8 } from 'node:buffer'; +import { object, recordEvents, recordMeta, ownedCausalOrder, nativePathSpelling, ownedNativePath, sameNativePath, + type ClaudeParentPublicEvent, type OwnedTranscriptReason, type RecordMeta } from './claude-journal-records'; +import type { PlanCountTranscript } from './claude-public-transcript'; + +/** The largest single journal record the guard verifies; any record over it is `too_large` (docs/autoplan-guard-troubleshooting.md). */ +export const OWNED_RECORD_MAX_BYTES = 32 * 1024 * 1024; +/** The most record bytes pass two may reload in full for one evaluation; over it is `oversized_invocation`. */ +export const OWNED_RETAINED_MAX_BYTES = 32 * 1024 * 1024; +const lowered = (name: string, max: number) => { + const value = Number(process.env[name]); + return Number.isInteger(value) && value > 0 && value < max ? value : max; +}; +/** Test seam: GSTACK_TRANSCRIPT_TEST_MAX_BYTES can only LOWER the record bound, never raise it. */ +export const ownedRecordLimit = () => lowered('GSTACK_TRANSCRIPT_TEST_MAX_BYTES', OWNED_RECORD_MAX_BYTES); +/** Test seam: GSTACK_TRANSCRIPT_TEST_RETAINED_BYTES can only LOWER the retained bound, never raise it. */ +export const ownedRetainedLimit = () => lowered('GSTACK_TRANSCRIPT_TEST_RETAINED_BYTES', OWNED_RETAINED_MAX_BYTES); +const CHUNK_BYTES = 1024 * 1024; +/** Parsed records are garbage once indexed; collecting every 32 MiB keeps a 120 MiB journal's peak RSS under budget (CEO-2). */ +const COLLECT_EVERY_BYTES = 32 * 1024 * 1024; + +/** The bytes [0, size) one read saw; a later read in the same hook invocation must see the same prefix. */ +export interface JournalPrefix { size: number; sha256: string } + +/** One owned record in causal order: where it is, and its events (stripped until pass two loads it). */ +export interface IndexedRecord { + readonly offset: number; + readonly length: number; + readonly uuid?: string; + /** The first event order this record owns; pass two keeps it. */ + readonly base: number; + readonly slots: number; + /** Tool ids whose result text is a JSON object, so a selector can load structured output without every result. */ + readonly jsonResults?: readonly string[]; + events: ClaudeParentPublicEvent[]; + full: boolean; +} + +/** How the hook bounds a read: what the index keeps of a tool input, and which records pass two loads. */ +export interface OwnedReadPolicy { + /** The part of a tool_use input pass one keeps (small by construction); undefined keeps none. */ + indexInput(name: string, input: Record): Record | undefined; + /** Load the records the evaluation reads, in any number of rounds; `load` enforces the retained bound. */ + select(records: readonly IndexedRecord[], load: (records: readonly IndexedRecord[]) => void): void; +} + +/** What a refused owned read can report without content: inputs to the hook's guidance and advisory. */ +export interface OwnedTranscriptDiagnostic { + /** Claude Code version recorded in the journal's own records, when present. */ + claudeVersion?: string; + /** Record types from the journal head down to its first turn; never content. */ + rootShape: string[]; + /** Parent assistant tool_use ids present in complete records. */ + toolUseIds: string[]; + /** sha256 of the bytes present at open, so a second read can prove the same prefix. */ + sha256: string; + /** The journal ended at a record boundary when it was opened. */ + complete: boolean; +} + +export interface OwnedRead { + transcript: PlanCountTranscript; + events: ClaudeParentPublicEvent[]; + diagnostic?: OwnedTranscriptDiagnostic; + claudeVersion?: string; +} + +/** What one read measured, filled in for a caller that passes it (kept out of the result so equal journals read equal). */ +export interface OwnedReadMeasure { + /** The prefix this read hashed; pass it as `prior` to the next read in the same hook invocation. */ + prefix?: JournalPrefix; + /** too_large only: the oversized record's length in bytes. */ + recordBytes?: number; + /** Bytes of the pass-one index (stripped events); set it to 0 to request the measurement. */ + indexBytes?: number; + /** Bytes of the records pass two reloaded. */ + retainedBytes?: number; +} + +class OwnedReadError extends Error { + constructor(readonly reason: OwnedTranscriptReason, readonly bytes?: number) { super(reason); } +} + +/** Strip, in place, content the index never keeps: tool input beyond the policy's part, result bodies, message text. */ +function strip(events: ClaudeParentPublicEvent[], policy: OwnedReadPolicy): ClaudeParentPublicEvent[] { + for (const e of events) { + if (e.kind === 'use') e.input = e.input && policy.indexInput(e.name ?? '', e.input); + else if (e.kind === 'result') { e.content = undefined; e.file = undefined; } + else if (e.kind === 'message') e.text = ''; + } + return events; +} + +/** One record's index entry: its ownership metadata, byte range and (stripped) events. */ +interface Entry extends RecordMeta { + offset: number; + length: number; + events?: ClaudeParentPublicEvent[]; + slots: number; + /** The message content is an array (the record can make a journal ready). */ + array: boolean; + jsonResults?: string[]; +} + +interface Scanned { + entries: Entry[]; + toolUseIds: string[]; + claudeVersion?: string; + prefix: JournalPrefix; + complete: boolean; +} + +const resultText = (b: Record): string => typeof b.content === 'string' ? b.content + : Array.isArray(b.content) && b.content.length === 1 && typeof b.content[0]?.text === 'string' ? b.content[0].text : ''; + +/** Pass one: stream [0, size) to the last complete line; per-record bound; prefix hash; content-free index. */ +function scan(fd: number, size: number, sessionId: string, prior: JournalPrefix | undefined, + policy: OwnedReadPolicy | undefined): Scanned { + const limit = ownedRecordLimit(), hash = createHash('sha256'), filename = `${sessionId}.jsonl`; + const out: Scanned = { entries: [], toolUseIds: [], prefix: { size, sha256: '' }, complete: true }; + const chunk = Buffer.allocUnsafe(Math.min(CHUNK_BYTES, Math.max(size, 1))); + // The unfinished line carried into the next chunk; past the record bound only its length is kept. + let carry: Buffer[] = [], carryBytes = 0, carryOffset = 0; + const record = (line: string, offset: number, length: number) => { + if (!line.length || (line.charCodeAt(0) <= 32 && !line.trim())) return; + let value: unknown; + try { value = JSON.parse(line); } catch { throw new OwnedReadError('malformed'); } + if (!object(value) || value.sessionId !== sessionId || filename !== `${value.sessionId}.jsonl`) return; + const entry = recordMeta(value, out.entries.length) as Entry; + entry.offset = offset; entry.length = length; entry.slots = 0; entry.array = false; + out.entries.push(entry); + if (!entry.agent && !entry.sidechain) { + out.claudeVersion ??= entry.version; + if (entry.role === 'assistant' && Array.isArray(value.message.content)) for (const block of value.message.content) + if (object(block) && block.type === 'tool_use' && typeof block.id === 'string') out.toolUseIds.push(block.id); + } + if (!entry.message) return; + const full = recordEvents(value, true, entry.timestampValid); + entry.events = policy ? strip(full.events, policy) : full.events; + entry.slots = full.slots; + entry.array = Array.isArray(value.message.content); + if (entry.array) for (const b of value.message.content) + if (object(b) && b.type === 'tool_result' && typeof b.tool_use_id === 'string' && /^\s*\{/.test(resultText(b))) + (entry.jsonResults ??= []).push(b.tool_use_id); + }; + const lines = (region: Buffer, start: number) => { + if (!isUtf8(region)) throw new OwnedReadError('malformed'); + const text = region.toString('utf8'), ascii = text.length === region.length; + let offset = start; + for (const line of text.split('\n')) { + const length = ascii ? line.length : Buffer.byteLength(line); + if (length > limit) throw new OwnedReadError('too_large', length); + record(line, offset, length); + offset += length + 1; + } + }; + for (let pos = 0; pos < size;) { + const got = fs.readSync(fd, chunk, 0, Math.min(chunk.length, size - pos), pos); + if (got <= 0) throw new OwnedReadError('identity'); + const view = chunk.subarray(0, got); + if (prior && prior.size > 0 && pos < prior.size && pos + got >= prior.size) { + hash.update(view.subarray(0, prior.size - pos)); + if (hash.copy().digest('hex') !== prior.sha256) throw new OwnedReadError('rewritten'); + hash.update(view.subarray(prior.size - pos)); + } else hash.update(view); + const last = view.lastIndexOf(10); + if (last >= 0) { + const first = view.indexOf(10); + if (carryBytes + first > limit) throw new OwnedReadError('too_large', carryBytes + first); + // The carried line joins its end in a small copy; the rest of the chunk decodes in place. + if (carry.length) lines(Buffer.concat([...carry, view.subarray(0, first)]), carryOffset); + if (carry.length ? last > first : true) lines(view.subarray(carry.length ? first + 1 : 0, last), carry.length ? pos + first + 1 : pos); + carry = []; carryBytes = 0; carryOffset = pos + last + 1; + } + const rest = view.subarray(last + 1); + // An oversized unfinished record keeps counting without retaining its bytes. + if (rest.length && carryBytes + rest.length <= limit) carry.push(Buffer.from(rest)); + carryBytes += rest.length; + pos += got; + if (pos % COLLECT_EVERY_BYTES < got && typeof Bun !== 'undefined') Bun.gc(true); + } + if (carryBytes > limit) throw new OwnedReadError('too_large', carryBytes); + // A record still being written (no newline yet) is not evidence and is not read. + out.complete = carryBytes === 0; + out.prefix.sha256 = hash.digest('hex'); + return out; +} + +/** Ownership on the index, for each accepted owner directory; the first ready owner wins. */ +function owned(scanned: Scanned, owners: readonly string[]) { + const results = owners.map(cwd => { + const causal = ownedCausalOrder(scanned.entries, cwd); + if ('reason' in causal) return { status: causal.reason === 'competing_root' || causal.reason === 'cycle' ? 'error' as const : 'missing' as const, + reason: causal.reason, shape: causal.shape }; + return { status: causal.order.some(i => scanned.entries[i]!.array) ? 'ready' as const : 'missing' as const, + order: causal.order, reason: undefined, shape: [] as string[] }; + }); + return results.find(r => r.status === 'ready') ?? results.find(r => r.reason !== 'foreign_cwd') ?? results[0]!; +} + +/** + * Read exactly the native hook's parent file; never scan another session. + * Appends after open never fail the read; the same device and inode and no + * shrink are required. Without a policy every owned record is kept in full. + */ +export function readOwnedClaudePublicTranscript(file: string, owners: string | readonly string[], sessionId: string, + options: { prior?: JournalPrefix; policy?: OwnedReadPolicy; measure?: OwnedReadMeasure } = {}): OwnedRead { + const cwds = typeof owners === 'string' ? [owners] : owners, measure = options.measure ?? {}; + let fd: number | undefined, scanned: Scanned | undefined; + try { + if (!/^[A-Za-z0-9_-]{1,160}$/.test(sessionId) || !cwds.length || !cwds.every(cwd => path.isAbsolute(cwd)) || !ownedNativePath(file) || + path.basename(nativePathSpelling(file)) !== `${sessionId}.jsonl`) throw new OwnedReadError('identity'); + file = nativePathSpelling(file); + const project = path.dirname(file), projects = path.dirname(project), config = path.dirname(projects); + if (path.basename(projects) !== 'projects' || + [config, projects, project].some(dir => !fs.lstatSync(dir).isDirectory() || !sameNativePath(fs.realpathSync(dir), dir))) + throw new OwnedReadError('identity'); + fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + const before = fs.fstatSync(fd, { bigint: true }); + if (!before.isFile()) throw new OwnedReadError('identity'); + const size = Number(before.size); + if (options.prior && size < options.prior.size) throw new OwnedReadError('identity'); + scanned = scan(fd, size, sessionId, options.prior, options.policy); + measure.prefix = scanned.prefix; + const after = fs.fstatSync(fd, { bigint: true }), current = fs.lstatSync(file, { bigint: true }); + // Claude only appends: a different file or fewer bytes than at open is not this journal. + if (!current.isFile() || before.dev !== current.dev || before.ino !== current.ino || + after.size < before.size || current.size < before.size) throw new OwnedReadError('identity'); + const ownership = owned(scanned, cwds); + if (ownership.reason || ownership.status !== 'ready') + return { transcript: { status: ownership.status, calls: [], assistantMessages: [], ...(ownership.reason ? { reason: ownership.reason } : {}) }, + events: [], diagnostic: { ...(scanned.claudeVersion ? { claudeVersion: scanned.claudeVersion } : {}), rootShape: ownership.shape, + toolUseIds: scanned.toolUseIds, sha256: scanned.prefix.sha256, complete: scanned.complete } }; + const records: IndexedRecord[] = []; + let base = 0, claudeVersion: string | undefined; + for (const i of ownership.order!) { + const entry = scanned.entries[i]!; + if (!entry.events) continue; + claudeVersion = entry.version ?? claudeVersion; + for (const e of entry.events) e.order += base; + records.push({ offset: entry.offset, length: entry.length, uuid: entry.uuid, base, slots: entry.slots, + jsonResults: entry.jsonResults, events: entry.events, full: !options.policy }); + base += entry.slots; + } + // Measured only on request (tests, the benchmark): serializing the index costs as much as building it. + if (measure.indexBytes !== undefined) measure.indexBytes = records.reduce((sum, r) => sum + JSON.stringify(r.events).length + 64, 0); + if (options.policy) { + const reader = fd, limit = ownedRetainedLimit(); + let retained = 0; + options.policy.select(records, wanted => { + for (const r of wanted) { + if (r.full) continue; + retained += r.length; measure.retainedBytes = retained; + if (retained > limit) throw new OwnedReadError('oversized_invocation', retained); + const bytes = Buffer.alloc(r.length); + if (fs.readSync(reader, bytes, 0, r.length, r.offset) !== r.length || !isUtf8(bytes)) throw new OwnedReadError('rewritten'); + let value: any; + try { value = JSON.parse(bytes.toString('utf8')); } catch { throw new OwnedReadError('rewritten'); } + // Pass two must find the record pass one indexed at the same bytes. + if (!object(value) || value.uuid !== r.uuid || !object(value.message)) throw new OwnedReadError('rewritten'); + const full = recordEvents(value, true); + if (full.slots !== r.slots || full.events.length !== r.events.length) throw new OwnedReadError('rewritten'); + for (const e of full.events) e.order += r.base; + r.events = full.events; + r.full = true; + } + }); + } + return { transcript: { status: 'ready', calls: [], assistantMessages: [] }, events: records.flatMap(r => r.events), + ...(claudeVersion ? { claudeVersion } : {}) }; + } catch (error) { + if (error instanceof OwnedReadError && error.reason === 'too_large') measure.recordBytes = error.bytes; + // A journal Claude has not created yet is unflushed, not an identity failure. + const reason = error instanceof OwnedReadError ? error.reason : + (error as NodeJS.ErrnoException)?.code === 'ENOENT' ? undefined : 'identity'; + return { transcript: { status: 'error', calls: [], assistantMessages: [], ...(reason ? { reason } : {}), + error: `Owned native public transcript is unavailable (${reason ?? 'not created yet'})` }, events: [], + ...(scanned ? { diagnostic: { ...(scanned.claudeVersion ? { claudeVersion: scanned.claudeVersion } : {}), + rootShape: [], toolUseIds: scanned.toolUseIds, sha256: scanned.prefix.sha256, complete: scanned.complete } } : {}) }; + } finally { + if (fd !== undefined) fs.closeSync(fd); + } +} diff --git a/lib/claude-public-transcript.ts b/lib/claude-public-transcript.ts index aca0ae92f..e01aec458 100644 --- a/lib/claude-public-transcript.ts +++ b/lib/claude-public-transcript.ts @@ -1,7 +1,13 @@ /** Lossless, read-only question metadata from one isolated Claude fixture. */ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { createHash } from 'node:crypto'; +import { object, validTimestamp, nativeUuid, recordEvents, recordMeta, ownedCausalOrder, sameNativePath, + type NativePublicToolEvent, type OwnedTranscriptReason } from './claude-journal-records'; + +export { nativePathSpelling, ownedNativePath, sameNativePath, type NativePublicToolEvent, type ClaudeParentPublicEvent, + type OwnedTranscriptReason } from './claude-journal-records'; +export { readOwnedClaudePublicTranscript, OWNED_RECORD_MAX_BYTES, OWNED_RETAINED_MAX_BYTES, ownedRecordLimit, ownedRetainedLimit, + type OwnedTranscriptDiagnostic, type OwnedRead } from './claude-owned-journal'; export interface NativePlanQuestion { header: string; @@ -22,24 +28,6 @@ export interface NativePlanQuestionCall { answeredAt?: string; } -/** Optional public tool projection for the Autoplan delivery audit; never thinking. */ -export interface NativePublicToolEvent { - sessionId: string; - timestamp: string; - toolUseId: string; - kind: 'use' | 'result'; - name?: string; - /** Exact native message/request identity, used only for owned queued tools. */ - messageId?: string; - requestId?: string; - input?: Record; - content?: unknown; - file?: unknown; - isError?: boolean; - /** Owned reads only: Claude Code launched this Agent in the background (its result is a launch receipt). */ - async?: boolean; -} - export interface PlanCountTranscript { status: 'missing' | 'ready' | 'error'; calls: NativePlanQuestionCall[]; @@ -51,33 +39,13 @@ export interface PlanCountTranscript { reason?: OwnedTranscriptReason; } -/** Hook-only verified causal order (physical order for independent ready records). The existing fixture projection remains unchanged. */ -export type ClaudeParentPublicEvent = (NativePublicToolEvent | { - kind: 'message'; sessionId: string; timestamp: string; text: string; -} | { - kind: 'end_turn' | 'user_turn'; sessionId: string; timestamp: string; autoplan?: boolean; -} | { - /** Claude Code's completion notice for a background Agent (origin kind task-notification). */ - kind: 'task_notification'; sessionId: string; timestamp: string; notifiedToolUseId: string; -}) & { order: number; messageId?: string; requestId?: string }; - -interface OwnedSnapshot { - file: string; - text: string; - events: ClaudeParentPublicEvent[]; - /** Record types (never content) from the journal head to the first turn, set on refusal. */ - rootShape?: string[]; - /** The newest owned record's Claude Code version (the CLI writing now), when present. */ - claudeVersion?: string; -} - /** A rejected/refused call needs an actual later answer, not unrelated progress. */ export function unresolvedPlanQuestionCalls(calls: NativePlanQuestionCall[]): NativePlanQuestionCall[] { return calls.filter((call, index) => call.failed && !call.questions.every(q => calls.slice(index + 1).some(later => later.answered && later.answers?.[q.question]))); } -/** The most journal bytes the guard reads; a longer session reports `too_large`, never `identity` (#3050). */ +/** The most journal bytes a batch read takes (the guard's own reader bounds each record instead, CEO-2); over it is `too_large` (#3050). */ export const OWNED_TRANSCRIPT_MAX_BYTES = 32 * 1024 * 1024; /** Test seam: GSTACK_TRANSCRIPT_TEST_MAX_BYTES can only LOWER the cap, never raise it. */ export function transcriptReadLimit(): number { @@ -90,76 +58,6 @@ class TranscriptTooLarge extends Error { constructor(readonly bytes: number) { super(`transcript is ${mib(bytes)}, over the ${mib(transcriptReadLimit())} read limit`); } } const MAX_FILES = 64; -const object = (value: unknown): value is Record => - value !== null && typeof value === 'object' && !Array.isArray(value); -const validTimestamp = (value: unknown): value is string => - typeof value === 'string' && Number.isFinite(Date.parse(value)); -/** A record's own Claude Code version string, shape-checked; never other content. */ -const claudeVersionOf = (record: Record): string | undefined => - typeof record.version === 'string' && /^\d+\.\d+\.\d+[0-9A-Za-z.+-]{0,24}$/.test(record.version) ? record.version : undefined; - -/** Read one length-delimited protobuf field, rejecting malformed/ambiguous input. */ -function signatureField(bytes: Uint8Array | undefined, wanted: number): Uint8Array | undefined { - if (!bytes) return; - let cursor = 0; - let result: Uint8Array | undefined; - let seen = false; - const integer = () => { - let value = 0; - for (let shift = 0; shift < 70; shift += 7) { - if (cursor >= bytes.length) throw new Error('truncated signature'); - const byte = bytes[cursor++]!; - value += (byte & 127) * 2 ** shift; - if (!Number.isSafeInteger(value)) throw new Error('signature integer overflow'); - if (!(byte & 128)) return value; - } - throw new Error('overlong signature integer'); - }; - while (cursor < bytes.length) { - const key = integer(); - const field = Math.floor(key / 8); - if (field < 1 || field > 0x1fffffff) throw new Error('invalid signature field'); - if (field === wanted) { - if (seen) throw new Error('duplicate signature field'); - seen = true; - } - switch (key % 8) { - case 0: integer(); break; - case 1: cursor += 8; break; - case 2: { - const length = integer(); - if (length > bytes.length - cursor) throw new Error('truncated signature field'); - if (field === wanted) result = bytes.subarray(cursor, cursor + length); - cursor += length; - break; - } - case 5: cursor += 4; break; - default: throw new Error('unsupported signature wire type'); - } - if (cursor > bytes.length) throw new Error('truncated signature field'); - } - return result; -} - -/** - * Claude's public narration renderer classifies signature fields 2→1→8 as - * block_kind="narration": summaries of inter-tool prose, not private reasoning. - * Match that metadata only in this already-owned native transcript. This is - * classification, not cryptographic signature verification. Never read the - * thinking text of an untagged, unknown, malformed or legacy block. - */ -function publicNarrationText(block: Record): string | undefined { - if (block.type !== 'thinking' || typeof block.signature !== 'string' || - block.signature.length > 64 * 1024 || !/^[A-Za-z0-9+/]+={0,2}$/.test(block.signature)) return; - try { - const bytes = Buffer.from(block.signature, 'base64'); - const canonical = bytes.toString('base64'); - if (block.signature !== canonical && block.signature !== canonical.replace(/=+$/, '')) return; - const tag = signatureField(signatureField(signatureField(bytes, 2), 1), 8); - if (!tag || Buffer.from(tag).toString('utf8') !== 'narration') return; - return typeof block.thinking === 'string' && block.thinking.trim() ? block.thinking : undefined; - } catch { return; } -} function validQuestions(value: unknown): value is NativePlanQuestion[] { return Array.isArray(value) && value.length > 0 && value.every(q => @@ -168,161 +66,6 @@ function validQuestions(value: unknown): value is NativePlanQuestion[] { object(o) && typeof o.label === 'string' && o.label.trim())); } -/** - * Windows spells one native path C:\, c:\, C:/ or Git Bash /c/ (hook env, - * journal and tool input disagree). Fold separators, the MSYS drive prefix and - * drive-letter case only. No resolve: `.`, `..` and doubled separators stay - * distinct, so a normalize check after the fold still rejects them. The path - * module is a parameter so win32 call sites are unit-testable on any host. - */ -export function nativePathSpelling(value: string, p: typeof path = path): string { - if (p.sep !== '\\') return value; - return value.replace(/^\/([A-Za-z])(?=\/|$)/, '$1:').replaceAll('/', '\\') - .replace(/^[a-z](?=:)/, drive => drive.toUpperCase()); -} -/** Validation-then-compare: absolute, and its folded spelling is already normal. */ -export function ownedNativePath(value: unknown, p: typeof path = path): value is string { - if (typeof value !== 'string') return false; - const folded = nativePathSpelling(value, p); - return p.isAbsolute(folded) && p.normalize(folded) === folded; -} -export function sameNativePath(a: unknown, b: unknown, p: typeof path = path): boolean { - return typeof a === 'string' && typeof b === 'string' && nativePathSpelling(a, p) === nativePathSpelling(b, p); -} -function sameRealPath(a: unknown, b: unknown): boolean { - try { return typeof a === 'string' && typeof b === 'string' && sameNativePath(fs.realpathSync(a), fs.realpathSync(b)); } - catch { return false; } -} - -/** - * Why an owned journal supplied no owned lines (docs/autoplan-guard-troubleshooting.md). - * Positive identity conflicts are hard; `changing`, `identity` and `malformed` - * retry; only `unrecognized_shape:*` may degrade to an advisory. - */ -export type OwnedTranscriptReason = 'competing_root' | 'foreign_cwd' | 'sidechain' | 'agent' | 'cycle' | 'too_large' | - 'changing' | 'identity' | 'malformed' | `unrecognized_shape:${string}`; - -type OwnedLines = { lines: string[]; root?: string } | { reason: OwnedTranscriptReason; shape: string[] }; - -const nativeUuid = (value: unknown): value is string => typeof value === 'string' && - /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(value); -/** Record type names only, never content: `attachment:hook_success`, `system:compact_boundary`. */ -const recordShape = (r: Record): string => [r.type, r.subtype, object(r.attachment) ? r.attachment.type : undefined] - .filter(x => typeof x === 'string' && x).map(x => x.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 40)).join(':') || 'untyped'; -/** Claude writes each SessionStart hook result as a message-less attachment chained above the first user turn. */ -const sessionStartRecord = (r: Record): boolean => r.type === 'attachment' && r.message == null && - object(r.attachment) && r.attachment.hookEvent === 'SessionStart' && - typeof r.attachment.type === 'string' && /^hook_[a-z_]{1,48}$/.test(r.attachment.type); - -/** Native journal writes can flush children before parents. Owned snapshots - * use causal order; ordinary readers only recover membership, keeping physical order. */ -function ownedCausalLines(lines: string[], cwd: string, filename: string): OwnedLines { - const parsed = lines.flatMap((line, index) => { - if (!line.trim()) return []; - const record = JSON.parse(line); - return object(record) && filename === `${record.sessionId}.jsonl` ? [{ line, index, record }] : []; - }); - type Node = typeof parsed[number]; - const first = parsed.find(x => x.record.agentId == null && object(x.record.message) && - ['user', 'assistant'].includes(x.record.message.role)); - if (!first) return { lines: [] }; - const nodes = parsed.filter(({ record: r }) => r.agentId == null && r.isSidechain === false && - typeof r.cwd === 'string' && path.isAbsolute(r.cwd) && nativeUuid(r.uuid) && validTimestamp(r.timestamp)); - const byId = new Map(); - for (const node of nodes) { - if (byId.has(node.record.uuid)) return { reason: 'competing_root', shape: ['duplicate_uuid'] }; - byId.set(node.record.uuid, node); - } - const anyById = new Map>(); - for (const { record } of parsed) if (nativeUuid(record.uuid) && !anyById.has(record.uuid)) anyById.set(record.uuid, record); - const excluded = (r: Record): OwnedTranscriptReason => r.agentId != null ? 'agent' : - r.isSidechain !== false ? 'sidechain' : 'unrecognized_shape:record_metadata'; - const parent = (r: Record): string | undefined => nativeUuid(r.parentUuid) ? r.parentUuid : - r.parentUuid === null && r.type === 'system' && r.subtype === 'compact_boundary' && - r.message == null && nativeUuid(r.logicalParentUuid) ? r.logicalParentUuid : undefined; - if (byId.get(first.record.uuid) !== first) return { reason: excluded(first.record), shape: [recordShape(first.record)] }; - // Anchor through the first observed conversation node, never an unrelated - // later root. An unflushed ancestor supplies no ownership yet. - const chain: Node[] = [first], seen = new Set(chain); - const shape = () => chain.map(n => recordShape(n.record)).reverse().slice(0, 32); - let genesis = false; - for (let at = first; ;) { - const id = parent(at.record); - if (!id) break; - const next = byId.get(id); - if (!next) { - const other = anyById.get(id); - if (other) return { reason: excluded(other), shape: [recordShape(other), ...shape()] }; - // A resumed fork begins with a boundary whose logical parent stayed in the - // source session. It is a root only as this file's first record; mid-file - // it may be an unflushed ancestor, so it supplies no ownership yet. - if (at.record.parentUuid !== null || at !== parsed.find(x => nativeUuid(x.record.uuid))) return { lines: [] }; - genesis = true; - break; - } - if (seen.has(next)) return { reason: 'cycle', shape: shape() }; - chain.push(next); seen.add(next); - at = next; - } - const top = chain.toReversed(), root = top[0]!; - const turn = top.findIndex(n => object(n.record.message)); - const children = new Map(); - for (const node of nodes) { - const id = parent(node.record); - if (id) { const list = children.get(id) ?? []; list.push(node); children.set(id, list); } - } - // Uniqueness over every root candidate: a null-parent user, a SessionStart - // chain head carrying a conversation, and the resumed-genesis boundary. - const carriesTurn = (head: Node): boolean => { - for (const pending = [head]; pending.length;) for (const child of children.get(pending.pop()!.record.uuid) ?? []) { - if (object(child.record.message)) return true; - if (sessionStartRecord(child.record)) pending.push(child); - } - return false; - }; - const candidates = new Set([root, ...nodes.filter(({ record: r }) => r.parentUuid === null && - (object(r.message) ? r.message.role === 'user' : sessionStartRecord(r))).filter(n => - object(n.record.message) || carriesTurn(n))]); - if (candidates.size > 1) return { reason: 'competing_root', shape: shape() }; - for (const node of genesis ? [root] : top.slice(0, turn + 1)) if (!sameNativePath(node.record.cwd, cwd)) - return { reason: sameRealPath(node.record.cwd, cwd) ? 'unrecognized_shape:cwd_spelling' : 'foreign_cwd', shape: shape() }; - if (!genesis) { - const preamble = top.slice(0, turn).find(n => !sessionStartRecord(n.record)); - const odd = root.record.parentUuid !== null ? `root_parent:${recordShape(root.record)}` - : preamble ? `preamble:${recordShape(preamble.record)}` - : top[turn]!.record.message.role !== 'user' ? `first_turn:${recordShape(top[turn]!.record)}` : undefined; - if (odd) return { reason: `unrecognized_shape:${odd}`, shape: shape() }; - } - // Stable topological traversal preserves physical order whenever two ready - // records have no parent dependency. No timestamp provides ordering credit. - const ready: number[] = []; - const offer = (value: number) => { - let i = ready.length; ready.push(value); - while (i > 0) { const p = (i - 1) >> 1; if (ready[p]! <= value) break; - ready[i] = ready[p]!; i = p; } - ready[i] = value; - }; - const take = () => { - const result = ready[0]!, value = ready.pop()!; - if (ready.length) { let i = 0; - while (i * 2 + 1 < ready.length) { let c = i * 2 + 1; - if (c + 1 < ready.length && ready[c + 1]! < ready[c]!) c++; - if (ready[c]! >= value) break; ready[i] = ready[c]!; i = c; } - ready[i] = value; - } - return result; - }; - const indexed = new Map(nodes.map(x => [x.index, x])); - const ordered: string[] = []; - offer(root.index); - while (ready.length) { - const node = indexed.get(take())!; - ordered.push(node.line); - for (const child of children.get(node.record.uuid) ?? []) offer(child.index); - } - return { lines: ordered, root: root.record.uuid }; -} - /** * Count callers consume each answered (sessionId, toolUseId) once, regardless * of questions[].length. A batched tool call must never become N findings. @@ -333,7 +76,6 @@ export function readPlanCountTranscript(configDir: string, cwd: string, onPublicToolEvent?: (event: NativePublicToolEvent) => void, /** Optional exact parent journal, already validated by the owning native hook. */ ownedParentTranscript?: string, - ownedSnapshot?: OwnedSnapshot, ): PlanCountTranscript { const calls = new Map(); const assistantMessages: PlanCountTranscript['assistantMessages'] = []; @@ -344,52 +86,43 @@ export function readPlanCountTranscript(configDir: string, cwd: string, const projects = path.join(configDir, 'projects'); try { if (!fs.existsSync(projects)) return { status: 'missing', calls: [], assistantMessages: [] }; - const dirs = ownedSnapshot ? [{ name: path.basename(path.dirname(ownedSnapshot.file)) }] - : fs.readdirSync(projects, { withFileTypes: true }).filter(d => d.isDirectory()); + const dirs = fs.readdirSync(projects, { withFileTypes: true }).filter(d => d.isDirectory()); if (dirs.length > MAX_FILES) throw new Error('too many project directories'); for (const dir of dirs) { const project = path.join(projects, dir.name); - const entries = ownedSnapshot ? [{ name: path.basename(ownedSnapshot.file), isFile: () => true }] - : fs.readdirSync(project, { withFileTypes: true }); - for (const entry of entries) { + for (const entry of fs.readdirSync(project, { withFileTypes: true })) { if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue; if (++files > MAX_FILES) throw new Error('too many transcript files'); const file = path.join(project, entry.name); if (ownedParentTranscript !== undefined && file !== ownedParentTranscript) continue; - bytes += ownedSnapshot ? Buffer.byteLength(ownedSnapshot.text) : fs.statSync(file).size; + bytes += fs.statSync(file).size; if (bytes > transcriptReadLimit()) throw new TranscriptTooLarge(bytes); - const text = ownedSnapshot?.text ?? fs.readFileSync(file, 'utf8'); - let publicOrder = 0; + const text = fs.readFileSync(file, 'utf8'); // Native sessions retain their original journal after Bash changes cwd. // Admit that continuation only through UUID ancestry rooted in this // fixture's first parent user message; legacy records keep exact-cwd scoping. let originSeen = false; const ancestry = new Set(); - let causal: OwnedLines | undefined, causalMembership: Set | undefined, ownedLines: string[] | undefined; + let causalMembership: Set | undefined; // Claude appends JSONL during rendering; an unfinished record is not // evidence of a call or an answer until its newline has been written. const completeLines = text.slice(0, text.lastIndexOf('\n') + 1).split('\n'); const recoveredMember = (id: string) => { if (causalMembership === undefined) { // Invalid strict ancestry adds no recovery; legacy traversal continues. - try { causal ??= ownedCausalLines(completeLines, cwd, entry.name); } catch { causal = { lines: [] }; } - causalMembership = new Set('lines' in causal ? causal.lines.map(line => JSON.parse(line).uuid) : []); + try { + const records = completeLines.flatMap(line => { + if (!line.trim()) return []; + const record = JSON.parse(line); + return object(record) && entry.name === `${record.sessionId}.jsonl` ? [record] : []; + }); + const causal = ownedCausalOrder(records.map(recordMeta), cwd); + causalMembership = new Set('order' in causal ? causal.order.map(i => records[i]!.uuid) : []); + } catch { causalMembership = new Set(); } } return causalMembership.has(id); }; - if (ownedSnapshot) { - causal = ownedCausalLines(completeLines, cwd, entry.name); - if ('reason' in causal) { - ownedSnapshot.rootShape = causal.shape; - return { status: causal.reason === 'competing_root' || causal.reason === 'cycle' ? 'error' : 'missing', - calls: [], assistantMessages: [], reason: causal.reason }; - } - // The verified root seeds ownership; message-less preamble and - // boundary roots supply no events of their own. - ownedLines = causal.lines; - if (causal.root) ancestry.add(causal.root); - } - for (const line of ownedLines ?? completeLines) { + for (const line of completeLines) { if (!line.trim()) continue; const record = JSON.parse(line); if (!object(record) || typeof record.sessionId !== 'string' || @@ -403,8 +136,7 @@ export function readPlanCountTranscript(configDir: string, cwd: string, // A delayed metadata parent must not cut an already-rooted native // session at its first cwd change. Recover membership lazily; do // not discover a later root or reorder public uses and results. - (!ownedSnapshot && !sameNativePath(record.cwd, cwd) && ancestry.size > 0 && - recoveredMember(record.uuid))); + (!sameNativePath(record.cwd, cwd) && ancestry.size > 0 && recoveredMember(record.uuid))); if (!originSeen && object(record.message) && ['user', 'assistant'].includes(record.message.role)) { originSeen = true; // The first user prompt roots ownership itself, or through the @@ -423,58 +155,15 @@ export function readPlanCountTranscript(configDir: string, cwd: string, record.message == null && nativeUuid(record.logicalParentUuid) && ancestry.has(record.logicalParentUuid) && !ancestry.has(record.uuid); if (compactContinuation) ancestry.add(record.uuid); - if (ownedSnapshot && !ancestry.has(record.uuid)) continue; if ((!sameNativePath(record.cwd, cwd) && !continuation) || record.isSidechain !== false || !object(record.message)) continue; - if (ownedSnapshot) ownedSnapshot.claudeVersion = claudeVersionOf(record) ?? ownedSnapshot.claudeVersion; - const notified = ownedSnapshot && record.message.role === 'user' && record.origin?.kind === 'task-notification' && - typeof record.message.content === 'string' ? /([A-Za-z0-9_-]{1,160})<\/tool-use-id>/.exec(record.message.content)?.[1] : undefined; - if (notified) ownedSnapshot!.events.push({ kind: 'task_notification', sessionId: record.sessionId, timestamp: record.timestamp, - order: publicOrder++, notifiedToolUseId: notified }); - if (ownedSnapshot && record.message.role === 'user' && record.origin?.kind === 'human' && - record.isMeta !== true && nativeUuid(record.promptId) && typeof record.message.content === 'string') { - const autoplan = /^autoplan<\/command-message>\n\/autoplan<\/command-name>(?:\n[\s\S]*<\/command-args>)?$/.test(record.message.content); - if (record.promptSource === 'typed' || autoplan) ownedSnapshot.events.push({ kind: 'user_turn', - sessionId: record.sessionId, timestamp: record.timestamp, order: publicOrder++, autoplan }); - } if (!Array.isArray(record.message.content)) continue; matched = true; + for (const event of recordEvents(record, false).events) { + if (event.kind === 'message') assistantMessages.push({ sessionId: record.sessionId, text: event.text, timestamp: record.timestamp }); + else if (event.kind === 'use' || event.kind === 'result') onPublicToolEvent?.(event); + } for (const block of record.message.content) { - const order = publicOrder++; if (!object(block)) continue; - const ordered = (event: NativePublicToolEvent | { kind: 'message'; sessionId: string; timestamp: string; text: string }) => { - if (ownedSnapshot) ownedSnapshot.events.push({ ...event, order, - ...(typeof record.message.id === 'string' ? { messageId: record.message.id } : {}), - ...(typeof record.requestId === 'string' ? { requestId: record.requestId } : {}) }); - }; - if (onPublicToolEvent && validTimestamp(record.timestamp)) { - if (record.message.role === 'assistant' && block.type === 'tool_use' && - typeof block.id === 'string' && typeof block.name === 'string' && object(block.input)) { - const batch = typeof record.message.id === 'string' && /^msg_[A-Za-z0-9_-]{1,160}$/.test(record.message.id) && - typeof record.requestId === 'string' && /^req_[A-Za-z0-9_-]{1,160}$/.test(record.requestId) - ? { messageId: record.message.id, requestId: record.requestId } : {}; - const event: NativePublicToolEvent = { sessionId: record.sessionId, timestamp: record.timestamp, - toolUseId: block.id, kind: 'use', name: block.name, input: block.input, ...batch }; - onPublicToolEvent(event); - ordered(event); - } else if (record.message.role === 'user' && block.type === 'tool_result' && - typeof block.tool_use_id === 'string') { - const event: NativePublicToolEvent = { sessionId: record.sessionId, timestamp: record.timestamp, - toolUseId: block.tool_use_id, kind: 'result', content: block.content, - file: record.toolUseResult?.file, isError: block.is_error === true, - ...(ownedSnapshot && record.toolUseResult?.isAsync === true ? { async: true } : {}) }; - onPublicToolEvent(event); - ordered(event); - } - } - - if (record.message.role === 'assistant' && validTimestamp(record.timestamp)) { - const text = block.type === 'text' && typeof block.text === 'string' && block.text.trim() - ? block.text : publicNarrationText(block); - if (text) { - assistantMessages.push({ sessionId: record.sessionId, text, timestamp: record.timestamp }); - ordered({ kind: 'message', sessionId: record.sessionId, text, timestamp: record.timestamp }); - } - } if (record.message.role === 'assistant' && block.type === 'tool_use' && block.name === 'ExitPlanMode' && typeof block.id === 'string' && validTimestamp(record.timestamp)) { const key = `${record.sessionId}:${block.id}`; @@ -515,9 +204,6 @@ export function readPlanCountTranscript(configDir: string, cwd: string, } } } - if (ownedSnapshot && record.message.role === 'assistant' && record.message.stop_reason === 'end_turn') - ownedSnapshot.events.push({ kind: 'end_turn', sessionId: record.sessionId, timestamp: record.timestamp, - order: publicOrder++, ...(typeof record.message.id === 'string' ? { messageId: record.message.id } : {}) }); } } } @@ -531,82 +217,3 @@ export function readPlanCountTranscript(configDir: string, cwd: string, } } -/** What a refused owned read can report without content: inputs to the hook's guidance and advisory. */ -export interface OwnedTranscriptDiagnostic { - /** Claude Code version recorded in the journal's own records, when present. */ - claudeVersion?: string; - /** Record types from the journal head down to its first turn; never content. */ - rootShape: string[]; - /** Parent assistant tool_use ids present in complete records. */ - toolUseIds: string[]; - /** sha256 of the exact bytes read, so two reads can prove a stable snapshot. */ - sha256: string; - /** The journal ends at a record boundary (no partially written record). */ - complete: boolean; -} - -class OwnedReadError extends Error { - constructor(readonly reason: OwnedTranscriptReason) { super(reason); } -} - -function diagnose(bytes: Buffer, sessionId: string, rootShape: string[] = []): OwnedTranscriptDiagnostic { - const text = bytes.toString('utf8'), toolUseIds: string[] = []; - let claudeVersion: string | undefined; - for (const line of text.slice(0, text.lastIndexOf('\n') + 1).split('\n')) { - let record: unknown; - try { record = JSON.parse(line); } catch { continue; } - if (!object(record) || record.sessionId !== sessionId || record.agentId != null || record.isSidechain !== false) continue; - claudeVersion ??= claudeVersionOf(record); - if (object(record.message) && record.message.role === 'assistant' && Array.isArray(record.message.content)) - for (const block of record.message.content) - if (object(block) && block.type === 'tool_use' && typeof block.id === 'string') toolUseIds.push(block.id); - } - return { ...(claudeVersion ? { claudeVersion } : {}), rootShape, toolUseIds, - sha256: createHash('sha256').update(bytes).digest('hex'), complete: text.endsWith('\n') }; -} - -/** Read exactly the native hook's parent file; never scan another session. */ -export function readOwnedClaudePublicTranscript(file: string, cwd: string, sessionId: string): { - transcript: PlanCountTranscript; events: ClaudeParentPublicEvent[]; diagnostic?: OwnedTranscriptDiagnostic; claudeVersion?: string; -} { - let fd: number | undefined, bytes: Buffer | undefined; - try { - if (!/^[A-Za-z0-9_-]{1,160}$/.test(sessionId) || !path.isAbsolute(cwd) || !ownedNativePath(file) || - path.basename(nativePathSpelling(file)) !== `${sessionId}.jsonl`) throw new OwnedReadError('identity'); - file = nativePathSpelling(file); - const project = path.dirname(file), projects = path.dirname(project), config = path.dirname(projects); - if (path.basename(projects) !== 'projects' || - [config, projects, project].some(dir => !fs.lstatSync(dir).isDirectory() || !sameNativePath(fs.realpathSync(dir), dir))) - throw new OwnedReadError('identity'); - fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); - const before = fs.fstatSync(fd, { bigint: true }); - if (!before.isFile()) throw new OwnedReadError('identity'); - // A journal only grows, so an oversize one can never pass on retry (#3050). - if (before.size > BigInt(transcriptReadLimit())) throw new OwnedReadError('too_large'); - bytes = fs.readFileSync(fd); - const after = fs.fstatSync(fd, { bigint: true }), current = fs.lstatSync(file, { bigint: true }); - if (!current.isFile() || before.dev !== current.dev || before.ino !== current.ino) throw new OwnedReadError('identity'); - // Racing Claude's own append is transient: the hook retries within its deadline. - if (before.size !== after.size || before.mtimeNs !== after.mtimeNs || - before.size !== current.size || before.mtimeNs !== current.mtimeNs || - before.size !== BigInt(bytes.length)) throw new OwnedReadError('changing'); - const text = bytes.toString('utf8'); - if (!Buffer.from(text).equals(bytes)) throw new OwnedReadError('malformed'); - const snapshot: OwnedSnapshot = { file, text, events: [] }; - const transcript = readPlanCountTranscript(config, cwd, () => {}, file, snapshot); - if (snapshot.events.some(event => event.sessionId !== sessionId)) throw new OwnedReadError('identity'); - if (transcript.status === 'error') transcript.reason ??= 'malformed'; - if (transcript.status === 'ready') return { transcript, events: snapshot.events, - ...(snapshot.claudeVersion ? { claudeVersion: snapshot.claudeVersion } : {}) }; - return { transcript, events: [], diagnostic: diagnose(bytes, sessionId, snapshot.rootShape) }; - } catch (error) { - // A journal Claude has not created yet is unflushed, not an identity failure. - const reason = error instanceof OwnedReadError ? error.reason : - (error as NodeJS.ErrnoException)?.code === 'ENOENT' ? undefined : 'identity'; - return { transcript: { status: 'error', calls: [], assistantMessages: [], ...(reason ? { reason } : {}), - error: `Owned native public transcript is unavailable (${reason ?? 'not created yet'})` }, events: [], - ...(bytes ? { diagnostic: diagnose(bytes, sessionId) } : {}) }; - } finally { - if (fd !== undefined) fs.closeSync(fd); - } -} diff --git a/test/autoplan-bounded-journal.test.ts b/test/autoplan-bounded-journal.test.ts new file mode 100644 index 000000000..7b27fc91f --- /dev/null +++ b/test/autoplan-bounded-journal.test.ts @@ -0,0 +1,190 @@ +/** + * A2 / CEO-2: the guard's bounded, append-tolerant owned read. Through the + * shrink seam the journals here are larger than both the record bound and the + * retained-data bound, so no multi-MiB fixture is committed. Decisions are the + * hook's own; ENG-4 and ENG-5 refine what pass two reloads. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { guardFixture, section, denial, codeOf, ROOT, type GuardFixture } from './helpers/autoplan-guard-fixture'; +import { ownedRead } from '../autoplan/bin/phase-publication-hook.ts'; +import { DEDUP_REPLY } from '../autoplan/bin/guard-journal'; +import { readOwnedClaudePublicTranscript, ownedRetainedLimit, OWNED_RETAINED_MAX_BYTES, type OwnedReadMeasure } from '../lib/claude-owned-journal'; + +const RECORD = 32 * 1024, RETAINED = 64 * 1024; +const fixtures: GuardFixture[] = []; +afterEach(() => { for (const f of fixtures.splice(0)) f.cleanup(); }); +const make = (...args: Parameters) => { const f = guardFixture(...args); fixtures.push(f); return f; }; +async function bounded(run: () => T | Promise, record = RECORD, retained = RETAINED): Promise { + const prior = { record: process.env.GSTACK_TRANSCRIPT_TEST_MAX_BYTES, retained: process.env.GSTACK_TRANSCRIPT_TEST_RETAINED_BYTES }; + process.env.GSTACK_TRANSCRIPT_TEST_MAX_BYTES = String(record); process.env.GSTACK_TRANSCRIPT_TEST_RETAINED_BYTES = String(retained); + try { return await run(); } finally { + for (const [key, value] of [['GSTACK_TRANSCRIPT_TEST_MAX_BYTES', prior.record], ['GSTACK_TRANSCRIPT_TEST_RETAINED_BYTES', prior.retained]] as const) + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } +} +type Row = { type: string; message?: Record; extra?: Record }; +let padId = 0; +/** Ordinary session work the evaluation never reads in full: large Read and Bash results, assistant text. */ +const work = (count: number): Row[] => Array.from({ length: count }, (_, i): Row => { + const id = `toolu_work${++padId}`, body = `${'work '.repeat(4000)}${i}`; + return i % 3 === 0 ? { type: 'assistant', message: { role: 'assistant', id: `msg_work${padId}`, content: [{ type: 'text', text: body.slice(0, 2000) }] } } + : i % 3 === 1 ? { type: 'assistant', message: { role: 'assistant', id: `msg_work${padId}`, content: [{ type: 'tool_use', id, name: 'Read', input: { file_path: '/repo/src/app.ts' } }] } } + : { type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: `toolu_work${padId - 1}`, content: body }] }, + extra: { toolUseResult: { file: { filePath: '/repo/src/app.ts', content: body.slice(0, 8000), numLines: 1, startLine: 1, totalLines: 1 } } } }; +}); +/** An earlier typed conversation that ended its turn, before this /autoplan run. */ +const earlier = (count: number): Row[] => [{ type: 'user', message: { role: 'user', content: 'Look at the retry change first.' }, + extra: { origin: { kind: 'human' }, promptSource: 'typed', promptId: randomUUID() } }, ...work(count), + { type: 'assistant', message: { role: 'assistant', id: 'msg_earlier_end', content: [{ type: 'text', text: 'Done.' }], stop_reason: 'end_turn' } }]; +const initResult = (f: GuardFixture) => f.steps.findIndex(s => s.kind === 'result' && s.id === 'init'); +/** A long session: earlier work before /autoplan and phase work after init, both over the shrunk bounds. */ +function long(f: GuardFixture, head = 30, window = 30) { + f.journal(f.steps.length, at => at === -1 ? earlier(head) : at === initResult(f) ? work(window) : []); + const size = fs.statSync(f.transcript).size, largest = Math.max(...fs.readFileSync(f.transcript, 'utf8').split('\n').map(l => Buffer.byteLength(l))); + expect(size).toBeGreaterThan(RETAINED * 8); + expect(largest).toBeLessThan(RECORD); + return size; +} +const next = (f: GuardFixture) => { f.use('next', 'Read', { file_path: section('design-phase.md') }); return f.input('next', 'Read', { file_path: section('design-phase.md') }); }; + +describe('A2: the guard decides on a journal larger than its shrunk bounds', () => { + test('a published Phase 1 enters Phase 2; the same journal without the report is denied', async () => { + const allowed = make(); allowed.publish(); const input = next(allowed); long(allowed); + expect(await bounded(() => allowed.hook(input))).toEqual({}); + expect(allowed.log().at(-1)).toMatchObject({ decision: 'allow', disposition: 'allow', path: 'journal' }); + const unpublished = make(); const blocked = next(unpublished); long(unpublished); + const output = await bounded(() => unpublished.hook(blocked)); + expect(codeOf(output)).toBe('publication_missing'); + }); + + test('ENG-5: the index keeps no content, and pass two reloads only what the evaluation reads', async () => { + const f = make(); f.publish(); const input = next(f); const size = long(f, 60, 60); + const measure: OwnedReadMeasure = { indexBytes: 0 }; + const read = await bounded(() => ownedRead(f.transcript, [f.cwd], input, ROOT, undefined, measure)); + expect(read.transcript.status).toBe('ready'); + expect(measure.indexBytes!).toBeLessThan(size / 20); + expect(measure.retainedBytes!).toBeLessThan(RETAINED); + expect(JSON.stringify(read.events)).not.toContain('work work'); + // The guarded Read results it evaluates are reloaded in full. + expect(read.events.find(e => e.kind === 'result' && e.toolUseId === 'close')).toMatchObject({ file: { filePath: f.packet.closePacketPath } }); + }); + + test('ENG-5: retained data over its bound is a hard denial naming the oversized invocation and the fallback', async () => { + const f = make(); f.publish(); const input = next(f); long(f); + const output = await bounded(() => f.hook(input), RECORD, 4 * 1024); + expect(codeOf(output)).toBe('oversized_invocation'); + expect(denial(output)).toContain('retained-data bound'); + expect(denial(output)).toContain('/plan-ceo-review, then /plan-devex-review, then /plan-eng-review'); + expect(f.log().at(-1)).toMatchObject({ decision: 'deny', disposition: 'fallback', code: 'oversized_invocation' }); + await bounded(() => expect(ownedRetainedLimit()).toBe(OWNED_RETAINED_MAX_BYTES), RECORD, OWNED_RETAINED_MAX_BYTES * 4); + }); + + test('a run started by a typed request and the Skill tool (no /autoplan slash turn) binds its init', async () => { + const f = make('ceo', { opening: 'skill' }); f.publish(); const input = next(f); long(f); + expect(await bounded(() => f.hook(input))).toEqual({}); + const unpublished = make('ceo', { opening: 'skill' }); const blocked = next(unpublished); long(unpublished); + expect(codeOf(await bounded(() => unpublished.hook(blocked)))).toBe('publication_missing'); + }); + + test('ENG-4: a dedup reply in the second /autoplan run of a session cites a Read from the first run', async () => { + for (const forged of [false, true]) { + const f = make(); + const entry = f.steps.findIndex(s => s.kind === 'result' && s.id === 'entry'), full = f.steps[entry]!; + // Run 2's driver Read is Claude Code's dedup reply; run 1 returned the file in full. Without the + // close Read, only that driver Read can establish Phase 1 for the Phase 2 entry below. + f.steps[entry] = { ...full, content: DEDUP_REPLY, file: { filePath: section('ceo-phase.md') } }; + f.steps.splice(0, f.steps.length, ...f.steps.filter(s => s.id !== 'close')); + const input = next(f); + const init = f.steps[0]!, initReply = f.steps[1]!, entryUse = f.steps[entry - 1]!; + const file = forged ? { ...(full.file as object), content: `${(full.file as { content: string }).content}\nForged.` } : full.file; + const firstRun: Row[] = [ + { type: 'user', message: { role: 'user', content: 'autoplan\n/autoplan' }, + extra: { origin: { kind: 'human' }, promptId: randomUUID() } }, + { type: 'assistant', message: { role: 'assistant', id: 'msg_run1_init', content: [{ type: 'tool_use', id: 'run1-init', name: 'Bash', input: init.input }] } }, + { type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'run1-init', content: initReply.content, is_error: false }] } }, + { type: 'assistant', message: { role: 'assistant', id: 'msg_run1_entry', content: [{ type: 'tool_use', id: 'run1-entry', name: 'Read', input: entryUse.input }] } }, + { type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'run1-entry', content: 'Read complete.', is_error: false }] }, + extra: { toolUseResult: { file } } }, + ...work(30), + { type: 'assistant', message: { role: 'assistant', id: 'msg_run1_end', content: [{ type: 'text', text: 'Stopping here.' }], stop_reason: 'end_turn' } }, + ]; + f.journal(f.steps.length, at => at === -1 ? firstRun : at === initResult(f) ? work(30) : []); + // The witness is reloaded and checked: an exact earlier Read establishes Phase 1 (whose close is then + // required); a forged one establishes nothing. + expect({ forged, code: codeOf(await bounded(() => f.hook(input))) }).toEqual({ forged, code: forged ? 'phase_order' : 'close_required' }); + } + }); +}); + +describe('A2: append-tolerant reads with prefix identity', () => { + test('Claude Code appending throughout the read never fails it', async () => { + const f = make(); f.publish(); const input = next(f); long(f, 600, 600); + const appender = Bun.spawn([process.execPath, '-e', `const fs=require('fs');const fd=fs.openSync(${JSON.stringify(f.transcript)},'a'); + const pause=new Int32Array(new SharedArrayBuffer(4)); + for(;;){fs.writeSync(fd,JSON.stringify({type:'progress',sessionId:${JSON.stringify(f.sessionId)},data:'x'.repeat(200)})+'\\n');Atomics.wait(pause,0,0,0.02);}`], + { stdout: 'ignore', stderr: 'ignore' }); + try { + const grown = fs.statSync(f.transcript).size; + for (let i = 0; i < 50 && fs.statSync(f.transcript).size === grown; i++) await Bun.sleep(10); + expect(fs.statSync(f.transcript).size).toBeGreaterThan(grown); + expect(await f.hook(input)).toEqual({}); + } finally { appender.kill(); await appender.exited; } + }); + + /** A lagging journal makes the hook read twice in one invocation; `change` runs between the reads. */ + async function twoReads(change: (file: string) => void) { + const f = make(); f.use('lagging', 'Bash', { command: 'sleep 5' }); f.say('Still working.'); + const input = f.input('next', 'Read', { file_path: f.method, offset: 1, limit: 1 }); + f.journal(); + setTimeout(() => change(f.transcript), 20); + return { f, output: await f.hook(input) }; + } + + test('a prefix rewritten between two reads in one hook invocation is the unverified `rewritten` allow', async () => { + const { f, output } = await twoReads(file => { + const fd = fs.openSync(file, 'r+'); + try { const first = Buffer.alloc(1); fs.readSync(fd, first, 0, 1, 2); fs.writeSync(fd, Buffer.from(first[0] === 0x61 ? 'b' : 'a'), 0, 1, 2); } + finally { fs.closeSync(fd); } + }); + expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); + expect(codeOf(output)).toBe('rewritten'); + expect(f.log().at(-1)).toMatchObject({ decision: 'allow', disposition: 'unverified', code: 'rewritten' }); + }); + + test('a journal that shrinks between two reads is an identity denial', async () => { + const { output } = await twoReads(file => fs.truncateSync(file, Math.floor(fs.statSync(file).size / 2))); + expect(codeOf(output)).toBe('identity'); + expect(denial(output)).toContain('/plan-ceo-review, then /plan-devex-review, then /plan-eng-review'); + }); + + test('the reader itself: a changed prefix is rewritten, fewer bytes than the prefix is identity', () => { + const f = make(); f.journal(); + const measure: OwnedReadMeasure = {}; + expect(readOwnedClaudePublicTranscript(f.transcript, f.cwd, f.sessionId, { measure }).transcript.status).toBe('ready'); + const prefix = measure.prefix!; + fs.appendFileSync(f.transcript, JSON.stringify({ type: 'progress', sessionId: f.sessionId }) + '\n'); + expect(readOwnedClaudePublicTranscript(f.transcript, f.cwd, f.sessionId, { prior: prefix }).transcript.status).toBe('ready'); + expect(readOwnedClaudePublicTranscript(f.transcript, f.cwd, f.sessionId, { prior: { ...prefix, sha256: '0'.repeat(64) } }).transcript.reason) + .toBe('rewritten'); + expect(readOwnedClaudePublicTranscript(f.transcript, f.cwd, f.sessionId, { prior: { ...prefix, size: prefix.size * 4 } }).transcript.reason) + .toBe('identity'); + }); + + test('only a single record over the record bound is too_large, and an unfinished record is not read', async () => { + const f = make(); f.publish(); const input = next(f); long(f); + fs.appendFileSync(f.transcript, JSON.stringify({ type: 'progress', sessionId: f.sessionId, data: 'y'.repeat(RECORD) }) + '\n'); + const measure: OwnedReadMeasure = {}; + const read = await bounded(() => readOwnedClaudePublicTranscript(f.transcript, f.cwd, f.sessionId, { measure })); + expect(read.transcript.reason).toBe('too_large'); + expect(measure.recordBytes!).toBeGreaterThan(RECORD); + const output = await bounded(() => f.hook(input)); + expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); + expect(codeOf(output)).toBe('too_large'); + // A record still being written (no newline yet) is not evidence: the read ends at the last complete line. + const partial = make(); partial.publish(); const current = next(partial); long(partial); + fs.appendFileSync(partial.transcript, JSON.stringify({ type: 'assistant', sessionId: partial.sessionId }).slice(0, -2)); + expect(await bounded(() => partial.hook(current))).toEqual({}); + }); +}); diff --git a/test/autoplan-publication-guard.test.ts b/test/autoplan-publication-guard.test.ts index 2ec8d242f..7cea969b4 100644 --- a/test/autoplan-publication-guard.test.ts +++ b/test/autoplan-publication-guard.test.ts @@ -338,12 +338,17 @@ describe('Autoplan parent publication guard', () => { if (prior === undefined) delete process.env.GSTACK_TRANSCRIPT_TEST_MAX_BYTES; else process.env.GSTACK_TRANSCRIPT_TEST_MAX_BYTES = prior; } }; - test('#3050 + UC1: at the read limit the journal reads; one byte over is an unverified allow naming its size, code and anchor', async () => { + 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; - await withReadLimit(size, () => expect(readOwnedClaudePublicTranscript(f.input.transcript_path, f.input.cwd, f.sessionId).transcript.reason).not.toBe('too_large')); - const output: any = await withReadLimit(size - 1, async () => { + 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({}); + }); + 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)); }); diff --git a/test/autoplan-publication-hook.test.ts b/test/autoplan-publication-hook.test.ts index 01f3d3dea..3c46d7149 100644 --- a/test/autoplan-publication-hook.test.ts +++ b/test/autoplan-publication-hook.test.ts @@ -126,12 +126,12 @@ describe('Autoplan hook journal-root verdicts', () => { expect(output.hookSpecificOutput.permissionDecision).toBeUndefined(); expect(output.systemMessage).toContain('code unrecognized_shape:preamble:system:local_command'); }); - test('no advisory while a record is still being written: a transient denial naming what to wait for', async () => { + test('CEO-2: a record still being written is ignored, so the complete unrecognized shape is the advisory', async () => { const s = session('unknown', { partial: true }); const output = await s.hookRun(); - expect(output.hookSpecificOutput.permissionDecision).toBe('deny'); - expect(output.hookSpecificOutput.permissionDecisionReason).toContain('no missing-publication conclusion'); - expect(output.hookSpecificOutput.permissionDecisionReason).toContain('Wait for the current response to finish'); + expect(output.hookSpecificOutput.permissionDecision).toBeUndefined(); + expect(output.systemMessage).toContain('code unrecognized_shape:preamble:system:local_command'); + expect(output.systemMessage).not.toContain('Wait for the current response to finish'); }); for (const code of ['competing_root', 'foreign_cwd', 'sidechain', 'agent', 'cycle'] as const) diff --git a/test/fixtures/module-size-ratchet.json b/test/fixtures/module-size-ratchet.json index 0f5f29713..4cbf69b23 100644 --- a/test/fixtures/module-size-ratchet.json +++ b/test/fixtures/module-size-ratchet.json @@ -4,6 +4,9 @@ { "file": "autoplan/bin/phase-publication-hook.ts", "movedFrom": "(owner: the /autoplan publication guard; ENG-17 keeps it under the module limit)" }, { "file": "autoplan/bin/guard-reasons.ts", "movedFrom": "(new: guard reason codes, denial text and anchors, ENG-17)" }, { "file": "autoplan/bin/guard-log.ts", "movedFrom": "(new: the guard decision log, CEO-12)" }, + { "file": "autoplan/bin/guard-journal.ts", "movedFrom": "(new: what the guard reloads of its journal, CEO-2/ENG-4/ENG-5)" }, + { "file": "lib/claude-journal-records.ts", "movedFrom": "lib/claude-public-transcript.ts" }, + { "file": "lib/claude-owned-journal.ts", "movedFrom": "lib/claude-public-transcript.ts" }, { "file": "scripts/lib/eval-history.ts", "movedFrom": "(new: headroom, red ledger and run triage, kept out of scripts/eval-flake-rank.ts)" }, { "file": "scripts/lib/measure-bar.ts", "movedFrom": "(new: the ship-measure measurement bar, kept out of scripts/ship-measure.ts)" }, { "file": "scripts/ship-measure-sweep.ts", "movedFrom": "(new: the weekly off-ship qualification sweep, kept out of scripts/ship-measure.ts)" }, diff --git a/test/helpers/autoplan-guard-fixture.ts b/test/helpers/autoplan-guard-fixture.ts index b646b898d..a10965dd1 100644 --- a/test/helpers/autoplan-guard-fixture.ts +++ b/test/helpers/autoplan-guard-fixture.ts @@ -23,7 +23,8 @@ export interface Step { } /** A completed Phase `phase` (init, entry, close packet Read) with a reviewer snapshot for `phase`. */ -export function guardFixture(phase: Phase = 'ceo', opts: { methodologyLines?: number; version?: string } = {}) { +/** `opening: 'skill'` starts the run from a typed request and a Skill tool call instead of the /autoplan slash turn. */ +export function guardFixture(phase: Phase = 'ceo', opts: { methodologyLines?: number; version?: string; opening?: 'slash' | 'skill' } = {}) { const cwd = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-guard-replay-'))); 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'); @@ -72,8 +73,12 @@ export function guardFixture(phase: Phase = 'ceo', opts: { methodologyLines?: nu hook_event_name: 'PreToolUse', session_id: sessionId, cwd, transcript_path: transcript, tool_name: name, tool_use_id: id, tool_input: toolInput }); - /** Write the journal for steps[0, upto). */ - function journal(upto = steps.length) { + /** + * Write the journal for steps[0, upto), streaming rows to the file. `pad(at)` + * adds Claude-shaped records before the /autoplan turn (`at` -1) and after + * step `at`, for bound tests and the journal-read benchmark. + */ + function journal(upto = steps.length, pad?: (at: number) => Array<{ type: string; message?: Record_; extra?: Record_ }>) { fs.mkdirSync(path.dirname(transcript), { recursive: true }); let parent: string | null = null, clock = Date.parse('2026-10-07T09:04:00Z'); const record = (type: string, message: Record_, extra: Record_ = {}) => { @@ -83,9 +88,17 @@ export function guardFixture(phase: Phase = 'ceo', opts: { methodologyLines?: nu }; const assistant = (messageId: string | undefined, content: unknown[], extra: Record_ = {}) => record('assistant', { role: 'assistant', ...(messageId ? { id: messageId } : {}), content, ...extra }); - const rows = [record('user', { role: 'user', content: 'autoplan\n/autoplan' }, - { origin: { kind: 'human' }, promptId: randomUUID() })]; - for (const s of steps.slice(0, upto)) { + const fd = fs.openSync(transcript, 'w'); + const rows = { push: (...items: Record_[]) => { fs.writeSync(fd, items.map(r => JSON.stringify(r) + '\n').join('')); } }; + const padding = (at: number) => { for (const r of pad?.(at) ?? []) rows.push(record(r.type, r.message as Record_, r.extra)); }; + padding(-1); + if (opts.opening === 'skill') { + rows.push(record('user', { role: 'user', content: 'Run autoplan on the current plan.' }, { origin: { kind: 'human' }, promptSource: 'typed', promptId: randomUUID() })); + rows.push(assistant(nextMessage(), [{ type: 'tool_use', id: 'skill-autoplan', name: 'Skill', input: { skill: 'autoplan' } }])); + rows.push(record('user', { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'skill-autoplan', content: 'Launching skill: autoplan' }] })); + } else rows.push(record('user', { role: 'user', content: 'autoplan\n/autoplan' }, + { origin: { kind: 'human' }, promptId: randomUUID() })); + for (const [at, s] of steps.slice(0, upto).entries()) { if (s.kind === 'use') rows.push(assistant(s.messageId, [{ type: 'tool_use', id: s.id, name: s.name, input: s.input }])); else if (s.kind === 'message') rows.push(assistant(s.messageId, [{ type: 'text', text: s.text }])); else if (s.kind === 'end_turn') rows.push(assistant(s.messageId, [], { stop_reason: 'end_turn' })); @@ -97,8 +110,9 @@ export function guardFixture(phase: Phase = 'ceo', opts: { methodologyLines?: nu else if (s.kind === 'notification') rows.push(record('user', { role: 'user', content: `\n${s.id}\ncompleted\nReview done.\n` }, { origin: { kind: 'task-notification' }, promptSource: 'system', promptId: randomUUID() })); + padding(at); } - fs.writeFileSync(transcript, rows.map(r => JSON.stringify(r)).join('\n') + '\n'); + fs.closeSync(fd); } /** Run the hook in-process against the journal as written, with an owned state root for the guard log. */ diff --git a/test/helpers/touchfiles-data.ts b/test/helpers/touchfiles-data.ts index 88a6b5876..5819a8a5c 100644 --- a/test/helpers/touchfiles-data.ts +++ b/test/helpers/touchfiles-data.ts @@ -84,11 +84,11 @@ export const E2E_TOUCHFILES: Record = { 'hermetic-canary': [ 'test/helpers/hermetic-env.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-hermetic-canary.test.ts', 'lib/conductor-env-shim.ts'], 'hermetic-sentinel': [ 'test/helpers/hermetic-env.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-hermetic-canary.test.ts', 'lib/conductor-env-shim.ts'], // Real pinned-claude journals (SessionStart hook, /compact, --fork-session) through the /autoplan guard's reader. - 'autoplan-journal-drift': ['lib/claude-public-transcript.ts', 'lib/claude-bin.ts', '.github/docker/Dockerfile.ci', 'test/skill-e2e-autoplan-journal-drift.test.ts'], - // Latest published Claude Code (not the pin): Agent/Read hook payloads vs their journal records, with the guard's own comparison. + 'autoplan-journal-drift': ['lib/claude-public-transcript.ts', 'lib/claude-journal-records.ts', 'lib/claude-owned-journal.ts', 'lib/claude-bin.ts', '.github/docker/Dockerfile.ci', 'test/skill-e2e-autoplan-journal-drift.test.ts'], // Live PTY phase boundary on the pinned Claude Code through /autoplan's own hook block (ENG-18). - 'autoplan-guard-pty': ['autoplan/bin/phase-publication-hook.ts', 'autoplan/bin/phase-publication-hook', 'autoplan/bin/guard-reasons.ts', 'autoplan/bin/guard-log.ts', 'autoplan/SKILL.md', 'autoplan/SKILL.md.tmpl', 'scripts/resolvers/composition.ts', 'lib/claude-public-transcript.ts', 'lib/autoplan-phase-publication.ts', 'lib/state-root.ts', 'bin/gstack-autoplan-snapshot.ts', '.github/docker/Dockerfile.ci', 'test/helpers/autoplan-guard-pty.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-autoplan-guard-pty.test.ts'], - 'autoplan-schema-canary': ['autoplan/bin/phase-publication-hook.ts', 'lib/claude-public-transcript.ts', 'lib/claude-bin.ts', 'test/helpers/schema-canary.ts', 'test/skill-e2e-autoplan-schema-canary.test.ts'], + 'autoplan-guard-pty': ['autoplan/bin/phase-publication-hook.ts', 'autoplan/bin/phase-publication-hook', 'autoplan/bin/guard-reasons.ts', 'autoplan/bin/guard-log.ts', 'autoplan/SKILL.md', 'autoplan/SKILL.md.tmpl', 'scripts/resolvers/composition.ts', 'lib/claude-public-transcript.ts', 'lib/claude-journal-records.ts', 'lib/claude-owned-journal.ts', 'autoplan/bin/guard-journal.ts', 'lib/autoplan-phase-publication.ts', 'lib/state-root.ts', 'bin/gstack-autoplan-snapshot.ts', '.github/docker/Dockerfile.ci', 'test/helpers/autoplan-guard-pty.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-autoplan-guard-pty.test.ts'], + // Latest published Claude Code (not the pin): Agent/Read hook payloads vs their journal records, with the guard's own comparison. + 'autoplan-schema-canary': ['autoplan/bin/phase-publication-hook.ts', 'lib/claude-public-transcript.ts', 'lib/claude-journal-records.ts', 'lib/claude-owned-journal.ts', 'lib/claude-bin.ts', 'test/helpers/schema-canary.ts', 'test/skill-e2e-autoplan-schema-canary.test.ts'], // P4 first-run scaffold (activation lift) — the detection binary end-to-end // through the real runner, plus the script wiring that gates + maps it @@ -207,7 +207,7 @@ export const E2E_TOUCHFILES: Record = { "test/fixtures/eng-option-b-scope-al.json", 'test/fixtures/pty-companion-cli.ts', 'lib/fs-atomic.ts', 'test/helpers/plan-skill-questions.ts', 'test/fixtures/eng-auq-validation-error.json', 'test/fixtures/bash-directory-permission.json', 'test/fixtures/design-tasks-bash-permission.json', 'test/fixtures/read-permission.json', 'test/fixtures/ceo-split-e5-numbered-description-491.json', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/skill-census.ts', 'scripts/resolvers/tasks-section.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'bin/gstack-context-recovery'], - 'plan-eng-review-plan-mode': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'lib/claude-public-transcript.ts', + 'plan-eng-review-plan-mode': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'lib/claude-public-transcript.ts', 'lib/claude-journal-records.ts', 'lib/claude-owned-journal.ts', 'test/fixtures/auto-decide-recommendation-361c.json', @@ -231,7 +231,7 @@ export const E2E_TOUCHFILES: Record = { 'test/fixtures/pty-companion-cli.ts', 'test/helpers/plan-seed-submission.ts', 'test/fixtures/plan-seed-cli.ts', 'test/helpers/owned-claude-transcript.ts', 'lib/fs-atomic.ts', 'test/helpers/plan-skill-questions.ts', 'test/fixtures/eng-auq-validation-error.json', 'test/fixtures/bash-directory-permission.json', 'test/fixtures/design-tasks-bash-permission.json', 'test/fixtures/read-permission.json', 'test/fixtures/ceo-split-e5-numbered-description-491.json', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/skill-census.ts', 'scripts/resolvers/testing.ts', 'test/helpers/plan-mode-evidence.ts', 'lib/redact-engine.ts', 'lib/redact-patterns.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'bin/gstack-context-recovery'], // PTY plan-mode smoke (whole file); the SDK plan-edit case below owns plan-design-review-plan-mode. 'plan-design-review-plan-mode-smoke': [ - 'lib/claude-public-transcript.ts', + 'lib/claude-public-transcript.ts', 'lib/claude-journal-records.ts', 'lib/claude-owned-journal.ts', 'test/fixtures/auto-decide-recommendation-361c.json', @@ -321,7 +321,7 @@ export const E2E_TOUCHFILES: Record = { // written a never-ask preference, AUQ should still auto-decide rather than // surfacing the question. Touches the question-tuning + preference // infrastructure plus the resolvers that own the AUTO_DECIDE preamble. - 'auto-decide-preserved': ['bin/gstack-ceo-mode-handoff', 'test/fixtures/auto-decide-handoff-line-e354.json', 'test/fixtures/auto-decide-handoff-before-log-37182865432.json', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'lib/claude-public-transcript.ts', + 'auto-decide-preserved': ['bin/gstack-ceo-mode-handoff', 'test/fixtures/auto-decide-handoff-line-e354.json', 'test/fixtures/auto-decide-handoff-before-log-37182865432.json', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'lib/claude-public-transcript.ts', 'lib/claude-journal-records.ts', 'lib/claude-owned-journal.ts', 'test/fixtures/auto-decide-recommendation-361c.json', @@ -348,7 +348,7 @@ export const E2E_TOUCHFILES: Record = { 'scripts/resolvers/tasks-section.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'], 'plan-ceo-mode-routing': ['bin/gstack-ceo-mode-handoff', - 'lib/claude-public-transcript.ts', + 'lib/claude-public-transcript.ts', 'lib/claude-journal-records.ts', 'lib/claude-owned-journal.ts', @@ -365,7 +365,7 @@ export const E2E_TOUCHFILES: Record = { 'test/fixtures/pty-companion-cli.ts', 'lib/fs-atomic.ts', 'test/helpers/ceo-finding-fixture.ts', 'test/helpers/plan-skill-questions.ts', 'test/fixtures/eng-auq-validation-error.json', 'test/fixtures/bash-directory-permission.json', 'test/fixtures/design-tasks-bash-permission.json', 'test/fixtures/read-permission.json', 'test/fixtures/ceo-split-e5-numbered-description-491.json', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/skill-census.ts', 'test/helpers/owned-claude-transcript.ts', 'scripts/resolvers/tasks-section.ts', 'test/fixtures/ceo-expansion-pacing-77.json', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts'], 'plan-design-with-ui-scope': [ 'design/src/variants.ts','bin/gstack-state-root.sh', 'lib/state-root.ts', 'test/helpers/pty-screen.ts', - 'lib/claude-public-transcript.ts', + 'lib/claude-public-transcript.ts', 'lib/claude-journal-records.ts', 'lib/claude-owned-journal.ts', 'test/fixtures/autoplan-public-narration-ad.json', 'test/helpers/plan-count-fixture.ts', 'test/fixtures/ceo-prerequisite-n-call.json', 'test/fixtures/eng-prerequisite-77.json', @@ -431,7 +431,7 @@ export const E2E_TOUCHFILES: Record = { // "did agent fire ANY AUQ?" observer that exits early on first non-permission // numbered-option render. ~1-3 min typical wall time per test, ~$2-6 total. 'plan-eng-finding-floor': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'test/helpers/pty-screen.ts', - 'lib/claude-public-transcript.ts', 'test/fixtures/plan-create-prepublication-491.json', 'test/fixtures/plan-create-combined-permission-70b.json', 'test/fixtures/plan-floor-quote-70b.json', + 'lib/claude-public-transcript.ts', 'lib/claude-journal-records.ts', 'lib/claude-owned-journal.ts', 'test/fixtures/plan-create-prepublication-491.json', 'test/fixtures/plan-create-combined-permission-70b.json', 'test/fixtures/plan-floor-quote-70b.json', 'test/fixtures/plan-create-permission-361c.json', 'test/helpers/plan-floor-review.ts', @@ -447,7 +447,7 @@ export const E2E_TOUCHFILES: Record = { "scripts/resolvers/preamble/generate-preamble-bash.ts", 'test/fixtures/pty-companion-cli.ts', 'lib/fs-atomic.ts', 'test/helpers/plan-skill-questions.ts', 'test/fixtures/eng-auq-validation-error.json', 'test/fixtures/bash-directory-permission.json', 'test/fixtures/design-tasks-bash-permission.json', 'test/fixtures/read-permission.json', 'test/fixtures/ceo-split-e5-numbered-description-491.json', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/skill-census.ts', 'scripts/resolvers/testing.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'bin/gstack-context-recovery'], 'plan-ceo-finding-floor': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'test/helpers/pty-screen.ts', - 'lib/claude-public-transcript.ts', 'test/fixtures/plan-create-prepublication-491.json', 'test/fixtures/plan-create-combined-permission-70b.json', 'test/fixtures/plan-floor-quote-70b.json', + 'lib/claude-public-transcript.ts', 'lib/claude-journal-records.ts', 'lib/claude-owned-journal.ts', 'test/fixtures/plan-create-prepublication-491.json', 'test/fixtures/plan-create-combined-permission-70b.json', 'test/fixtures/plan-floor-quote-70b.json', 'test/fixtures/plan-create-permission-361c.json', 'test/helpers/plan-floor-review.ts', @@ -460,7 +460,7 @@ export const E2E_TOUCHFILES: Record = { 'test/fixtures/pty-companion-cli.ts', 'lib/fs-atomic.ts', 'test/helpers/plan-skill-questions.ts', 'test/fixtures/eng-auq-validation-error.json', 'test/fixtures/bash-directory-permission.json', 'test/fixtures/design-tasks-bash-permission.json', 'test/fixtures/read-permission.json', 'test/fixtures/ceo-split-e5-numbered-description-491.json', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/skill-census.ts', 'scripts/resolvers/tasks-section.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'bin/gstack-context-recovery'], 'plan-design-finding-floor': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'test/helpers/pty-screen.ts', - 'lib/claude-public-transcript.ts', 'test/fixtures/plan-create-prepublication-491.json', 'test/fixtures/plan-create-combined-permission-70b.json', 'test/fixtures/plan-floor-quote-70b.json', + 'lib/claude-public-transcript.ts', 'lib/claude-journal-records.ts', 'lib/claude-owned-journal.ts', 'test/fixtures/plan-create-prepublication-491.json', 'test/fixtures/plan-create-combined-permission-70b.json', 'test/fixtures/plan-floor-quote-70b.json', 'test/fixtures/plan-create-permission-361c.json', 'test/helpers/plan-floor-review.ts', @@ -478,7 +478,7 @@ export const E2E_TOUCHFILES: Record = { 'plan-devex-finding-floor': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'test/helpers/pty-screen.ts', 'test/fixtures/plan-floor-dx-custom-491.json', 'test/fixtures/plan-floor-dx-editor-hint.json', - 'lib/claude-public-transcript.ts', 'test/fixtures/plan-create-prepublication-491.json', 'test/fixtures/plan-create-combined-permission-70b.json', 'test/fixtures/plan-floor-quote-70b.json', 'test/fixtures/plan-floor-product-type-70b.json', + 'lib/claude-public-transcript.ts', 'lib/claude-journal-records.ts', 'lib/claude-owned-journal.ts', 'test/fixtures/plan-create-prepublication-491.json', 'test/fixtures/plan-create-combined-permission-70b.json', 'test/fixtures/plan-floor-quote-70b.json', 'test/fixtures/plan-floor-product-type-70b.json', 'test/fixtures/plan-create-permission-361c.json', 'test/helpers/plan-floor-review.ts', @@ -493,7 +493,7 @@ export const E2E_TOUCHFILES: Record = { // a model fires one AUQ then batches the rest into a "## Decisions to // confirm" plan write. runPlanSkillFloorCheck cannot detect that shape // (it exits on first AUQ); runPlanSkillCounting can. - 'plan-eng-multi-finding-batching': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'lib/claude-public-transcript.ts', 'test/fixtures/plan-create-prepublication-491.json', 'test/fixtures/plan-create-combined-permission-70b.json', + 'plan-eng-multi-finding-batching': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'lib/claude-public-transcript.ts', 'lib/claude-journal-records.ts', 'lib/claude-owned-journal.ts', 'test/fixtures/plan-create-prepublication-491.json', 'test/fixtures/plan-create-combined-permission-70b.json', 'test/fixtures/plan-create-permission-361c.json', @@ -531,7 +531,7 @@ export const E2E_TOUCHFILES: Record = { "scripts/resolvers/preamble/generate-preamble-bash.ts", 'test/fixtures/pty-companion-cli.ts', 'lib/fs-atomic.ts', 'test/helpers/owned-claude-transcript.ts', 'test/fixtures/webfetch-permission.json', 'test/helpers/plan-skill-questions.ts', 'test/fixtures/eng-auq-validation-error.json', 'test/fixtures/bash-directory-permission.json', 'test/fixtures/design-tasks-bash-permission.json', 'test/fixtures/read-permission.json', 'test/fixtures/ceo-split-e5-numbered-description-491.json', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/skill-census.ts', 'test/helpers/ceo-finding-fixture.ts', 'test/helpers/plan-review-decisions.ts', 'test/helpers/plan-review-cases.ts', 'test/helpers/llm-judge.ts', 'lib/eval-model.ts', 'test/skill-e2e-plan-decision-classification.test.ts', 'test/fixtures/plan-decision-classification.ts', 'scripts/resolvers/testing.ts', 'test/fixtures/eng-file-permission-repaint.json', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'bin/gstack-context-recovery'], - 'plan-ceo-split-overflow': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'lib/claude-public-transcript.ts', 'test/fixtures/plan-create-prepublication-491.json', 'test/fixtures/plan-create-combined-permission-70b.json', + 'plan-ceo-split-overflow': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'lib/claude-public-transcript.ts', 'lib/claude-journal-records.ts', 'lib/claude-owned-journal.ts', 'test/fixtures/plan-create-prepublication-491.json', 'test/fixtures/plan-create-combined-permission-70b.json', 'test/fixtures/plan-create-permission-361c.json', @@ -881,7 +881,7 @@ export const E2E_TOUCHFILES: Record = { 'test/helpers/outside-voice-evidence.ts', 'test/fixtures/outside-async-task-m-events.json', 'test/fixtures/autoplan-amend-input-77.json', - 'test/fixtures/autoplan-phase-handoff-6714.json','scripts/resolvers/learnings.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'codex/**', 'bin/gstack-codex-probe', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/design.ts', 'test/skill-e2e-autoplan-dual-voice.test.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', + 'test/fixtures/autoplan-phase-handoff-6714.json','scripts/resolvers/learnings.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'lib/claude-journal-records.ts', 'lib/claude-owned-journal.ts', 'codex/**', 'bin/gstack-codex-probe', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/design.ts', 'test/skill-e2e-autoplan-dual-voice.test.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/design-doc-discovery.ts', 'bin/gstack-design-doc-find', 'plan-ceo-review/**', 'plan-eng-review/**', 'plan-design-review/**', 'plan-devex-review/**', 'scripts/resolvers/testing.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/tasks-section.ts', 'test/helpers/autoplan-phase-observer.ts', 'scripts/resolve-codex-generation-model.ts', 'scripts/resolvers/constants.ts'], // Multi-provider benchmark adapters — live API smoke against real claude/codex/gemini CLIs