From cd12cb207a9fe20e4cbfb7b6d37e5ac4df4916fe Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 7 Oct 2026 20:59:41 +0000 Subject: [PATCH] fix(autoplan): bounded, append-tolerant owned-journal read (A2) The publication guard no longer reads the whole parent journal or refuses it at 32 MiB. lib/claude-owned-journal.ts streams the bytes present at open to the last complete line (pass one), keeps a content-free index per record and decides ownership, ancestry and causal order on it with the same checks and reason codes as before. Pass two reloads in full only what the evaluation reads (autoplan/bin/guard-journal.ts): every literal init and its result, the current call, and inside the invocation window (from the latest human turn at or before the bound chain's first init) guarded and close-packet Read results, structured Bash results, error results, assistant text after the first close Read and active-plan mutations, plus the earlier Read a dedup reply cites even before the window (ENG-4). - lib/claude-journal-records.ts: per-record event extraction, record metadata and the causal-order checks, shared by readPlanCountTranscript and the bounded reader. The batch reader keeps its whole-file read and cap. - Appends after open never fail a read; same device and inode and no shrink are required (identity). A second read in one hook invocation must hash bytes [0, first size) the same, or it is `rewritten` (UC1 unverified); the unrecognized-shape double read uses that prefix hash. `changing` is removed (REMOVED_CODES; its anchor stays). - OWNED_RECORD_MAX_BYTES (32 MiB): only one record over it is too_large. OWNED_RETAINED_MAX_BYTES (32 MiB): over it is the new oversized_invocation fallback denial. GSTACK_TRANSCRIPT_TEST_MAX_BYTES and GSTACK_TRANSCRIPT_TEST_RETAINED_BYTES can only lower them. - ENG-5: evaluation groups uses and results by tool id once (no per-tool rescans). CEO-25 / ENG-5 spike (120 MiB synthetic, 31,242 records, 4 vCPU Linux, Bun 1.4.2): a full streaming rescan (parse every line, small index, sha256) took 254-326 ms at 147-161 MiB peak RSS; a pass-one index cache under the state root (load cached index, verify a 64 KiB tail hash, parse the tail) took 26-29 ms at 47 MiB. The rescan meets the 1 s budget, so the cache is not adopted: it would add trusted persistent state and invalidation for no budget need. A backward tail read cannot prove competing_root or cycle, so it was not a candidate. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- autoplan/bin/guard-journal.ts | 115 ++++++ autoplan/bin/guard-reasons.ts | 4 +- autoplan/bin/phase-publication-hook.ts | 112 +++--- docs/autoplan-guard-troubleshooting.md | 28 +- lib/claude-journal-records.ts | 374 ++++++++++++++++++++ lib/claude-owned-journal.ts | 296 ++++++++++++++++ lib/claude-public-transcript.ts | 449 ++---------------------- test/autoplan-bounded-journal.test.ts | 190 ++++++++++ test/autoplan-publication-guard.test.ts | 11 +- test/autoplan-publication-hook.test.ts | 8 +- test/fixtures/module-size-ratchet.json | 3 + test/helpers/autoplan-guard-fixture.ts | 28 +- test/helpers/touchfiles-data.ts | 32 +- 13 files changed, 1140 insertions(+), 510 deletions(-) create mode 100644 autoplan/bin/guard-journal.ts create mode 100644 lib/claude-journal-records.ts create mode 100644 lib/claude-owned-journal.ts create mode 100644 test/autoplan-bounded-journal.test.ts 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