From bfd27c1b8f9915d95cd6207a82fce314285688c7 Mon Sep 17 00:00:00 2001 From: garrytan Date: Fri, 2 Oct 2026 15:06:08 +0000 Subject: [PATCH] fix(autoplan): publication guard accepts real Claude Code journals (#3007, #3009, #2968, #2977, #2986, #3006) The guard required a session journal to start with a user message, but Claude Code starts it with SessionStart hook attachments (and a forked compacted session starts with a compact_boundary), so every phase was denied for anyone with a SessionStart hook. Windows path spellings (C:/, /c/, drive case) were rejected too. - Reader accepts the captured startup, compact, clear and fork shapes (claude 2.1.284 fixture), computes root uniqueness over every candidate, and returns typed reason codes instead of one string. - Hard denials (competing root, foreign cwd, sidechain, agent, cycle, malformed, identity) say what happened and how to run the reviews by hand; unflushed journals still retry. - A well-formed, stable journal whose root shape is unrecognized now warns ('phase publication was NOT verified') and continues, without overriding Claude Code's own permission check; no bypass switch. - Windows paths fold before validation, so '..' is still rejected. - Periodic drift canary runs the reader on fresh journals from the pinned claude. Co-authored-by: Charles M Berg <77517285+CharlesBerg85@users.noreply.github.com> Co-authored-by: laurenbdaniels-coder <264343335+laurenbdaniels-coder@users.noreply.github.com> Co-authored-by: Jordan Mayer <176445995+maydaycyber@users.noreply.github.com> Co-authored-by: elmesery-dev --- autoplan/bin/phase-publication-hook.ts | 196 +++++++++-- docs/autoplan-guard-troubleshooting.md | 109 ++++++ lib/claude-public-transcript.ts | 273 +++++++++++---- test/autoplan-publication-guard.test.ts | 320 +++++++++++++++++- test/autoplan-publication-hook.test.ts | 129 +++++++ .../claude-native-journal-roots-2.1.284.json | 219 ++++++++++++ test/helpers/touchfiles-data.ts | 4 + test/plan-count-transcript.test.ts | 203 ++++++++++- test/skill-e2e-autoplan-journal-drift.test.ts | 87 +++++ ...dows-free-selection-autoplan-guard.test.ts | 12 + 10 files changed, 1443 insertions(+), 109 deletions(-) create mode 100644 docs/autoplan-guard-troubleshooting.md create mode 100644 test/fixtures/claude-native-journal-roots-2.1.284.json create mode 100644 test/skill-e2e-autoplan-journal-drift.test.ts create mode 100644 test/windows-free-selection-autoplan-guard.test.ts diff --git a/autoplan/bin/phase-publication-hook.ts b/autoplan/bin/phase-publication-hook.ts index aa3ea4dbb..c8b3dbd80 100644 --- a/autoplan/bin/phase-publication-hook.ts +++ b/autoplan/bin/phase-publication-hook.ts @@ -2,12 +2,15 @@ /** A native parent publication barrier at Autoplan's exact Read boundaries. */ import * as fs from 'node:fs'; import * as path from 'node:path'; +import * as os from 'node:os'; import { createHash } from 'node:crypto'; 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, type ClaudeParentPublicEvent } from '../../lib/claude-public-transcript'; +import { readOwnedClaudePublicTranscript, nativePathSpelling, ownedNativePath, sameNativePath, + type ClaudeParentPublicEvent, type OwnedTranscriptReason } from '../../lib/claude-public-transcript'; +import { resolveStateRoot } from '../../lib/state-root'; const PHASES = ['ceo', 'design', 'dx', 'eng', 'tasks'] as const; type Phase = typeof PHASES[number]; @@ -20,7 +23,20 @@ const number: Record = { ceo: 1, design: 2, dx: 2.5, eng: 3, task 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; const hash = (x: string | Buffer) => createHash('sha256').update(x).digest('hex'); -const ownPath = (value: unknown): value is string => typeof value === 'string' && path.isAbsolute(value) && path.normalize(value) === value; +/** Fold the native spelling first, then require it to be absolute and already normal (`..` stays rejected). */ +const ownPath = (value: unknown): value is string => ownedNativePath(value); +/** One native spelling on both sides of every path compare. */ +const samePath = (a: unknown, b: unknown): boolean => sameNativePath(a, b); +/** Claude's Read expands a leading ~ before the tool runs; resolve the same file. */ +const requestedPath = (cwd: string, file: string) => + nativePathSpelling(path.resolve(cwd, file.replace(/^~(?=[\\/]|$)/, () => os.homedir()))); +/** + * Claude records the model's file_path verbatim in the journal but hands + * PreToolUse its resolved form (C:\x/y becomes C:\x\y). Compare that one field + * as the file it names; every other field stays exact. + */ +const nativeToolInput = (input: unknown, cwd: string): unknown => + object(input) && typeof input.file_path === 'string' ? { ...input, file_path: requestedPath(cwd, input.file_path) } : input; class BoundaryError extends Error {} function fail(reason: string): never { throw new BoundaryError(reason); } export interface PublicationHookInput { @@ -32,7 +48,7 @@ interface Invocation { activePlan: string; restorePath: string; originalSha256: /** Stable, bounded regular bytes; links never establish an artifact identity. */ function read(file: string, immutable = false): string { - if (!ownPath(file) || fs.realpathSync(file) !== file) fail('Artifact path is unavailable or aliased.'); + if (!ownPath(file) || !samePath(fs.realpathSync(file), file)) fail('Artifact path is unavailable or aliased.'); const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); try { const before = fs.fstatSync(fd, { bigint: true }); @@ -50,7 +66,7 @@ function read(file: string, immutable = false): string { function phaseName(file: unknown, cwd: string): Phase | undefined { if (typeof file !== 'string') return; - const requested = path.resolve(cwd, file); + const requested = requestedPath(cwd, file); const name = /^((?:ceo|design|dx|eng)-phase|tasks-aggregator)\.md$/.exec(path.basename(requested)); if (!name || path.basename(path.dirname(requested)) !== 'sections' || path.basename(path.dirname(path.dirname(requested))) !== 'autoplan') return; @@ -60,17 +76,38 @@ function phaseName(file: unknown, cwd: string): Phase | undefined { function driver(file: unknown, cwd: string, root: string): Phase | undefined { const phase = phaseName(file, cwd); if (!phase) return; - const requested = path.resolve(cwd, file as string); - const canonical = path.join(root, 'autoplan', 'sections', path.basename(requested)); - if (fs.realpathSync(requested) !== canonical || fs.realpathSync(canonical) !== canonical) - fail('Autoplan phase entry belongs to a different or unavailable installation. Restore this invocation’s hook installation before retrying.'); - return phase; + const requested = requestedPath(cwd, file as string), base = path.basename(requested); + const canonical = path.join(root, 'autoplan', 'sections', base), actual = fs.realpathSync(requested); + if (samePath(fs.realpathSync(canonical), canonical)) { + if (samePath(actual, canonical)) return phase; + // setup serves the gbrain :user render (#2569) from exactly one render root, + // whose section links point back into it. That render is this installation + // only at its exact path and only with the install's bytes after that rewrite. + const render = userRenderRoot(); + if (render && samePath(actual, path.join(render, 'autoplan', 'sections', base)) && + read(actual) === renderSectionBase(read(canonical), render)) return phase; + } + return fail('Autoplan phase entry belongs to a different or unavailable installation. Restore this invocation’s hook installation before retrying.'); +} + +/** setup's `${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}`, realpath'd. */ +function userRenderRoot(): string | undefined { + const configured = process.env.GSTACK_USER_RENDER_DIR || path.join(resolveStateRoot(), 'render', 'claude'); + try { return fs.realpathSync(path.resolve(nativePathSpelling(configured))); } catch { return; } +} + +/** scripts/gen-skill-docs.ts rewriteSectionBase, which writes that render. */ +function renderSectionBase(content: string, linkRoot: string): string { + return content.replace( + /~\/\.claude\/skills\/gstack\/([^\s)`"'*]+\/sections\/)/g, + (_m, p1: string) => `${linkRoot}/${p1}`, + ); } interface Consumer { phase: Phase; content?: string; kind: 'Read' | 'Agent' } function artifactName(file: unknown, cwd: string, includeClose = false): Phase | undefined { if (typeof file !== 'string') return; - const requested = path.resolve(cwd, file), base = path.basename(requested); + const requested = requestedPath(cwd, file), base = path.basename(requested); const match = /^autoplan-(ceo|design|dx|eng)-.+$/.exec(path.basename(path.dirname(requested))); if (!match || !['methodology.md', 'methodology.json', 'native-prompt.md', 'snapshot.json', 'source-implementation.md', `${match[1]}-implementation.md`, ...(includeClose ? ['close-packet.md'] : [])].includes(base)) return; @@ -116,10 +153,10 @@ function consumption(use: { name?: string; input?: Record }, cw init: Invocation, includeClose = false): Consumer | undefined { if (use.name === 'Read') { const direct = driver(use.input?.file_path, cwd, root); - if (direct) return { phase: direct, kind: 'Read', content: read(fs.realpathSync(path.resolve(cwd, use.input!.file_path as string))) }; + if (direct) return { phase: direct, kind: 'Read', content: read(fs.realpathSync(requestedPath(cwd, use.input!.file_path as string))) }; const phase = artifactName(use.input?.file_path, cwd, includeClose); if (!phase) return; - const file = path.resolve(cwd, use.input!.file_path as string), base = path.basename(file); + const file = requestedPath(cwd, use.input!.file_path as string), base = path.basename(file); if (base === 'methodology.md' || base === 'methodology.json') { const m = methodology(path.join(path.dirname(file), 'methodology.md'), phase, init); return { phase, kind: 'Read', content: base === 'methodology.md' ? m.content : m.manifestBytes }; @@ -180,9 +217,10 @@ function initArguments(command: unknown, root: string): string[] | undefined { const match = new RegExp(String.raw`^\s*(?:cd\s+${literal}\s*(?:\n|&&)\s*)?(?:bun|${literal}/bun)\s+(${literal})\s+init\s+(${literal})\s+(${literal})\s+(${literal})\s*$`).exec(normalized); if (!match) return; const args = match.slice(1).map(x => /^["']/.test(x!) ? x!.slice(1, -1) : x!) - // Git Bash accepts forward slashes; retain all other canonical-path checks. - .map(x => process.platform === 'win32' ? x.replaceAll('/', '\\') : x); - if (!args.every(ownPath) || fs.realpathSync(args[0]!) !== path.join(root, 'bin', 'gstack-autoplan-snapshot.ts')) return; + // Git Bash accepts forward slashes and hands native programs /c/x as C:/x. + // Fold that spelling only; every canonical-path check still applies. + .map(x => nativePathSpelling(x, path)); + if (!args.every(ownPath) || !samePath(fs.realpathSync(args[0]!), path.join(root, 'bin', 'gstack-autoplan-snapshot.ts'))) return; return args.slice(1); } @@ -198,8 +236,8 @@ function invocation(events: Event[], root: string): Invocation { const text = textResult(results[0]!); if (text === undefined) fail('Autoplan initialization did not succeed. Complete the existing init step first.'); const result = JSON.parse(text); - if (!object(result) || result.sourcePlan !== fs.realpathSync(args[0]!) || result.activePlan !== args[1] || - result.restorePath !== args[2] || typeof result.reused !== 'boolean' || !positive(result.originalBytes) || + if (!object(result) || !samePath(result.sourcePlan, fs.realpathSync(args[0]!)) || !samePath(result.activePlan, args[1]) || + !samePath(result.restorePath, args[2]) || typeof result.reused !== 'boolean' || !positive(result.originalBytes) || !/^[a-f0-9]{64}$/.test(result.originalSha256)) fail('Autoplan initialization does not match the successful native request.'); if (result.reused && bound && bound.activePlan === result.activePlan && bound.restorePath === result.restorePath) continue; chosen = result; @@ -210,7 +248,9 @@ function invocation(events: Event[], root: string): Invocation { const restore = read(bound.restorePath, true), active = read(bound.activePlan); const reference = JSON.stringify(bound.restorePath).replace(/--/g, '\\u002d\\u002d'); if (hash(restore) !== bound.originalSha256 || Buffer.byteLength(restore) !== chosen.originalBytes || - !active.startsWith(`\n`) || bound.activePlan === bound.restorePath) + // Claude's Edit rewrites a CRLF plan's line endings, including init's LF header line. + !/^\r?\n/.test(active.slice(``.length)) || + !active.startsWith(``) || bound.activePlan === bound.restorePath) fail('Autoplan initialization artifacts do not match this parent invocation.'); return bound; } @@ -221,11 +261,11 @@ export function autoplanReadRange(use: Use, result: Event, content: string, hist 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.' || - !isDeepStrictEqual(result.file, { filePath: use.input?.file_path })) break; + !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. const prior = history.filter((e): e is Use => e.kind === 'use' && e.name === 'Read' && - e.sessionId === use.sessionId && e.order < use.order && e.input?.file_path === use.input?.file_path).at(-1); + e.sessionId === use.sessionId && e.order < use.order && samePath(e.input?.file_path, use.input?.file_path)).at(-1); if (!prior || (prior.input?.offset ?? 1) !== (use.input?.offset ?? 1) || prior.input?.limit !== use.input?.limit) return; const sameRecord = (a: Event, b: Event) => isDeepStrictEqual({ ...a, order: 0 }, { ...b, order: 0 }); const uses = history.filter((e): e is Use => e.kind === 'use' && e.sessionId === prior.sessionId && e.toolUseId === prior.toolUseId); @@ -237,10 +277,12 @@ export function autoplanReadRange(use: Use, result: Event, content: string, hist use = uses[0]!; result = replies[0]!; } const f = result.file, lines = content.split('\n'); - if (f.filePath !== use.input?.file_path || typeof f.content !== 'string' || !positive(f.startLine) || !positive(f.numLines) || + if (!samePath(f.filePath, use.input?.file_path) || typeof f.content !== 'string' || !positive(f.startLine) || !positive(f.numLines) || f.totalLines !== lines.length || f.startLine + f.numLines - 1 > lines.length || (use.input?.offset ?? 1) !== f.startLine || (use.input?.limit !== undefined && (!positive(use.input.limit) || f.numLines > use.input.limit)) || - f.content !== lines.slice(f.startLine - 1, f.startLine - 1 + f.numLines).join('\n')) return; + // Claude's Read reports a CRLF line without its CR; nothing else may differ. + f.content.replace(/\r(?=\n|$)/g, '') !== + lines.slice(f.startLine - 1, f.startLine - 1 + f.numLines).join('\n').replace(/\r(?=\n|$)/g, '')) return; return { start: f.startLine, end: f.startLine + f.numLines - 1 }; } @@ -292,7 +334,7 @@ function disarmed(events: Event[], root: string): boolean { /** Only exact reversible successful Edits can establish a report-only change. */ function verifyCloseEdits(events: Event[], closeOrder: number, init: Invocation): void { const edits = events.filter((e): e is Use => e.kind === 'use' && e.order > closeOrder && - ['Write', 'Edit'].includes(e.name ?? '') && e.input?.file_path === init.activePlan); + ['Write', 'Edit'].includes(e.name ?? '') && samePath(e.input?.file_path, init.activePlan)); if (!edits.length) return; const current = read(init.activePlan); let prior = current; @@ -325,17 +367,18 @@ function verifyCloseEdits(events: Event[], closeOrder: number, init: Invocation) } function requirePublication(phase: Phase, entryOrder: number, entered: Event[], init: Invocation, current: boolean, checkpoint?: string): void { + const native = (e: Event) => e.kind === 'use' && typeof e.input?.file_path === 'string' ? nativePathSpelling(e.input.file_path) : undefined; const closeReads = entered.filter((e): e is Use => e.kind === 'use' && e.name === 'Read' && e.order >= entryOrder && - ownPath(e.input?.file_path) && path.basename(e.input.file_path) === 'close-packet.md' && - path.dirname(path.dirname(e.input.file_path)) === path.dirname(init.restorePath) && - path.basename(path.dirname(e.input.file_path)).startsWith(`autoplan-${phase}-`)); + ownPath(native(e)) && path.basename(native(e)!) === 'close-packet.md' && + samePath(path.dirname(path.dirname(native(e)!)), path.dirname(init.restorePath)) && + path.basename(path.dirname(native(e)!)).startsWith(`autoplan-${phase}-`)); if (!closeReads.length) fail(`Finish the existing Phase ${number[phase]} close procedure and Read its complete current close packet before entering the next phase.`); - const latestPath = closeReads.at(-1)!.input!.file_path as string; + const latestPath = native(closeReads.at(-1)!)!; const content = closePacket(latestPath, phase, init, current), covered = new Set(); if (checkpoint && JSON.parse(/^Binding: (.+)$/m.exec(content)![1]!).checkpointPath !== checkpoint) fail(`The Phase ${number[phase]} close packet belongs to an earlier checkpoint. Complete the current phase's close procedure with its fixed checkpoint.`); let closeOrder = -1; - for (const use of closeReads.filter(e => e.input?.file_path === latestPath)) { + for (const use of closeReads.filter(e => native(e) === latestPath)) { const results = entered.filter(e => e.kind === 'result' && e.toolUseId === use.toolUseId); if (results.length !== 1) continue; const range = autoplanReadRange(use, results[0]!, content, entered); @@ -345,7 +388,7 @@ function requirePublication(phase: Phase, entryOrder: number, entered: Event[], } if (covered.size !== content.split('\n').length) fail(`Read every line of the current Phase ${number[phase]} close packet successfully before entering the next phase.`); const pending = entered.some(e => e.kind === 'use' && e.order > closeOrder && ['Write', 'Edit'].includes(e.name ?? '') && - e.input?.file_path === init.activePlan && !entered.some(r => r.kind === 'result' && r.toolUseId === e.toolUseId)); + samePath(e.input?.file_path, init.activePlan) && !entered.some(r => r.kind === 'result' && r.toolUseId === e.toolUseId)); if (pending) fail('An active-plan mutation is pending after the close Read. Wait for its result, then verify the current close input.'); if (current) verifyCloseEdits(entered, closeOrder, init); const messages = entered.filter((e): e is Event & { kind: 'message' } => e.kind === 'message' && e.order > closeOrder); @@ -374,7 +417,7 @@ function evaluatePublication(input: PublicationHookInput, root: string, events: if (pendingRead ? input.tool_name !== 'Read' || events.some(e => (e.kind === 'use' || e.kind === 'result') && e.toolUseId === input.tool_use_id) : current.length !== 1 || current[0]!.kind !== 'use' || current[0]!.name !== input.tool_name || - !isDeepStrictEqual(current[0]!.input, input.tool_input)) fail('Current native phase-entry identity is unavailable. Retry this phase-entry tool after the journal is available.'); + !isDeepStrictEqual(nativeToolInput(current[0]!.input, input.cwd), nativeToolInput(input.tool_input, input.cwd))) fail('Current native phase-entry identity is unavailable. Retry this phase-entry tool after the journal is available.'); const before = pendingRead ? events : events.filter(e => e.order < current[0]!.order); // Pinned Claude retains skill hooks after end_turn. Only an authenticated // later human request can release the old invocation; tool results and @@ -443,11 +486,71 @@ function evaluatePublication(input: PublicationHookInput, root: string, events: } } +/** + * A worktree session gets the repository root as CLAUDE_PROJECT_DIR while its + * journal is rooted in the linked worktree. Only git's own two-way link makes + * a directory that worktree: /.git/worktrees//gitdir names + * /.git, and that file names the same admin entry back. + */ +export function linkedWorktrees(projectDir: string): string[] { + let entries: fs.Dirent[]; + const admin = path.join(projectDir, '.git', 'worktrees'); + try { entries = fs.readdirSync(admin, { withFileTypes: true }); } catch { return []; } + return entries.filter(entry => entry.isDirectory()).slice(0, 256).flatMap(entry => { + try { + const link = path.join(admin, entry.name); + // worktree.useRelativePaths writes each side relative to its own file's directory. + const forward = /^([^\r\n]+)\r?\n?$/.exec(fs.readFileSync(path.join(link, 'gitdir'), 'utf8'))?.[1]; + if (!forward) return []; + const gitFile = nativePathSpelling(path.resolve(link, nativePathSpelling(forward))); + if (path.basename(gitFile) !== '.git' || !fs.lstatSync(gitFile).isFile()) return []; + const back = /^gitdir: ([^\r\n]+)\r?\n?$/.exec(fs.readFileSync(gitFile, 'utf8'))?.[1]; + if (!back || nativePathSpelling(fs.realpathSync(path.resolve(path.dirname(gitFile), nativePathSpelling(back)))) !== + nativePathSpelling(fs.realpathSync(link))) return []; + const worktree = nativePathSpelling(fs.realpathSync(path.dirname(gitFile))); + return ownPath(worktree) ? [worktree] : []; + } catch { return []; } + }); +} + export function publicationHookOutput(decision: PublicationDecision): object { return decision.allow ? {} : { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: `[autoplan] ${decision.reason}` } }; } +const GUIDE = 'https://github.com/garrytan/gstack/blob/main/docs/autoplan-guard-troubleshooting.md'; +type OwnedRead = ReturnType; +/** Positive identity conflicts: retrying cannot change them, so they deny with the supported fallback. */ +const HARD_CAUSE: Partial> = { + competing_root: 'The session journal has more than one conversation root, so this session cannot be identified.', + foreign_cwd: "The session journal was started in a different project directory than this hook's project.", + sidechain: "The session journal's first turn or its ancestry is a sidechain record, not the parent session.", + agent: "The session journal's conversation ancestry passes through a subagent record.", + cycle: "The session journal's parent links form a cycle.", +}; +const guidance = (code: string, read?: OwnedRead) => + `(code ${code}, Claude Code ${read?.diagnostic?.claudeVersion ?? 'version unknown'}). Troubleshooting: ${GUIDE}`; + +/** + * Narrow advisory (D1): the journal parsed, held still across two reads and + * holds this tool_use, but its root shape is one this guard does not know. + * No permissionDecision, so Claude Code's own permission check still runs. + */ +function unverifiedEntry(code: string, read: OwnedRead): object { + try { + const dir = path.join(resolveStateRoot(), 'analytics'); + fs.mkdirSync(dir, { recursive: true }); + fs.appendFileSync(path.join(dir, 'autoplan-guard.jsonl'), JSON.stringify({ ts: new Date().toISOString(), + event: 'unverified_phase_entry', reason: code, claude_code_version: read.diagnostic?.claudeVersion ?? null, + record_types: read.diagnostic?.rootShape ?? [] }) + '\n'); + } catch { /* A logging failure never changes the verdict. */ } + const notice = `phase publication was NOT verified for this session (journal shape ${code})`; + return { systemMessage: `[autoplan] ${notice}. ${guidance(code, read)}`, + hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: `[autoplan] ${notice}: this Claude Code journal ` + + 'shape is not recognized, so this phase entry proceeds unverified. Publish each completed phase report as your ' + + 'own parent assistant text before entering the next phase, exactly as the workflow requires.' } }; +} + /** Claude's pending tool record can flush after hook entry; wait only for that identity. */ export async function runPublicationHook(value: unknown, root: string): Promise { try { @@ -459,20 +562,39 @@ export async function runPublicationHook(value: unknown, root: string): Promise< if (!candidate({ name: input.tool_name, input: input.tool_input }, input.cwd)) return {}; // Native hooks override this environment value with the session's project // root. Bash cd changes input.cwd, not the journal's original ownership. - const projectCwd = process.env.CLAUDE_PROJECT_DIR ?? input.cwd; + // On Windows the hook env spells it C:/...; the journal records C:\... + const projectCwd = nativePathSpelling(process.env.CLAUDE_PROJECT_DIR ?? input.cwd); if (!ownPath(projectCwd)) fail('Native parent project directory is unavailable.'); + const journal = nativePathSpelling(input.transcript_path), owners = [projectCwd, ...linkedWorktrees(projectCwd)]; const deadline = performance.now() + 2_000; + let read: OwnedRead | undefined, stable: string | undefined; do { - const snapshot = readOwnedClaudePublicTranscript(input.transcript_path, projectCwd, input.session_id); - if (snapshot.transcript.status === 'ready') { - if (snapshot.events.some(e => e.kind === 'use' && e.toolUseId === input.tool_use_id)) - return publicationHookOutput(evaluateAutoplanPublication(input, root, snapshot.events)); - if (input.tool_name === 'Read' && evaluatePublication(input, root, snapshot.events, true).allow) + const reads = [readOwnedClaudePublicTranscript(journal, projectCwd, input.session_id)]; + if (reads[0]!.transcript.reason === 'foreign_cwd') + for (const owner of owners.slice(1)) reads.push(readOwnedClaudePublicTranscript(journal, owner, input.session_id)); + read = reads.find(r => r.transcript.status === 'ready') ?? reads.find(r => r.transcript.reason !== 'foreign_cwd') ?? reads[0]!; + if (read.transcript.status === 'ready') { + if (read.events.some(e => e.kind === 'use' && e.toolUseId === input.tool_use_id)) + return publicationHookOutput(evaluateAutoplanPublication(input, root, read.events)); + if (input.tool_name === 'Read' && evaluatePublication(input, root, read.events, true).allow) return {}; } + const code = read.transcript.reason, cause = code && HARD_CAUSE[code]; + if (cause) fail(`Publication guard cannot verify this session: ${cause} Fallback: run /plan-ceo-review, then ` + + `/plan-devex-review, then /plan-eng-review by hand, or start a new session. ${guidance(code, read)}`); + // Unflushed records, a missing current tool_use, malformed or changing + // bytes and identity failures stay deny-and-retry. + const shape = read.diagnostic; + if (code?.startsWith('unrecognized_shape:') && shape?.complete && shape.toolUseIds.includes(input.tool_use_id)) { + if (stable === shape.sha256) return unverifiedEntry(code, read); + stable = shape.sha256; + } else stable = undefined; await new Promise(resolve => setTimeout(resolve, 50)); } while (performance.now() < deadline); - fail('Native parent evidence has not reached the journal yet. Retry this phase-entry tool; no missing-publication conclusion has been made.'); + const code = read?.transcript.reason; + fail(`${code === 'changing' ? 'Claude Code was still writing the session journal on every read.' + : 'Native parent evidence has not reached the journal yet.'} Retry this phase-entry tool; no missing-publication ` + + `conclusion has been made.${code ? ` ${guidance(code, read)}` : ''}`); } catch (error) { return publicationHookOutput({ allow: false, reason: error instanceof BoundaryError ? error.message : 'Hook installation or native evidence is unavailable. Restore this Autoplan installation before retrying.' }); diff --git a/docs/autoplan-guard-troubleshooting.md b/docs/autoplan-guard-troubleshooting.md new file mode 100644 index 000000000..01f43e838 --- /dev/null +++ b/docs/autoplan-guard-troubleshooting.md @@ -0,0 +1,109 @@ +# /autoplan publication guard: troubleshooting + +`/autoplan` installs a `PreToolUse` hook (`autoplan/bin/phase-publication-hook`) +at its phase boundaries. Before it lets a phase entry `Read` (or a reviewer +`Agent` dispatch) run, it reads this session's own Claude Code journal +(`~/.claude/projects//.jsonl`) and checks that the previous +phase's report was published as parent assistant text. + +To do that it must first prove which journal records belong to this session. +It walks the record chain from the first conversation turn up to its root. When +it cannot prove ownership it reports a **code**. This page explains each code +and what to do. + +Every message from the guard names the code and the Claude Code version that +wrote the journal. Include both when you report a problem. + +## Supported journal roots + +The guard accepts exactly one root per journal file: + +- A user message with no parent (sessions without hooks). +- An unbroken chain of message-less `SessionStart` hook attachments + (`hook_success`, `hook_additional_context`, ...) that starts at a null parent + and ends at the first user turn. Claude Code writes this when any + `SessionStart` hook is installed, including hooks that plugins add. It also + writes one after `/clear`. +- A `compact_boundary` record whose logical parent is not in the file, but only + when it is the file's first record. Claude Code writes this when you resume a + compacted session with `--fork-session`. + +`--continue`, `--resume` and `/compact` append to the same journal, and the +guard follows them through their existing parent links. + +The real journals these rules were checked against (startup with two +`SessionStart` hooks, `/compact`, `/clear`, and `--resume --fork-session` +after a compact) came from Claude Code 2.1.284. A redacted copy is in +`test/fixtures/claude-native-journal-roots-2.1.284.json`. A periodic CI +canary, `autoplan-journal-drift`, captures fresh journals from the pinned +Claude Code and checks the reader still accepts them. + +## Hard denials + +The journal shows a conflict, so retrying cannot help. The guard denies the +phase entry. + +| Code | Cause | +|------|-------| +| `competing_root` | The journal has more than one conversation root, or two records share one UUID. | +| `foreign_cwd` | The journal's root was written in another project directory, after spelling and symlinks are taken into account. Linked git worktrees of the project are accepted. | +| `sidechain` | The first turn or its ancestry is a sidechain (subagent) record, not the parent session. | +| `agent` | The conversation ancestry passes through a subagent record. | +| `cycle` | The journal's parent links form a loop. | + +**What to do:** run the review phases by hand: `/plan-ceo-review`, then +`/plan-devex-review`, then `/plan-eng-review`. Or start a new Claude Code +session in the project directory and run `/autoplan` again. If a fresh session +gets the same code, open an issue with the code and Claude Code version. + +## Retry denials + +The guard has not reached a conclusion. The message ends with +"no missing-publication conclusion has been made". Retry the same tool. + +| Code | Cause | +|------|-------| +| (none) | The journal does not yet hold the records the guard needs. This includes the current tool call, or an ancestor record Claude Code has not written yet. | +| `changing` | Claude Code was writing the journal during every read in the 2-second window. | +| `identity` | The journal path, its directories or the file itself failed the identity checks: for example a symlink, a foreign session, or the wrong directory layout. | +| `malformed` | A complete journal line is not valid JSON or UTF-8, or its records contradict each other. | + +If a retry keeps failing with the same code, use the fallback above. + +## Unverified phase entry (`unrecognized_shape:`) + +The guard found a journal root it does not recognize. This usually means Claude +Code changed its journal format. In this one case the guard does not block you, +but only when all of the following hold: + +- every journal line parses, +- the journal is identical across two reads, +- the current tool call is already in the journal, and +- the only problem is the unrecognized root shape. + +The phase entry then goes ahead **without** a permission decision from the +guard, so Claude Code's own permission check still runs. You see: + +> [autoplan] phase publication was NOT verified for this session (journal shape unrecognized_shape:...) + +The model gets the same notice, with a reminder to publish each phase report. +`unrecognized_shape:cwd_spelling` means the journal's directory is the same +folder as the project, spelled differently. + +If any other condition fails, or a hard code also applies, the guard denies +instead. + +Each unverified entry adds one line to +`/analytics/autoplan-guard.jsonl` (normally +`~/.gstack/analytics/autoplan-guard.jsonl`). The line holds the code, the Claude +Code version and the record types at the journal root. It never holds content. +A failure to write this log never changes the guard's decision. Please include +that line when you report the new shape. + +There is no environment variable that turns the guard off. + +## Windows paths + +Claude Code and Git Bash can spell one path as `C:\x`, `c:\x`, `C:/x` or `/c/x`. +The guard treats these as the same path. It does not resolve `.` or `..`, so a +path containing them is still rejected. diff --git a/lib/claude-public-transcript.ts b/lib/claude-public-transcript.ts index b8b000eed..d28ebfabb 100644 --- a/lib/claude-public-transcript.ts +++ b/lib/claude-public-transcript.ts @@ -1,6 +1,7 @@ /** 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'; export interface NativePlanQuestion { header: string; @@ -44,6 +45,8 @@ export interface PlanCountTranscript { /** Actual native plan-mode approval requests; pending is the UI gate, never an AUQ. */ planReadyRequests?: Array<{ sessionId: string; toolUseId: string; timestamp: string; failed: boolean; source?: 'pre_tool_use' }>; error?: string; + /** Owned reads only: why the exact parent journal supplied no owned lines. */ + reason?: OwnedTranscriptReason; } /** Hook-only verified causal order (physical order for independent ready records). The existing fixture projection remains unchanged. */ @@ -57,6 +60,8 @@ 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[]; } /** A rejected/refused call needs an actual later answer, not unrelated progress. */ @@ -142,55 +147,132 @@ 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' | + '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): string[] { - const uuid = (value: unknown): value is string => typeof value === 'string' && - /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(value); - const scoped = lines.flatMap((line, index) => { +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` && record.agentId == null - ? [{ line, index, record }] : []; + return object(record) && filename === `${record.sessionId}.jsonl` ? [{ line, index, record }] : []; }); - const first = scoped.find(x => object(x.record.message) && ['user', 'assistant'].includes(x.record.message.role)); - if (!first) return []; - const nodes = scoped.filter(({ record: r }) => r.isSidechain === false && - typeof r.cwd === 'string' && path.isAbsolute(r.cwd) && uuid(r.uuid) && validTimestamp(r.timestamp)); - const byId = new Map(); + 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)) throw Error('ambiguous owned native UUID'); + if (byId.has(node.record.uuid)) return { reason: 'competing_root', shape: ['duplicate_uuid'] }; byId.set(node.record.uuid, node); } - if (byId.get(first.record.uuid) !== first) return []; - const parent = (r: Record): string | undefined => uuid(r.parentUuid) ? r.parentUuid : + 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 && uuid(r.logicalParentUuid) ? r.logicalParentUuid : undefined; + 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/malformed ancestor supplies no ownership. - let root = first; - const ancestry = new Set(); - while (true) { - if (ancestry.has(root.record.uuid)) throw Error('cyclic owned native ancestry'); - ancestry.add(root.record.uuid); - const id = parent(root.record); + // 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) return []; - root = next; + 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; } - if (root.record.parentUuid !== null || root.record.cwd !== cwd || - !object(root.record.message) || root.record.message.role !== 'user') return []; - if (nodes.some(x => x !== root && x.record.parentUuid === null && - object(x.record.message) && x.record.message.role === 'user')) throw Error('competing owned native roots'); - // Stable topological traversal preserves physical order whenever two ready - // records have no parent dependency. No timestamp provides ordering credit. - const children = new Map(); - const indexed = new Map(nodes.map(x => [x.index, x])); + 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.index); children.set(id, list); } + 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); @@ -208,14 +290,15 @@ function ownedCausalLines(lines: string[], cwd: string, filename: string): strin } 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); + for (const child of children.get(node.record.uuid) ?? []) offer(child.index); } - return ordered; + return { lines: ordered, root: root.record.uuid }; } /** @@ -260,23 +343,31 @@ export function readPlanCountTranscript(configDir: string, cwd: string, // fixture's first parent user message; legacy records keep exact-cwd scoping. let originSeen = false; const ancestry = new Set(); - let causalMembership: Set | undefined; - 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); + let causal: OwnedLines | undefined, causalMembership: Set | undefined, ownedLines: string[] | 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) { - causalMembership = new Set(); - try { - causalMembership = new Set(ownedCausalLines(completeLines, cwd, entry.name) - .map(line => JSON.parse(line).uuid)); - } catch { /* Invalid strict ancestry adds no recovery; legacy traversal continues. */ } + // 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) : []); } return causalMembership.has(id); }; - for (const line of ownedSnapshot ? ownedCausalLines(completeLines, cwd, entry.name) : completeLines) { + 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) { if (!line.trim()) continue; const record = JSON.parse(line); if (!object(record) || typeof record.sessionId !== 'string' || @@ -290,12 +381,16 @@ 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 && record.cwd !== cwd && ancestry.size > 0 && + (!ownedSnapshot && !sameNativePath(record.cwd, cwd) && ancestry.size > 0 && recoveredMember(record.uuid))); if (!originSeen && object(record.message) && ['user', 'assistant'].includes(record.message.role)) { originSeen = true; - if (parentMetadata && record.cwd === cwd && record.message.role === 'user' && - record.parentUuid === null) ancestry.add(record.uuid); + // The first user prompt roots ownership itself, or through the + // verified causal root above it (SessionStart preamble, resumed + // boundary). Children can flush before parents, so physical order + // never decides that ancestry. + if (parentMetadata && sameNativePath(record.cwd, cwd) && record.message.role === 'user' && + (record.parentUuid === null || recoveredMember(record.uuid))) ancestry.add(record.uuid); } if (continuation) ancestry.add(record.uuid); // Native compaction resets parentUuid but links its prior owned @@ -307,7 +402,7 @@ export function readPlanCountTranscript(configDir: string, cwd: string, ancestry.has(record.logicalParentUuid) && !ancestry.has(record.uuid); if (compactContinuation) ancestry.add(record.uuid); if (ownedSnapshot && !ancestry.has(record.uuid)) continue; - if ((record.cwd !== cwd && !continuation) || record.isSidechain !== false || !object(record.message)) continue; + if ((!sameNativePath(record.cwd, cwd) && !continuation) || record.isSidechain !== false || !object(record.message)) continue; 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); @@ -407,37 +502,79 @@ 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; + if (!claudeVersion && typeof record.version === 'string' && /^\d+\.\d+\.\d+[0-9A-Za-z.+-]{0,24}$/.test(record.version)) + claudeVersion = record.version; + 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[]; + transcript: PlanCountTranscript; events: ClaudeParentPublicEvent[]; diagnostic?: OwnedTranscriptDiagnostic; } { - let fd: number | undefined; + let fd: number | undefined, bytes: Buffer | undefined; try { - if (!/^[A-Za-z0-9_-]{1,160}$/.test(sessionId) || !path.isAbsolute(cwd) || - !path.isAbsolute(file) || path.normalize(file) !== file || - path.basename(file) !== `${sessionId}.jsonl`) throw Error('invalid native parent identity'); + 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() || fs.realpathSync(dir) !== dir)) - throw Error('invalid native parent directory'); + [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() || before.size > BigInt(MAX_BYTES)) throw Error('invalid native parent file'); - const bytes = fs.readFileSync(fd); + if (!before.isFile() || before.size > BigInt(MAX_BYTES)) throw new OwnedReadError('identity'); + 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 || - before.size !== after.size || before.mtimeNs !== after.mtimeNs || + 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 Error('native parent changed during read'); + before.size !== BigInt(bytes.length)) throw new OwnedReadError('changing'); const text = bytes.toString('utf8'); - if (!Buffer.from(text).equals(bytes)) throw Error('invalid native parent encoding'); - const events: ClaudeParentPublicEvent[] = []; - const transcript = readPlanCountTranscript(config, cwd, () => {}, file, { file, text, events }); - if (events.some(event => event.sessionId !== sessionId)) throw Error('foreign native parent event'); - return { transcript, events }; - } catch { - return { transcript: { status: 'error', calls: [], assistantMessages: [], - error: 'Owned native public transcript is unavailable or changing' }, events: [] }; + 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 }; + 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-publication-guard.test.ts b/test/autoplan-publication-guard.test.ts index 43d3eab7a..d0b45f532 100644 --- a/test/autoplan-publication-guard.test.ts +++ b/test/autoplan-publication-guard.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; import { initializePlan, prepareMethodology, createSnapshot, preparePhaseClose, prepareAmendedInput } from '../bin/gstack-autoplan-snapshot'; import { evaluateAutoplanPublication, runPublicationHook, autoplanReadRange, type PublicationHookInput } from '../autoplan/bin/phase-publication-hook.ts'; -import { readOwnedClaudePublicTranscript, type ClaudeParentPublicEvent } from '../lib/claude-public-transcript'; +import { readOwnedClaudePublicTranscript, nativePathSpelling, ownedNativePath, sameNativePath, type ClaudeParentPublicEvent } from '../lib/claude-public-transcript'; import { prematureAutoplanPhaseEntry } from './helpers/autoplan-method-read-audit'; import captured from './fixtures/autoplan-publication-boundary-361c.json'; import consumption from './fixtures/autoplan-phase-consumption-491.json'; @@ -298,10 +298,11 @@ describe('Autoplan parent publication guard', () => { test('the literal init parser retains Windows drive and UNC identities', () => { const source = fs.readFileSync(path.join(ROOT, 'autoplan/bin/phase-publication-hook.ts'), 'utf8'); const fn = source.slice(source.indexOf('function initArguments('), source.indexOf('\nfunction invocation(')); - const parse = new Function('path', 'fs', 'process', 'ownPath', + const parse = new Function('path', 'fs', 'process', 'ownPath', 'nativePathSpelling', 'samePath', new Bun.Transpiler({ loader: 'ts' }).transformSync(fn) + '\nreturn initArguments;')( path.win32, { realpathSync: (file: string) => file }, { platform: 'win32' }, - (value: unknown) => typeof value === 'string' && path.win32.isAbsolute(value) && path.win32.normalize(value) === value); + (value: unknown) => ownedNativePath(value, path.win32), nativePathSpelling, + (a: unknown, b: unknown) => sameNativePath(a, b, path.win32)); for (const root of [String.raw`C:\repo`, String.raw`\\server\share\repo`]) { const args = ['source.md', 'active.md', 'restore.md'].map(file => path.win32.join(root, file)); const script = path.win32.join(root, 'bin/gstack-autoplan-snapshot.ts'); @@ -313,6 +314,13 @@ describe('Autoplan parent publication guard', () => { if (!root.startsWith('\\\\')) { const native = [script, ...args].map(value => '"' + value + '"'); expect(parse(`bun ${native[0]} init ${native.slice(1).join(' ')}`, root)).toEqual(args); + // gstack-paths prints /c/...; Git Bash hands bun C:/... for the same file. + const msys = [script, ...args].map(value => '"/c/' + value.slice(3).replaceAll('\\', '/') + '"'); + expect(parse(`bun ${msys[0]} init ${msys.slice(1).join(' ')}`, root)).toEqual(args); + const lower = [script, ...args].map(value => `'c${value.slice(1)}'`); + expect(parse(`bun ${lower[0]} init ${lower.slice(1).join(' ')}`, root)).toEqual(args); + for (const bad of ['/cc/repo/source.md', '/c', '/c/repo/../source.md']) + expect(parse(`bun "${msys[0]!.slice(1, -1)}" init "${bad}" ${msys.slice(2).join(' ')}`, root)?.[0]).not.toBe(args[0]); } for (const bad of [command + ' && true', command.replace('source.md', String.raw`..\source.md`), command.replace('source.md', 'nested/../source.md'), command.replace(script, path.win32.join(root, 'foreign.ts'))]) { @@ -1068,3 +1076,309 @@ describe('Autoplan authenticated phase consumption', () => { }); }); + +describe('Autoplan native journal roots and Windows spellings', () => { + const windows = process.platform === 'win32'; + function published(publish = true) { + const f = fixture(); if (publish) f.message(); f.current(); + const { rows } = f.journal(); + const save = () => fs.writeFileSync(f.input.transcript_path, rows.map(r => JSON.stringify(r)).join('\n') + '\n'); + return { f, rows, save }; + } + const attachment = (f: ReturnType, parentUuid: string | null, extra: object = {}): any => ({ + uuid: randomUUID(), parentUuid, cwd: f.cwd, sessionId: f.sessionId, isSidechain: false, + timestamp: new Date(clock).toISOString(), type: 'attachment', + attachment: { type: 'hook_success', hookEvent: 'SessionStart' }, ...extra }); + const hook = (f: ReturnType, project = f.cwd) => + withNativeProjectDirectory(project, () => runPublicationHook(f.input, ROOT)); + const status = (f: ReturnType) => + readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId).transcript.status; + + for (const shape of ['one', 'chained', 'stray-before'] as const) + test(`a SessionStart attachment root owns the journal beneath it (${shape})`, async () => { + const { f, rows, save } = published(); + const root = attachment(f, null), chain = shape === 'chained' ? [root, attachment(f, root.uuid)] : [root]; + rows[0].parentUuid = chain.at(-1)!.uuid; + rows.unshift(...(shape === 'stray-before' ? [attachment(f, null)] : []), ...chain); save(); + expect(status(f)).toBe('ready'); + expect(await hook(f)).toEqual({}); + }); + + test('an attachment root still requires the parent publication', async () => { + const { f, rows, save } = published(false); + const root = attachment(f, null); rows[0].parentUuid = root.uuid; rows.unshift(root); save(); + expect(await hook(f)).toMatchObject({ hookSpecificOutput: { permissionDecisionReason: expect.stringContaining('Publish the filled Phase 1') } }); + }); + + test('a message-bearing attachment root is an unrecognized shape: advisory, never ownership', async () => { + const { f, rows, save } = published(); + const root = attachment(f, null, { message: { role: 'system', content: [] } }); + rows[0].parentUuid = root.uuid; rows.unshift(root); save(); + const read = readOwnedClaudePublicTranscript(f.input.transcript_path, f.cwd, f.sessionId); + expect(read.transcript.status).not.toBe('ready'); + expect(read.transcript.reason).toBe('unrecognized_shape:first_turn:attachment:hook_success'); + expect(read.events).toEqual([]); + const home = fs.mkdtempSync(path.join(tmpdir(), 'autoplan-guard-home-')); dirs.push(home); + const previous = process.env.GSTACK_HOME; process.env.GSTACK_HOME = home; + try { + const output: any = await hook(f); + expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); + expect(output.systemMessage).toContain('phase publication was NOT verified for this session'); + } finally { if (previous === undefined) delete process.env.GSTACK_HOME; else process.env.GSTACK_HOME = previous; } + }); + + for (const kind of ['foreign-cwd', 'sidechain', 'competing-conversation', 'competing-user-root'] as const) + test(`an attachment root cannot supply ownership: ${kind}`, async () => { + const { f, rows, save } = published(); + const root = attachment(f, null, kind === 'foreign-cwd' ? { cwd: path.join(f.cwd, 'foreign') } + : kind === 'sidechain' ? { isSidechain: true } : {}); + rows[0].parentUuid = root.uuid; rows.unshift(root); + if (kind === 'competing-conversation' || kind === 'competing-user-root') { + const other = attachment(f, null); + const prompt = { ...structuredClone(rows[1]), uuid: randomUUID(), parentUuid: kind === 'competing-user-root' ? null : other.uuid }; + rows.push(...(kind === 'competing-conversation' ? [other] : []), prompt); + } + save(); + expect(status(f)).not.toBe('ready'); + expect((await hook(f) as any).hookSpecificOutput?.permissionDecision).toBe('deny'); + }); + + test.if(windows)('CLAUDE_PROJECT_DIR spelled C:/ or c:\\ names the native journal cwd', async () => { + const { f } = published(); + expect(await hook(f, f.cwd.replaceAll('\\', '/'))).toEqual({}); + expect(await hook(f, f.cwd[0]!.toLowerCase() + f.cwd.slice(1))).toEqual({}); + expect((await hook(f, f.cwd + '\\.') as any).hookSpecificOutput?.permissionDecision).toBe('deny'); + }); + + test.if(windows)('an init command spelled with Git Bash /c/ paths binds the same invocation', () => { + const f = fixture(); f.message(); f.current(); + const msys = (file: string) => '/' + file[0]!.toLowerCase() + file.slice(2).replaceAll('\\', '/'); + (f.events[0] as any).input.command = `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" init "${msys(f.source)}" "${msys(f.active)}" "${msys(f.restore)}"`; + expect(f.evaluate()).toEqual({ allow: true }); + }); + + test.if(windows)('a close packet Read spelled C:/ is still the close Read', () => { + const f = fixture(), close = f.events.find(e => e.kind === 'use' && e.toolUseId === 'close') as any; + const forward = (close.input!.file_path as string).replaceAll('\\', '/'); + close.input!.file_path = forward; + (f.events.find(e => e.kind === 'result' && e.toolUseId === 'close') as any).file.filePath = forward; + f.message(); f.current(); + expect(f.evaluate()).toEqual({ allow: true }); + }); + + test.if(windows)('an active-plan Edit spelled C:/ after the close Read is still a mutation', () => { + const f = fixture(); f.message(); + f.use('edit', 'Edit', { file_path: f.active.replaceAll('\\', '/'), old_string: 'Keep', new_string: 'Drop' }); + f.current(); + expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('mutation is pending') }); + }); +}); + +describe('Autoplan phase entry through the gbrain :user render (#2569)', () => { + const generator = fs.readFileSync(path.join(ROOT, 'scripts/gen-skill-docs.ts'), 'utf8'); + const rewriteSectionBase: (content: string, linkRoot: string | null) => string = new Function( + new Bun.Transpiler({ loader: 'ts' }).transformSync(generator.slice(generator.indexOf('function rewriteSectionBase('), + generator.indexOf('\n}\n', generator.indexOf('function rewriteSectionBase(')) + 2)) + '\nreturn rewriteSectionBase;')(); + const sections = ['ceo-phase.md', 'design-phase.md', 'dx-phase.md', 'eng-phase.md', 'tasks-aggregator.md']; + /** Writes what setup's swap-in render writes: the generator's rewrite with --link-root = the final render root. */ + function render(root: string, linkRoot = root, edit = (text: string) => text) { + fs.mkdirSync(path.join(root, 'autoplan', 'sections'), { recursive: true }); + for (const name of sections) fs.writeFileSync(path.join(root, 'autoplan', 'sections', name), + edit(rewriteSectionBase(fs.readFileSync(path.join(ROOT, 'autoplan/sections', name), 'utf8'), linkRoot))); + return root; + } + function withEnv(env: Record, work: () => T): T { + const previous = Object.fromEntries(Object.keys(env).map(k => [k, process.env[k]])); + for (const [k, v] of Object.entries(env)) if (v === undefined) delete process.env[k]; else process.env[k] = v; + try { return work(); } + finally { for (const [k, v] of Object.entries(previous)) if (v === undefined) delete process.env[k]; else process.env[k] = v; } + } + /** CEO entered through `entry` and published; the next phase entry is the request under judgment. */ + function entered(entry: string, next?: string) { + const f = fixture(), text = fs.readFileSync(entry, 'utf8'), lines = text.split('\n'); + const use = f.events.find(e => e.kind === 'use' && e.toolUseId === 'entry') as any; + const result = f.events.find(e => e.kind === 'result' && e.toolUseId === 'entry') as any; + use.input = { file_path: entry, offset: 1 }; + (result as any).file = { filePath: entry, content: text, startLine: 1, numLines: lines.length, totalLines: lines.length }; + f.message(); + if (next) f.input.tool_input = { file_path: next }; + f.current(); + return f; + } + const scratch = () => { const dir = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-render-'))); dirs.push(dir); return dir; }; + const denied = { allow: false, reason: expect.stringContaining('different or unavailable installation') }; + + test('the hook rewrite is the generator rewrite', () => { + const linkRoot = path.join(scratch(), 'render', 'claude'); + for (const name of [...sections, 'phase-close.md']) { + const text = fs.readFileSync(path.join(ROOT, 'autoplan/sections', name), 'utf8'); + const hook = fs.readFileSync(path.join(ROOT, 'autoplan/bin/phase-publication-hook.ts'), 'utf8'); + const fn = hook.slice(hook.indexOf('function renderSectionBase('), hook.indexOf('\n}\n', hook.indexOf('function renderSectionBase(')) + 2); + const ours = new Function(new Bun.Transpiler({ loader: 'ts' }).transformSync(fn) + '\nreturn renderSectionBase;')(); + expect(ours(text, linkRoot)).toBe(rewriteSectionBase(text, linkRoot)); + } + }); + + for (const via of ['GSTACK_USER_RENDER_DIR', 'GSTACK_HOME', 'HOME', 'empty GSTACK_USER_RENDER_DIR'] as const) + test(`the render root setup resolves through ${via} is this installation`, () => { + const home = scratch(), root = render(via === 'GSTACK_USER_RENDER_DIR' ? path.join(home, 'custom') : + via === 'HOME' ? path.join(home, '.gstack', 'render', 'claude') : path.join(home, 'render', 'claude')); + const env = via === 'GSTACK_USER_RENDER_DIR' ? { GSTACK_USER_RENDER_DIR: root, GSTACK_HOME: undefined } + : via === 'HOME' ? { GSTACK_USER_RENDER_DIR: undefined, GSTACK_HOME: undefined, HOME: home } + : { GSTACK_USER_RENDER_DIR: via === 'GSTACK_HOME' ? undefined : '', GSTACK_HOME: home }; + const f = entered(path.join(root, 'autoplan/sections/ceo-phase.md'), path.join(root, 'autoplan/sections/design-phase.md')); + expect(withEnv(env, () => f.evaluate())).toEqual({ allow: true }); + }); + + for (const kind of ['sibling-root', 'other-home', 'linked-out', 'changed-bytes', 'stale-link-root', 'no-render-root'] as const) + test(`a render lookalike is a different installation: ${kind}`, () => { + const home = scratch(), root = path.join(home, 'render', 'claude'); + let next = path.join(root, 'autoplan/sections/design-phase.md'), env: Record = { GSTACK_USER_RENDER_DIR: undefined, GSTACK_HOME: home }; + if (kind === 'sibling-root') { render(root); next = path.join(render(path.join(home, 'render', 'claude2')), 'autoplan/sections/design-phase.md'); } + if (kind === 'other-home') { const other = scratch(); render(root); next = path.join(render(path.join(other, 'render', 'claude')), 'autoplan/sections/design-phase.md'); } + if (kind === 'linked-out') { + const outside = render(path.join(scratch(), 'outside'), root); + fs.mkdirSync(path.join(root, 'autoplan'), { recursive: true }); + fs.symlinkSync(path.join(outside, 'autoplan', 'sections'), path.join(root, 'autoplan', 'sections'), 'junction'); + } + if (kind === 'changed-bytes') render(root, root, text => text + 'Forged instruction.\n'); + if (kind === 'stale-link-root') render(root, path.join(home, 'previous-render')); + if (kind === 'no-render-root') { render(root); env = { GSTACK_USER_RENDER_DIR: path.join(home, 'missing'), GSTACK_HOME: home }; } + const f = entered(path.join(ROOT, 'autoplan/sections/ceo-phase.md'), next); + expect(withEnv(env, () => f.evaluate())).toMatchObject(denied); + }); +}); + +describe('Autoplan current-use identity: journal spelling vs PreToolUse spelling', () => { + // Claude journals the model's file_path verbatim and hands PreToolUse its resolved form. + const spellings = (file: string) => [ + path.join(path.dirname(file), '.') + path.sep + '.' + path.sep + path.basename(file), + ...(process.platform === 'win32' ? [path.dirname(file) + '/' + path.basename(file)] : []), + ]; + for (const kind of ['dot-segment', 'forward-slash'] as const) + test.if(kind === 'dot-segment' || process.platform === 'win32')(`a ${kind} journal spelling is the same current Read`, () => { + const f = fixture(); f.message(); + const target = f.input.tool_input.file_path as string, journal = spellings(target)[kind === 'dot-segment' ? 0 : 1]!; + f.use('next', 'Read', { file_path: journal }); + expect(f.evaluate()).toEqual({ allow: true }); + }); + + for (const kind of ['other-file', 'extra-field'] as const) + test(`a current Read that differs beyond spelling is not the same identity: ${kind}`, () => { + const f = fixture(); f.message(); + const target = f.input.tool_input.file_path as string; + f.use('next', 'Read', kind === 'other-file' ? { file_path: path.join(path.dirname(target), 'eng-phase.md') } + : { file_path: spellings(target)[0], limit: 5 }); + expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('identity is unavailable') }); + }); +}); + +describe('Autoplan Read coverage of a CRLF artifact', () => { + // A CRLF plan carries CRLF into the close packet; Claude's Read reports those lines without CR. + const disk = 'Binding: {}\r\n## Implementation plan\r\nKeep it.\nlast line\r\n'; + const use: any = { kind: 'use', sessionId: 's', toolUseId: 't', name: 'Read', order: 1, input: { file_path: 'C:\\x\\close-packet.md' } }; + const result = (content: string): any => ({ kind: 'result', sessionId: 's', toolUseId: 't', isError: false, order: 2, + file: { filePath: 'C:\\x\\close-packet.md', content, startLine: 1, numLines: 5, totalLines: 5 } }); + for (const [kind, reported, credited] of [ + ['crlf-stripped', disk.replace(/\r\n/g, '\n'), true], + ['verbatim', disk, true], + ['changed-text', disk.replace(/\r\n/g, '\n').replace('Keep it.', 'Drop it.'), false], + ['mid-line-cr-dropped', 'Binding: {}\n## Implementation plan\nKeep it.\nlast line\n', true], + ] as const) + test(`a ${kind} report ${credited ? 'covers' : 'does not cover'} every line`, () => { + expect(autoplanReadRange(use, result(reported), disk)).toEqual(credited ? { start: 1, end: 5 } : undefined); + }); + test('a CR inside a line is content, not a line ending', () => { + const inner = 'a\rb\nc'; + const r: any = { ...result('ab\nc'), file: { filePath: 'C:\\x\\close-packet.md', content: 'ab\nc', startLine: 1, numLines: 2, totalLines: 2 } }; + expect(autoplanReadRange(use, r, inner)).toBeUndefined(); + }); +}); + +describe('Autoplan restore-point header line ending', () => { + // init writes an LF header above a CRLF plan; Claude's Edit then rewrites every ending as CRLF. + for (const [kind, ending, allowed] of [['crlf', '\r\n', true], ['lf', '\n', true], + ['trailing-text', ' forged\n', false], ['no-newline', '', false]] as const) + test(`a restore-point header ending ${kind} ${allowed ? 'binds' : 'does not bind'} the invocation`, () => { + const f = fixture(); f.message(); f.current(); + const text = fs.readFileSync(f.active, 'utf8'), end = text.indexOf('-->') + 3; + fs.writeFileSync(f.active, text.slice(0, end) + ending + text.slice(text.indexOf('\n', end) + 1)); + expect(f.evaluate()).toEqual(allowed ? { allow: true } + : { allow: false, reason: expect.stringContaining('initialization artifacts do not match') }); + }); +}); + +describe('Autoplan ownership in a linked git worktree session', () => { + const posix = (file: string) => file.replaceAll('\\', '/'); + /** git worktree add's two-way link: /.git/worktrees//gitdir <-> /.git. */ + function admin(repo: string, worktree: string) { + const entry = path.join(repo, '.git', 'worktrees', 'session'); + fs.mkdirSync(entry, { recursive: true }); + fs.writeFileSync(path.join(entry, 'gitdir'), posix(path.join(worktree, '.git')) + '\n'); + return entry; + } + const pointer = (worktree: string, entry: string) => fs.writeFileSync(path.join(worktree, '.git'), `gitdir: ${posix(entry)}\n`); + const repo = () => { const dir = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-repo-'))); dirs.push(dir); return dir; }; + const run = (f: ReturnType, root: string) => withNativeProjectDirectory(root, () => runPublicationHook(f.input, ROOT)); + + test('the repository root owns its linked worktree journal', async () => { + const f = fixture(); f.message(); f.current(); f.journal(); + const root = repo(); pointer(f.cwd, admin(root, f.cwd)); + expect(await run(f, root)).toEqual({}); + }); + + test('a linked worktree journal still requires the parent publication', async () => { + const f = fixture(); f.current(); f.journal(); + const root = repo(); pointer(f.cwd, admin(root, f.cwd)); + expect(await run(f, root)).toMatchObject({ hookSpecificOutput: { permissionDecisionReason: expect.stringContaining('Publish the filled Phase 1') } }); + }); + + test.if(process.platform === 'win32')('a forward-slash, lowercase-drive gitdir names the same worktree', async () => { + const f = fixture(); f.message(); f.current(); f.journal(); + const root = repo(), lower = (file: string) => posix(file[0]!.toLowerCase() + file.slice(1)); + const entry = path.join(root, '.git', 'worktrees', 'session'); fs.mkdirSync(entry, { recursive: true }); + fs.writeFileSync(path.join(entry, 'gitdir'), lower(path.join(f.cwd, '.git')) + '\n'); + fs.writeFileSync(path.join(f.cwd, '.git'), `gitdir: ${lower(entry)}\n`); + expect(await run(f, lower(root))).toEqual({}); + }); + + test('worktree.useRelativePaths links resolve against each file\'s own directory', async () => { + const f = fixture(); f.message(); f.current(); f.journal(); + const root = repo(), entry = path.join(root, '.git', 'worktrees', 'session'); fs.mkdirSync(entry, { recursive: true }); + // Exactly what git 2.55 `-c worktree.useRelativePaths=true worktree add` writes. + fs.writeFileSync(path.join(entry, 'gitdir'), posix(path.relative(entry, path.join(f.cwd, '.git'))) + '\n'); + fs.writeFileSync(path.join(f.cwd, '.git'), `gitdir: ${posix(path.relative(f.cwd, entry))}\n`); + expect(await run(f, root)).toEqual({}); + }); + + test('an unrelated linked worktree leaves the exact project-directory owner unchanged', async () => { + const f = fixture(); f.message(); f.current(); f.journal(); + const elsewhere = repo(); fs.mkdirSync(path.join(f.cwd, '.git', 'worktrees', 'session'), { recursive: true }); + fs.writeFileSync(path.join(f.cwd, '.git', 'worktrees', 'session', 'gitdir'), posix(path.join(elsewhere, '.git')) + '\n'); + expect(await run(f, f.cwd)).toEqual({}); + }); + + for (const kind of ['no-link', 'worktree-file-only', 'admin-entry-only', 'other-repository', + 'submodule-gitdir', 'project-is-itself-a-worktree', 'bare-repository'] as const) + test(`an unlinked directory cannot own the journal: ${kind}`, async () => { + const f = fixture(); f.message(); f.current(); f.journal(); + const root = repo(); + if (kind === 'worktree-file-only') pointer(f.cwd, path.join(root, '.git', 'worktrees', 'session')); + if (kind === 'admin-entry-only') admin(root, f.cwd); + // The root claims the worktree, but the worktree names another repository. + if (kind === 'other-repository') { admin(root, f.cwd); pointer(f.cwd, admin(repo(), f.cwd)); } + if (kind === 'submodule-gitdir') { + admin(root, f.cwd); const modules = path.join(root, '.git', 'modules', 'session'); + fs.mkdirSync(modules, { recursive: true }); pointer(f.cwd, modules); + } + // A project directory whose own .git is a file has no worktree registry of its own. + if (kind === 'project-is-itself-a-worktree') { fs.writeFileSync(path.join(root, '.git'), 'gitdir: elsewhere\n'); pointer(f.cwd, path.join(root, '.git', 'worktrees', 'session')); } + if (kind === 'bare-repository') { + const entry = path.join(root, 'worktrees', 'session'); fs.mkdirSync(entry, { recursive: true }); + fs.writeFileSync(path.join(entry, 'gitdir'), posix(path.join(f.cwd, '.git')) + '\n'); pointer(f.cwd, entry); + } + const output: any = await run(f, root); + expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); + expect(output.hookSpecificOutput.permissionDecisionReason).toContain('code foreign_cwd'); + }); +}); diff --git a/test/autoplan-publication-hook.test.ts b/test/autoplan-publication-hook.test.ts index 73e4fb51a..a5cb6f8eb 100644 --- a/test/autoplan-publication-hook.test.ts +++ b/test/autoplan-publication-hook.test.ts @@ -3,6 +3,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { tmpdir } from 'node:os'; import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { runPublicationHook } from '../autoplan/bin/phase-publication-hook.ts'; const ROOT = path.join(import.meta.dir, '..'); const SHIM = path.join(ROOT, 'autoplan/bin/phase-publication-hook'); @@ -43,3 +45,130 @@ describe('Autoplan hook transport', () => { const { output } = run(JSON.stringify({ ...input, agent_id: 'reviewer-child' })); expect(output).toEqual({}); }); }); + +describe('Autoplan hook journal-root verdicts', () => { + const entry = path.join(ROOT, 'autoplan/sections/design-phase.md'); + const real = JSON.parse(fs.readFileSync(path.join(ROOT, 'test/fixtures/claude-native-journal-roots-2.1.284.json'), 'utf8')); + /** One owned native journal; the current phase-entry Read is its last record unless `current` is false. */ + function session(shape: 'unknown' | 'competing_root' | 'foreign_cwd' | 'sidechain' | 'agent' | 'cycle' | 'startup', + { current = true, partial = false } = {}) { + const base = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-hook-roots-'))); dirs.push(base); + const cwd = path.join(base, 'project'), project = path.join(base, 'config', 'projects', 'project'); + fs.mkdirSync(cwd); fs.mkdirSync(project, { recursive: true }); + const home = path.join(base, 'gstack-home'); + const sessionId = 'parent-session', file = path.join(project, `${sessionId}.jsonl`); + const node = (parentUuid: string | null, extra: object) => ({ uuid: randomUUID(), parentUuid, cwd, sessionId, + isSidechain: false, version: '2.1.284', timestamp: '2026-10-02T14:00:00.000Z', ...extra }); + const hook = (parentUuid: string | null) => node(parentUuid, { type: 'attachment', + attachment: { type: 'hook_success', hookEvent: 'SessionStart', hookName: 'SessionStart:startup' } }); + const prompt = (parentUuid: string | null) => node(parentUuid, { type: 'user', + message: { role: 'user', content: 'Secret plan text.' }, origin: { kind: 'human' }, promptSource: 'typed', promptId: randomUUID() }); + let rows: object[]; + if (shape === 'startup') { + rows = real.journals.startup.lines.map((line: string) => JSON.parse(line.replaceAll('__CWD__', JSON.stringify(cwd).slice(1, -1)))); + for (const row of rows as any[]) if (row.sessionId) row.sessionId = sessionId; + } else { + const head = shape === 'unknown' ? node(null, { type: 'system', subtype: 'local_command' }) : hook(null); + const turn = prompt(head.uuid); + rows = [head, turn]; + if (shape === 'competing_root') rows.push(prompt(null)); + if (shape === 'foreign_cwd') Object.assign(head, { cwd: path.join(base, 'config') }); + if (shape === 'sidechain') Object.assign(head, { isSidechain: true }); + if (shape === 'agent') Object.assign(head, { agentId: 'agent-1' }); + if (shape === 'cycle') Object.assign(head, { parentUuid: turn.uuid }); + } + const last = [...rows].reverse().find((r: any) => r.uuid) as { uuid: string }; + if (current) rows.push(node(last.uuid, { type: 'assistant', message: { role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_current', name: 'Read', input: { file_path: entry } }] } })); + fs.writeFileSync(file, rows.map(r => JSON.stringify(r)).join('\n') + '\n' + (partial ? '{"type":"assistant"' : '')); + const value = { hook_event_name: 'PreToolUse', session_id: sessionId, cwd, transcript_path: file, + tool_name: 'Read', tool_use_id: 'toolu_current', tool_input: { file_path: entry } }; + const env = { CLAUDE_PROJECT_DIR: cwd, GSTACK_HOME: home }; + const log = path.join(home, 'analytics', 'autoplan-guard.jsonl'); + async function hookRun(overrides: Record = {}): Promise { + const previous = Object.fromEntries(Object.keys({ ...env, ...overrides }).map(k => [k, process.env[k]])); + Object.assign(process.env, env, overrides); + try { return await runPublicationHook(value, ROOT); } + finally { for (const [k, v] of Object.entries(previous)) if (v === undefined) delete process.env[k]; else process.env[k] = v; } + } + return { cwd, file, home, log, value, env, hookRun }; + } + + test('an unrecognized root shape degrades to an advisory: no permission decision, user and model notices, a content-free log', async () => { + const s = session('unknown'); + const output = await s.hookRun(); + expect(output.hookSpecificOutput.permissionDecision).toBeUndefined(); + expect(output.hookSpecificOutput.hookEventName).toBe('PreToolUse'); + expect(output.systemMessage).toContain('phase publication was NOT verified for this session (journal shape unrecognized_shape:preamble:system:local_command)'); + expect(output.systemMessage).toContain('Claude Code 2.1.284'); + expect(output.systemMessage).toContain('docs/autoplan-guard-troubleshooting.md'); + expect(output.hookSpecificOutput.additionalContext).toContain('NOT verified'); + const records = fs.readFileSync(s.log, 'utf8').trim().split('\n').map(line => JSON.parse(line)); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ event: 'unverified_phase_entry', reason: 'unrecognized_shape:preamble:system:local_command', + claude_code_version: '2.1.284', record_types: ['system:local_command', 'user'] }); + expect(fs.readFileSync(s.log, 'utf8')).not.toContain('Secret plan text'); + }); + + test('a logging failure never changes the advisory verdict', async () => { + const s = session('unknown'); + fs.writeFileSync(s.home, 'not a directory'); + const output = await s.hookRun(); + expect(output.hookSpecificOutput.permissionDecision).toBeUndefined(); + expect(output.systemMessage).toContain('NOT verified'); + }); + + for (const [label, options] of [['the current tool_use is missing', { current: false }], + ['a record is still being written', { partial: true }]] as const) + test(`no advisory when ${label}: deny and retry`, async () => { + const s = session('unknown', options); + 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('code unrecognized_shape:preamble:system:local_command'); + expect(fs.existsSync(s.log)).toBe(false); + }); + + for (const code of ['competing_root', 'foreign_cwd', 'sidechain', 'agent', 'cycle'] as const) + test(`${code} is a hard denial naming the code, version, fallback and guide`, async () => { + const s = session(code); + const output = await s.hookRun(); + expect(output.hookSpecificOutput.permissionDecision).toBe('deny'); + const reason: string = output.hookSpecificOutput.permissionDecisionReason; + expect(reason).toContain(`code ${code}`); + expect(reason).toContain('Claude Code 2.1.284'); + expect(reason).toContain('/plan-ceo-review, then /plan-devex-review, then /plan-eng-review'); + expect(reason).toContain('docs/autoplan-guard-troubleshooting.md'); + expect(reason).not.toContain('no missing-publication conclusion'); + expect(fs.existsSync(s.log)).toBe(false); + }); + + test('a real 2.1.284 SessionStart journal is owned and reaches publication evaluation', async () => { + const s = session('startup'); + const output = await s.hookRun(); + expect(output.hookSpecificOutput.permissionDecision).toBe('deny'); + // Ownership succeeded: the denial is the evaluator's own invocation check. + expect(output.hookSpecificOutput.permissionDecisionReason).toContain('Autoplan invocation evidence is unavailable'); + }); + + test('the shipped shim degrades and denies with real native spellings', () => { + // On Windows the hook env and transcript arrive spelled C:/...; the fold must accept them. + const forward = (value: string) => process.platform === 'win32' ? value.replaceAll('\\', '/') : value; + const advisory = session('unknown'); + const soft = run(JSON.stringify({ ...advisory.value, transcript_path: forward(advisory.file) }), SHIM, + { ...process.env, ...advisory.env, CLAUDE_PROJECT_DIR: forward(advisory.cwd) }).output; + expect(soft.hookSpecificOutput.permissionDecision).toBeUndefined(); + expect(soft.systemMessage).toContain('NOT verified'); + const hard = session('competing_root'); + const denied = run(JSON.stringify(hard.value), SHIM, { ...process.env, ...hard.env }).output; + expect(denied.hookSpecificOutput.permissionDecision).toBe('deny'); + expect(denied.hookSpecificOutput.permissionDecisionReason).toContain('code competing_root'); + }); + + test('the guide documents every guard code', () => { + const guide = fs.readFileSync(path.join(ROOT, 'docs/autoplan-guard-troubleshooting.md'), 'utf8'); + for (const code of ['competing_root', 'foreign_cwd', 'sidechain', 'agent', 'cycle', 'changing', 'identity', 'malformed', + 'unrecognized_shape']) expect(guide).toContain(`\`${code}`); + for (const detail of ['unrecognized_shape:cwd_spelling', 'analytics/autoplan-guard.jsonl']) expect(guide).toContain(detail); + }); +}); diff --git a/test/fixtures/claude-native-journal-roots-2.1.284.json b/test/fixtures/claude-native-journal-roots-2.1.284.json new file mode 100644 index 000000000..7032e208d --- /dev/null +++ b/test/fixtures/claude-native-journal-roots-2.1.284.json @@ -0,0 +1,219 @@ +{ + "claudeCodeVersion": "2.1.284", + "journals": { + "startup": { + "sessionId": "3dea0a29-560c-4987-8665-38025df2a023", + "lines": [ + "{\"type\":\"last-prompt\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"mode\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"permission-mode\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"atis-latch\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"attachment\",\"uuid\":\"6e7eabb9-6c77-4cf8-a4c6-b9971edaf8df\",\"parentUuid\":null,\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:33.280Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"hook_success\",\"hookEvent\":\"SessionStart\",\"hookName\":\"SessionStart:startup\"}}", + "{\"type\":\"attachment\",\"uuid\":\"e6acf2a4-1410-48ac-a18e-9757bc6cbe6b\",\"parentUuid\":\"6e7eabb9-6c77-4cf8-a4c6-b9971edaf8df\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:33.284Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"hook_success\",\"hookEvent\":\"SessionStart\",\"hookName\":\"SessionStart:startup\"}}", + "{\"type\":\"attachment\",\"uuid\":\"45199483-60e8-4e3d-b3e3-f9f7d805bee5\",\"parentUuid\":\"e6acf2a4-1410-48ac-a18e-9757bc6cbe6b\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:33.285Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"hook_additional_context\",\"hookEvent\":\"SessionStart\",\"hookName\":\"SessionStart\"}}", + "{\"type\":\"file-history-snapshot\"}", + "{\"type\":\"user\",\"uuid\":\"e1c08f94-262e-4ccb-953d-36f23beaec32\",\"parentUuid\":\"45199483-60e8-4e3d-b3e3-f9f7d805bee5\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:41.925Z\",\"version\":\"2.1.284\",\"promptId\":\"549e2578-4cd2-4516-a1a0-7a2fa6e20034\",\"promptSource\":\"typed\",\"origin\":{\"kind\":\"human\"},\"cwd\":\"__CWD__\",\"message\":{\"role\":\"user\",\"content\":\"[redacted]\"}}", + "{\"type\":\"attachment\",\"uuid\":\"9688635f-c149-4855-a0f8-c555bf17b9e8\",\"parentUuid\":\"e1c08f94-262e-4ccb-953d-36f23beaec32\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:41.923Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"environment\"}}", + "{\"type\":\"attachment\",\"uuid\":\"edf03123-3903-428c-bdc9-368090d016f3\",\"parentUuid\":\"9688635f-c149-4855-a0f8-c555bf17b9e8\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:41.923Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"model\"}}", + "{\"type\":\"attachment\",\"uuid\":\"77b09d47-848c-48e1-ac26-250a4eee238d\",\"parentUuid\":\"edf03123-3903-428c-bdc9-368090d016f3\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:41.923Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"deferred_tools_delta\"}}", + "{\"type\":\"attachment\",\"uuid\":\"b90b90e4-ce3c-46c5-9abc-bc27c9eb48ae\",\"parentUuid\":\"77b09d47-848c-48e1-ac26-250a4eee238d\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:41.924Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"agent_listing_delta\"}}", + "{\"type\":\"attachment\",\"uuid\":\"7ccc84ef-d2de-451e-944a-503849963bdd\",\"parentUuid\":\"b90b90e4-ce3c-46c5-9abc-bc27c9eb48ae\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:41.924Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"skill_listing\"}}", + "{\"type\":\"attachment\",\"uuid\":\"8afc88e7-cac7-45b2-8453-3fe57ac6a564\",\"parentUuid\":\"7ccc84ef-d2de-451e-944a-503849963bdd\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:41.924Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"total_tokens_reminder\"}}", + "{\"type\":\"attachment\",\"uuid\":\"a7e658b6-ee67-4780-96cb-ef1a510203ab\",\"parentUuid\":\"8afc88e7-cac7-45b2-8453-3fe57ac6a564\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:41.950Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"session_context\"}}", + "{\"type\":\"attachment\",\"uuid\":\"1770833f-d8aa-4d8d-ac41-6eb8de528d6f\",\"parentUuid\":\"a7e658b6-ee67-4780-96cb-ef1a510203ab\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:41.951Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"date\"}}", + "{\"type\":\"attachment\",\"uuid\":\"0df8a6d4-00cf-497b-8c3f-9631460a0ef4\",\"parentUuid\":\"1770833f-d8aa-4d8d-ac41-6eb8de528d6f\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:41.953Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"remote_session_change\"}}", + "{\"type\":\"attachment\",\"uuid\":\"eb7ad3bc-1553-4164-8026-769e2e2652fd\",\"parentUuid\":\"0df8a6d4-00cf-497b-8c3f-9631460a0ef4\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:41.954Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"prompt_snapshot\"}}", + "{\"type\":\"last-prompt\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"mode\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"permission-mode\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"atis-latch\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"ai-title\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"assistant\",\"uuid\":\"f5f8c547-c662-4635-a36e-37bc7ae9cc5d\",\"parentUuid\":\"eb7ad3bc-1553-4164-8026-769e2e2652fd\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:42.838Z\",\"version\":\"2.1.284\",\"requestId\":\"req_011CfdYdzSB9aJ3uziW4wEGk\",\"cwd\":\"__CWD__\",\"message\":{\"role\":\"assistant\",\"id\":\"msg_011CfdYdzhHoKS8Jgv7jwTs4\",\"stop_reason\":\"end_turn\",\"content\":[{\"type\":\"thinking\"}]}}", + "{\"type\":\"assistant\",\"uuid\":\"f5089f67-39f9-40c9-b451-edad5400a22f\",\"parentUuid\":\"f5f8c547-c662-4635-a36e-37bc7ae9cc5d\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:42.849Z\",\"version\":\"2.1.284\",\"requestId\":\"req_011CfdYdzSB9aJ3uziW4wEGk\",\"cwd\":\"__CWD__\",\"message\":{\"role\":\"assistant\",\"id\":\"msg_011CfdYdzhHoKS8Jgv7jwTs4\",\"stop_reason\":\"end_turn\",\"content\":[{\"type\":\"text\",\"text\":\"[redacted]\"}]}}", + "{\"type\":\"attachment\",\"uuid\":\"a5edfe87-8650-4b2b-aa92-41985e867735\",\"parentUuid\":\"f5089f67-39f9-40c9-b451-edad5400a22f\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:42.889Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"prompt_snapshot\"}}", + "{\"type\":\"system\",\"subtype\":\"turn_duration\",\"uuid\":\"40ad045f-eed1-4896-9190-4c6713889c8a\",\"parentUuid\":\"a5edfe87-8650-4b2b-aa92-41985e867735\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:42.902Z\",\"version\":\"2.1.284\",\"isMeta\":false,\"cwd\":\"__CWD__\"}", + "{\"type\":\"last-prompt\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"ai-title\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"mode\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"permission-mode\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"atis-latch\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"file-history-snapshot\"}", + "{\"type\":\"cost-state\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"cost-state\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}" + ] + }, + "compact": { + "sessionId": "3dea0a29-560c-4987-8665-38025df2a023", + "lines": [ + "{\"type\":\"last-prompt\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"mode\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"permission-mode\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"atis-latch\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"attachment\",\"uuid\":\"6e7eabb9-6c77-4cf8-a4c6-b9971edaf8df\",\"parentUuid\":null,\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:33.280Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"hook_success\",\"hookEvent\":\"SessionStart\",\"hookName\":\"SessionStart:startup\"}}", + "{\"type\":\"attachment\",\"uuid\":\"e6acf2a4-1410-48ac-a18e-9757bc6cbe6b\",\"parentUuid\":\"6e7eabb9-6c77-4cf8-a4c6-b9971edaf8df\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:33.284Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"hook_success\",\"hookEvent\":\"SessionStart\",\"hookName\":\"SessionStart:startup\"}}", + "{\"type\":\"attachment\",\"uuid\":\"45199483-60e8-4e3d-b3e3-f9f7d805bee5\",\"parentUuid\":\"e6acf2a4-1410-48ac-a18e-9757bc6cbe6b\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:33.285Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"hook_additional_context\",\"hookEvent\":\"SessionStart\",\"hookName\":\"SessionStart\"}}", + "{\"type\":\"file-history-snapshot\"}", + "{\"type\":\"user\",\"uuid\":\"e1c08f94-262e-4ccb-953d-36f23beaec32\",\"parentUuid\":\"45199483-60e8-4e3d-b3e3-f9f7d805bee5\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:41.925Z\",\"version\":\"2.1.284\",\"promptId\":\"549e2578-4cd2-4516-a1a0-7a2fa6e20034\",\"promptSource\":\"typed\",\"origin\":{\"kind\":\"human\"},\"cwd\":\"__CWD__\",\"message\":{\"role\":\"user\",\"content\":\"[redacted]\"}}", + "{\"type\":\"attachment\",\"uuid\":\"9688635f-c149-4855-a0f8-c555bf17b9e8\",\"parentUuid\":\"e1c08f94-262e-4ccb-953d-36f23beaec32\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:41.923Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"environment\"}}", + "{\"type\":\"attachment\",\"uuid\":\"edf03123-3903-428c-bdc9-368090d016f3\",\"parentUuid\":\"9688635f-c149-4855-a0f8-c555bf17b9e8\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:41.923Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"model\"}}", + "{\"type\":\"attachment\",\"uuid\":\"77b09d47-848c-48e1-ac26-250a4eee238d\",\"parentUuid\":\"edf03123-3903-428c-bdc9-368090d016f3\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:41.923Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"deferred_tools_delta\"}}", + "{\"type\":\"attachment\",\"uuid\":\"b90b90e4-ce3c-46c5-9abc-bc27c9eb48ae\",\"parentUuid\":\"77b09d47-848c-48e1-ac26-250a4eee238d\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:41.924Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"agent_listing_delta\"}}", + "{\"type\":\"attachment\",\"uuid\":\"7ccc84ef-d2de-451e-944a-503849963bdd\",\"parentUuid\":\"b90b90e4-ce3c-46c5-9abc-bc27c9eb48ae\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:41.924Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"skill_listing\"}}", + "{\"type\":\"attachment\",\"uuid\":\"8afc88e7-cac7-45b2-8453-3fe57ac6a564\",\"parentUuid\":\"7ccc84ef-d2de-451e-944a-503849963bdd\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:41.924Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"total_tokens_reminder\"}}", + "{\"type\":\"attachment\",\"uuid\":\"a7e658b6-ee67-4780-96cb-ef1a510203ab\",\"parentUuid\":\"8afc88e7-cac7-45b2-8453-3fe57ac6a564\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:41.950Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"session_context\"}}", + "{\"type\":\"attachment\",\"uuid\":\"1770833f-d8aa-4d8d-ac41-6eb8de528d6f\",\"parentUuid\":\"a7e658b6-ee67-4780-96cb-ef1a510203ab\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:41.951Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"date\"}}", + "{\"type\":\"attachment\",\"uuid\":\"0df8a6d4-00cf-497b-8c3f-9631460a0ef4\",\"parentUuid\":\"1770833f-d8aa-4d8d-ac41-6eb8de528d6f\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:41.953Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"remote_session_change\"}}", + "{\"type\":\"attachment\",\"uuid\":\"eb7ad3bc-1553-4164-8026-769e2e2652fd\",\"parentUuid\":\"0df8a6d4-00cf-497b-8c3f-9631460a0ef4\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:41.954Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"prompt_snapshot\"}}", + "{\"type\":\"last-prompt\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"mode\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"permission-mode\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"atis-latch\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"ai-title\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"assistant\",\"uuid\":\"f5f8c547-c662-4635-a36e-37bc7ae9cc5d\",\"parentUuid\":\"eb7ad3bc-1553-4164-8026-769e2e2652fd\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:42.838Z\",\"version\":\"2.1.284\",\"requestId\":\"req_011CfdYdzSB9aJ3uziW4wEGk\",\"cwd\":\"__CWD__\",\"message\":{\"role\":\"assistant\",\"id\":\"msg_011CfdYdzhHoKS8Jgv7jwTs4\",\"stop_reason\":\"end_turn\",\"content\":[{\"type\":\"thinking\"}]}}", + "{\"type\":\"assistant\",\"uuid\":\"f5089f67-39f9-40c9-b451-edad5400a22f\",\"parentUuid\":\"f5f8c547-c662-4635-a36e-37bc7ae9cc5d\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:42.849Z\",\"version\":\"2.1.284\",\"requestId\":\"req_011CfdYdzSB9aJ3uziW4wEGk\",\"cwd\":\"__CWD__\",\"message\":{\"role\":\"assistant\",\"id\":\"msg_011CfdYdzhHoKS8Jgv7jwTs4\",\"stop_reason\":\"end_turn\",\"content\":[{\"type\":\"text\",\"text\":\"[redacted]\"}]}}", + "{\"type\":\"attachment\",\"uuid\":\"a5edfe87-8650-4b2b-aa92-41985e867735\",\"parentUuid\":\"f5089f67-39f9-40c9-b451-edad5400a22f\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:42.889Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"prompt_snapshot\"}}", + "{\"type\":\"system\",\"subtype\":\"turn_duration\",\"uuid\":\"40ad045f-eed1-4896-9190-4c6713889c8a\",\"parentUuid\":\"a5edfe87-8650-4b2b-aa92-41985e867735\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:11:42.902Z\",\"version\":\"2.1.284\",\"isMeta\":false,\"cwd\":\"__CWD__\"}", + "{\"type\":\"last-prompt\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"ai-title\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"mode\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"permission-mode\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"atis-latch\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"file-history-snapshot\"}", + "{\"type\":\"cost-state\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"cost-state\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"file-history-snapshot\"}", + "{\"type\":\"user\",\"uuid\":\"a6be8ff7-d4f8-4e99-a751-e6616443b4d3\",\"parentUuid\":\"40ad045f-eed1-4896-9190-4c6713889c8a\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:12:24.747Z\",\"version\":\"2.1.284\",\"promptId\":\"cdad6d00-35d8-4e9e-a867-28d16053b17a\",\"cwd\":\"__CWD__\",\"message\":{\"role\":\"user\",\"content\":\"[redacted]\"}}", + "{\"type\":\"last-prompt\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"ai-title\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"mode\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"permission-mode\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"atis-latch\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"system\",\"subtype\":\"compact_boundary\",\"uuid\":\"b142b71b-fd05-4e24-9650-a392fa80a9ce\",\"parentUuid\":null,\"logicalParentUuid\":\"40ad045f-eed1-4896-9190-4c6713889c8a\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:12:34.639Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\"}", + "{\"type\":\"user\",\"uuid\":\"94a1fe61-95d8-4d3a-80f4-32bd9cf5e37d\",\"parentUuid\":\"b142b71b-fd05-4e24-9650-a392fa80a9ce\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:12:34.619Z\",\"version\":\"2.1.284\",\"isCompactSummary\":true,\"promptId\":\"cdad6d00-35d8-4e9e-a867-28d16053b17a\",\"cwd\":\"__CWD__\",\"message\":{\"role\":\"user\",\"content\":\"[redacted]\"}}", + "{\"type\":\"user\",\"uuid\":\"eeaf3b82-59d1-4c03-9183-f0a352d3bf98\",\"parentUuid\":\"94a1fe61-95d8-4d3a-80f4-32bd9cf5e37d\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:12:24.768Z\",\"version\":\"2.1.284\",\"isMeta\":true,\"promptId\":\"cdad6d00-35d8-4e9e-a867-28d16053b17a\",\"cwd\":\"__CWD__\",\"message\":{\"role\":\"user\",\"content\":\"[redacted]\"}}", + "{\"type\":\"user\",\"uuid\":\"05a99a8e-4158-437b-ad14-3e1d20689800\",\"parentUuid\":\"eeaf3b82-59d1-4c03-9183-f0a352d3bf98\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:12:24.768Z\",\"version\":\"2.1.284\",\"promptId\":\"cdad6d00-35d8-4e9e-a867-28d16053b17a\",\"cwd\":\"__CWD__\",\"message\":{\"role\":\"user\",\"content\":\"[redacted]\"}}", + "{\"type\":\"user\",\"uuid\":\"cd43d700-f7df-4b04-b74c-630b89494e2c\",\"parentUuid\":\"05a99a8e-4158-437b-ad14-3e1d20689800\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:12:34.739Z\",\"version\":\"2.1.284\",\"promptId\":\"cdad6d00-35d8-4e9e-a867-28d16053b17a\",\"cwd\":\"__CWD__\",\"message\":{\"role\":\"user\",\"content\":\"[redacted]\"}}", + "{\"type\":\"attachment\",\"uuid\":\"5368b903-2e1a-457b-bb48-ae6d87fa38eb\",\"parentUuid\":\"cd43d700-f7df-4b04-b74c-630b89494e2c\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:12:34.627Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"deferred_tools_delta\"}}", + "{\"type\":\"attachment\",\"uuid\":\"be2b64bf-3c38-4b09-afe5-00f823e6a64e\",\"parentUuid\":\"5368b903-2e1a-457b-bb48-ae6d87fa38eb\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:12:34.627Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"agent_listing_delta\"}}", + "{\"type\":\"attachment\",\"uuid\":\"5d847d8b-a629-4d61-b3a4-7673b65e8f98\",\"parentUuid\":\"be2b64bf-3c38-4b09-afe5-00f823e6a64e\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:12:34.627Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"environment\"}}", + "{\"type\":\"attachment\",\"uuid\":\"b9420cf2-b399-4b72-88f0-e33ecf5c9a6e\",\"parentUuid\":\"5d847d8b-a629-4d61-b3a4-7673b65e8f98\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:12:34.627Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"model\"}}", + "{\"type\":\"attachment\",\"uuid\":\"54db946e-07aa-4df9-a208-c012ff1ed12d\",\"parentUuid\":\"b9420cf2-b399-4b72-88f0-e33ecf5c9a6e\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:12:34.633Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"hook_success\",\"hookEvent\":\"SessionStart\",\"hookName\":\"SessionStart:compact\"}}", + "{\"type\":\"attachment\",\"uuid\":\"3f00aaf6-c1c9-4136-870a-b22458f9cee7\",\"parentUuid\":\"54db946e-07aa-4df9-a208-c012ff1ed12d\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:12:34.635Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"hook_success\",\"hookEvent\":\"SessionStart\",\"hookName\":\"SessionStart:compact\"}}", + "{\"type\":\"attachment\",\"uuid\":\"fca832e6-f148-45bb-839f-c9a66c6158f2\",\"parentUuid\":\"3f00aaf6-c1c9-4136-870a-b22458f9cee7\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:12:34.635Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"hook_additional_context\",\"hookEvent\":\"SessionStart\",\"hookName\":\"SessionStart\"}}", + "{\"type\":\"file-history-snapshot\"}", + "{\"type\":\"user\",\"uuid\":\"6a4972f9-2e7e-4af6-a777-3d9241e7d201\",\"parentUuid\":\"fca832e6-f148-45bb-839f-c9a66c6158f2\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:13:05.754Z\",\"version\":\"2.1.284\",\"promptId\":\"b1fc5ea5-48cd-4db1-8183-edf2ed7a5041\",\"promptSource\":\"typed\",\"origin\":{\"kind\":\"human\"},\"cwd\":\"__CWD__\",\"message\":{\"role\":\"user\",\"content\":\"[redacted]\"}}", + "{\"type\":\"attachment\",\"uuid\":\"e5070b7b-3bc8-41aa-97e3-42d5a31be290\",\"parentUuid\":\"6a4972f9-2e7e-4af6-a777-3d9241e7d201\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:13:05.754Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"total_tokens_reminder\"}}", + "{\"type\":\"attachment\",\"uuid\":\"0add9551-e7ac-4a07-ab92-3cfa71ed97aa\",\"parentUuid\":\"e5070b7b-3bc8-41aa-97e3-42d5a31be290\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:13:05.784Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"session_context\"}}", + "{\"type\":\"attachment\",\"uuid\":\"23bce9b5-f0fa-4a66-af52-2e1a41f83b48\",\"parentUuid\":\"0add9551-e7ac-4a07-ab92-3cfa71ed97aa\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:13:05.784Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"date\"}}", + "{\"type\":\"attachment\",\"uuid\":\"a4020455-0b65-4963-9e77-4c12bf2b6bf9\",\"parentUuid\":\"23bce9b5-f0fa-4a66-af52-2e1a41f83b48\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:13:05.787Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"remote_session_change\"}}", + "{\"type\":\"attachment\",\"uuid\":\"88e96ecc-e0de-46c3-87b8-33b68f55c1d7\",\"parentUuid\":\"a4020455-0b65-4963-9e77-4c12bf2b6bf9\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:13:05.788Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"prompt_snapshot\"}}", + "{\"type\":\"last-prompt\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"ai-title\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"mode\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"permission-mode\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"atis-latch\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"assistant\",\"uuid\":\"893221f6-ddcb-4375-8dff-1793a641c26c\",\"parentUuid\":\"88e96ecc-e0de-46c3-87b8-33b68f55c1d7\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:13:07.363Z\",\"version\":\"2.1.284\",\"requestId\":\"req_011CfdYkAwrt61tSr3EWMnPy\",\"cwd\":\"__CWD__\",\"message\":{\"role\":\"assistant\",\"id\":\"msg_011CfdYkBBjuc7wyrpaUK9oo\",\"stop_reason\":\"end_turn\",\"content\":[{\"type\":\"thinking\"}]}}", + "{\"type\":\"assistant\",\"uuid\":\"4da4a025-96d8-4515-885c-b697181827c7\",\"parentUuid\":\"893221f6-ddcb-4375-8dff-1793a641c26c\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:13:07.365Z\",\"version\":\"2.1.284\",\"requestId\":\"req_011CfdYkAwrt61tSr3EWMnPy\",\"cwd\":\"__CWD__\",\"message\":{\"role\":\"assistant\",\"id\":\"msg_011CfdYkBBjuc7wyrpaUK9oo\",\"stop_reason\":\"end_turn\",\"content\":[{\"type\":\"text\",\"text\":\"[redacted]\"}]}}", + "{\"type\":\"attachment\",\"uuid\":\"e6a50c50-d9fa-4099-ab25-18224db7e563\",\"parentUuid\":\"4da4a025-96d8-4515-885c-b697181827c7\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:13:07.408Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"prompt_snapshot\"}}", + "{\"type\":\"system\",\"subtype\":\"turn_duration\",\"uuid\":\"a95fb9e0-916f-4ae5-811a-9dbf1561beaf\",\"parentUuid\":\"e6a50c50-d9fa-4099-ab25-18224db7e563\",\"isSidechain\":false,\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\",\"timestamp\":\"2026-10-02T14:13:07.420Z\",\"version\":\"2.1.284\",\"isMeta\":false,\"cwd\":\"__CWD__\"}", + "{\"type\":\"last-prompt\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"ai-title\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"mode\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"permission-mode\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"atis-latch\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"file-history-snapshot\"}", + "{\"type\":\"cost-state\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}", + "{\"type\":\"cost-state\",\"sessionId\":\"3dea0a29-560c-4987-8665-38025df2a023\"}" + ] + }, + "forkAfterCompact": { + "sessionId": "6a87cfeb-1a6f-4747-9883-a295c57fad68", + "lines": [ + "{\"type\":\"ai-title\",\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\"}", + "{\"type\":\"mode\",\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\"}", + "{\"type\":\"permission-mode\",\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\"}", + "{\"type\":\"atis-latch\",\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\"}", + "{\"type\":\"file-history-snapshot\"}", + "{\"type\":\"file-history-snapshot\"}", + "{\"type\":\"system\",\"subtype\":\"compact_boundary\",\"uuid\":\"b142b71b-fd05-4e24-9650-a392fa80a9ce\",\"parentUuid\":null,\"logicalParentUuid\":\"40ad045f-eed1-4896-9190-4c6713889c8a\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:12:34.639Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\"}", + "{\"type\":\"user\",\"uuid\":\"94a1fe61-95d8-4d3a-80f4-32bd9cf5e37d\",\"parentUuid\":\"b142b71b-fd05-4e24-9650-a392fa80a9ce\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:12:34.619Z\",\"version\":\"2.1.284\",\"isCompactSummary\":true,\"promptId\":\"5b4b0a2b-d62a-4db4-8776-2ab7176fe41d\",\"cwd\":\"__CWD__\",\"message\":{\"role\":\"user\",\"content\":\"[redacted]\"}}", + "{\"type\":\"user\",\"uuid\":\"eeaf3b82-59d1-4c03-9183-f0a352d3bf98\",\"parentUuid\":\"94a1fe61-95d8-4d3a-80f4-32bd9cf5e37d\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:12:24.768Z\",\"version\":\"2.1.284\",\"isMeta\":true,\"promptId\":\"5b4b0a2b-d62a-4db4-8776-2ab7176fe41d\",\"cwd\":\"__CWD__\",\"message\":{\"role\":\"user\",\"content\":\"[redacted]\"}}", + "{\"type\":\"user\",\"uuid\":\"05a99a8e-4158-437b-ad14-3e1d20689800\",\"parentUuid\":\"eeaf3b82-59d1-4c03-9183-f0a352d3bf98\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:12:24.768Z\",\"version\":\"2.1.284\",\"promptId\":\"5b4b0a2b-d62a-4db4-8776-2ab7176fe41d\",\"cwd\":\"__CWD__\",\"message\":{\"role\":\"user\",\"content\":\"[redacted]\"}}", + "{\"type\":\"user\",\"uuid\":\"cd43d700-f7df-4b04-b74c-630b89494e2c\",\"parentUuid\":\"05a99a8e-4158-437b-ad14-3e1d20689800\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:12:34.739Z\",\"version\":\"2.1.284\",\"promptId\":\"5b4b0a2b-d62a-4db4-8776-2ab7176fe41d\",\"cwd\":\"__CWD__\",\"message\":{\"role\":\"user\",\"content\":\"[redacted]\"}}", + "{\"type\":\"attachment\",\"uuid\":\"5368b903-2e1a-457b-bb48-ae6d87fa38eb\",\"parentUuid\":\"cd43d700-f7df-4b04-b74c-630b89494e2c\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:12:34.627Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"deferred_tools_delta\"}}", + "{\"type\":\"attachment\",\"uuid\":\"be2b64bf-3c38-4b09-afe5-00f823e6a64e\",\"parentUuid\":\"5368b903-2e1a-457b-bb48-ae6d87fa38eb\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:12:34.627Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"agent_listing_delta\"}}", + "{\"type\":\"attachment\",\"uuid\":\"5d847d8b-a629-4d61-b3a4-7673b65e8f98\",\"parentUuid\":\"be2b64bf-3c38-4b09-afe5-00f823e6a64e\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:12:34.627Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"environment\"}}", + "{\"type\":\"attachment\",\"uuid\":\"b9420cf2-b399-4b72-88f0-e33ecf5c9a6e\",\"parentUuid\":\"5d847d8b-a629-4d61-b3a4-7673b65e8f98\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:12:34.627Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"model\"}}", + "{\"type\":\"attachment\",\"uuid\":\"54db946e-07aa-4df9-a208-c012ff1ed12d\",\"parentUuid\":\"b9420cf2-b399-4b72-88f0-e33ecf5c9a6e\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:12:34.633Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"hook_success\",\"hookEvent\":\"SessionStart\",\"hookName\":\"SessionStart:compact\"}}", + "{\"type\":\"attachment\",\"uuid\":\"3f00aaf6-c1c9-4136-870a-b22458f9cee7\",\"parentUuid\":\"54db946e-07aa-4df9-a208-c012ff1ed12d\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:12:34.635Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"hook_success\",\"hookEvent\":\"SessionStart\",\"hookName\":\"SessionStart:compact\"}}", + "{\"type\":\"attachment\",\"uuid\":\"fca832e6-f148-45bb-839f-c9a66c6158f2\",\"parentUuid\":\"3f00aaf6-c1c9-4136-870a-b22458f9cee7\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:12:34.635Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"hook_additional_context\",\"hookEvent\":\"SessionStart\",\"hookName\":\"SessionStart\"}}", + "{\"type\":\"user\",\"uuid\":\"6a4972f9-2e7e-4af6-a777-3d9241e7d201\",\"parentUuid\":\"fca832e6-f148-45bb-839f-c9a66c6158f2\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:13:05.754Z\",\"version\":\"2.1.284\",\"promptId\":\"5b4b0a2b-d62a-4db4-8776-2ab7176fe41d\",\"promptSource\":\"typed\",\"origin\":{\"kind\":\"human\"},\"cwd\":\"__CWD__\",\"message\":{\"role\":\"user\",\"content\":\"[redacted]\"}}", + "{\"type\":\"attachment\",\"uuid\":\"e5070b7b-3bc8-41aa-97e3-42d5a31be290\",\"parentUuid\":\"6a4972f9-2e7e-4af6-a777-3d9241e7d201\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:13:05.754Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"total_tokens_reminder\"}}", + "{\"type\":\"attachment\",\"uuid\":\"0add9551-e7ac-4a07-ab92-3cfa71ed97aa\",\"parentUuid\":\"e5070b7b-3bc8-41aa-97e3-42d5a31be290\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:13:05.784Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"session_context\"}}", + "{\"type\":\"attachment\",\"uuid\":\"23bce9b5-f0fa-4a66-af52-2e1a41f83b48\",\"parentUuid\":\"0add9551-e7ac-4a07-ab92-3cfa71ed97aa\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:13:05.784Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"date\"}}", + "{\"type\":\"attachment\",\"uuid\":\"a4020455-0b65-4963-9e77-4c12bf2b6bf9\",\"parentUuid\":\"23bce9b5-f0fa-4a66-af52-2e1a41f83b48\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:13:05.787Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"remote_session_change\"}}", + "{\"type\":\"attachment\",\"uuid\":\"88e96ecc-e0de-46c3-87b8-33b68f55c1d7\",\"parentUuid\":\"a4020455-0b65-4963-9e77-4c12bf2b6bf9\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:13:05.788Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"prompt_snapshot\"}}", + "{\"type\":\"assistant\",\"uuid\":\"893221f6-ddcb-4375-8dff-1793a641c26c\",\"parentUuid\":\"88e96ecc-e0de-46c3-87b8-33b68f55c1d7\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:13:07.363Z\",\"version\":\"2.1.284\",\"requestId\":\"req_011CfdYkAwrt61tSr3EWMnPy\",\"cwd\":\"__CWD__\",\"message\":{\"role\":\"assistant\",\"id\":\"msg_011CfdYkBBjuc7wyrpaUK9oo\",\"stop_reason\":\"end_turn\",\"content\":[{\"type\":\"thinking\"}]}}", + "{\"type\":\"assistant\",\"uuid\":\"4da4a025-96d8-4515-885c-b697181827c7\",\"parentUuid\":\"893221f6-ddcb-4375-8dff-1793a641c26c\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:13:07.365Z\",\"version\":\"2.1.284\",\"requestId\":\"req_011CfdYkAwrt61tSr3EWMnPy\",\"cwd\":\"__CWD__\",\"message\":{\"role\":\"assistant\",\"id\":\"msg_011CfdYkBBjuc7wyrpaUK9oo\",\"stop_reason\":\"end_turn\",\"content\":[{\"type\":\"text\",\"text\":\"[redacted]\"}]}}", + "{\"type\":\"attachment\",\"uuid\":\"e6a50c50-d9fa-4099-ab25-18224db7e563\",\"parentUuid\":\"4da4a025-96d8-4515-885c-b697181827c7\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:13:07.408Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"prompt_snapshot\"}}", + "{\"type\":\"system\",\"subtype\":\"turn_duration\",\"uuid\":\"a95fb9e0-916f-4ae5-811a-9dbf1561beaf\",\"parentUuid\":\"e6a50c50-d9fa-4099-ab25-18224db7e563\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:13:07.420Z\",\"version\":\"2.1.284\",\"isMeta\":false,\"cwd\":\"__CWD__\"}", + "{\"type\":\"user\",\"uuid\":\"8d8f25c4-133d-4082-8198-57a7f146eaad\",\"parentUuid\":\"a95fb9e0-916f-4ae5-811a-9dbf1561beaf\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:14:10.990Z\",\"version\":\"2.1.284\",\"promptId\":\"5b4b0a2b-d62a-4db4-8776-2ab7176fe41d\",\"promptSource\":\"typed\",\"origin\":{\"kind\":\"human\"},\"cwd\":\"__CWD__\",\"message\":{\"role\":\"user\",\"content\":\"[redacted]\"}}", + "{\"type\":\"attachment\",\"uuid\":\"ac32ab4f-189f-4166-906b-df6f5b309046\",\"parentUuid\":\"8d8f25c4-133d-4082-8198-57a7f146eaad\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:14:10.990Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"skill_listing\"}}", + "{\"type\":\"attachment\",\"uuid\":\"a44c6f9b-2ab7-4c7d-a11b-d9a0010a07ba\",\"parentUuid\":\"ac32ab4f-189f-4166-906b-df6f5b309046\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:14:10.990Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"total_tokens_reminder\"}}", + "{\"type\":\"last-prompt\",\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\"}", + "{\"type\":\"ai-title\",\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\"}", + "{\"type\":\"mode\",\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\"}", + "{\"type\":\"permission-mode\",\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\"}", + "{\"type\":\"atis-latch\",\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\"}", + "{\"type\":\"assistant\",\"uuid\":\"16875202-98b9-4d2c-9344-0f55a2859d70\",\"parentUuid\":\"a44c6f9b-2ab7-4c7d-a11b-d9a0010a07ba\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:14:12.230Z\",\"version\":\"2.1.284\",\"requestId\":\"req_011CfdYpyq1waV9ufcAq9ujA\",\"cwd\":\"__CWD__\",\"message\":{\"role\":\"assistant\",\"id\":\"msg_011CfdYpz5ttbN8Y99UFNQPB\",\"stop_reason\":\"end_turn\",\"content\":[{\"type\":\"thinking\"}]}}", + "{\"type\":\"assistant\",\"uuid\":\"b516eb7f-8f78-4bb0-b872-fc90be0d0756\",\"parentUuid\":\"16875202-98b9-4d2c-9344-0f55a2859d70\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:14:12.234Z\",\"version\":\"2.1.284\",\"requestId\":\"req_011CfdYpyq1waV9ufcAq9ujA\",\"cwd\":\"__CWD__\",\"message\":{\"role\":\"assistant\",\"id\":\"msg_011CfdYpz5ttbN8Y99UFNQPB\",\"stop_reason\":\"end_turn\",\"content\":[{\"type\":\"text\",\"text\":\"[redacted]\"}]}}", + "{\"type\":\"system\",\"subtype\":\"turn_duration\",\"uuid\":\"cd198163-c689-48a6-81c6-14f112d47586\",\"parentUuid\":\"b516eb7f-8f78-4bb0-b872-fc90be0d0756\",\"isSidechain\":false,\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\",\"timestamp\":\"2026-10-02T14:14:12.291Z\",\"version\":\"2.1.284\",\"isMeta\":false,\"cwd\":\"__CWD__\"}", + "{\"type\":\"file-history-snapshot\"}", + "{\"type\":\"cost-state\",\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\"}", + "{\"type\":\"last-prompt\",\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\"}", + "{\"type\":\"cost-state\",\"sessionId\":\"6a87cfeb-1a6f-4747-9883-a295c57fad68\"}" + ] + }, + "clear": { + "sessionId": "4afc15c8-ba09-4a60-9d57-28d59bd43639", + "lines": [ + "{\"type\":\"mode\",\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\"}", + "{\"type\":\"file-history-snapshot\"}", + "{\"type\":\"attachment\",\"uuid\":\"75bebdd1-a55b-432f-bf4d-9c8fd3cab51f\",\"parentUuid\":null,\"isSidechain\":false,\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\",\"timestamp\":\"2026-10-02T14:12:24.813Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"hook_success\",\"hookEvent\":\"SessionStart\",\"hookName\":\"SessionStart:clear\"}}", + "{\"type\":\"attachment\",\"uuid\":\"0a398afd-57d0-4972-8e84-43ca3fddc41b\",\"parentUuid\":\"75bebdd1-a55b-432f-bf4d-9c8fd3cab51f\",\"isSidechain\":false,\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\",\"timestamp\":\"2026-10-02T14:12:24.814Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"hook_success\",\"hookEvent\":\"SessionStart\",\"hookName\":\"SessionStart:clear\"}}", + "{\"type\":\"attachment\",\"uuid\":\"931cb822-c79a-4651-9a4e-bd604787bafe\",\"parentUuid\":\"0a398afd-57d0-4972-8e84-43ca3fddc41b\",\"isSidechain\":false,\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\",\"timestamp\":\"2026-10-02T14:12:24.814Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"hook_additional_context\",\"hookEvent\":\"SessionStart\",\"hookName\":\"SessionStart\"}}", + "{\"type\":\"user\",\"uuid\":\"8ae3e12a-d14b-4ae4-990c-ab8d32d506df\",\"parentUuid\":\"931cb822-c79a-4651-9a4e-bd604787bafe\",\"isSidechain\":false,\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\",\"timestamp\":\"2026-10-02T14:12:24.771Z\",\"version\":\"2.1.284\",\"isMeta\":true,\"cwd\":\"__CWD__\",\"message\":{\"role\":\"user\",\"content\":\"[redacted]\"}}", + "{\"type\":\"user\",\"uuid\":\"387dc4d7-9a31-4af5-ba54-281b251ac834\",\"parentUuid\":\"8ae3e12a-d14b-4ae4-990c-ab8d32d506df\",\"isSidechain\":false,\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\",\"timestamp\":\"2026-10-02T14:12:24.758Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"message\":{\"role\":\"user\",\"content\":\"[redacted]\"}}", + "{\"type\":\"system\",\"subtype\":\"local_command\",\"uuid\":\"320899f3-3f1f-4cb5-afbd-febc9a77600e\",\"parentUuid\":\"387dc4d7-9a31-4af5-ba54-281b251ac834\",\"isSidechain\":false,\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\",\"timestamp\":\"2026-10-02T14:12:24.769Z\",\"version\":\"2.1.284\",\"isMeta\":false,\"cwd\":\"__CWD__\"}", + "{\"type\":\"file-history-snapshot\"}", + "{\"type\":\"user\",\"uuid\":\"ea698251-f9c3-4ab5-8809-6873f47c30b6\",\"parentUuid\":\"320899f3-3f1f-4cb5-afbd-febc9a77600e\",\"isSidechain\":false,\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\",\"timestamp\":\"2026-10-02T14:12:31.814Z\",\"version\":\"2.1.284\",\"promptId\":\"031cfa38-f4ae-4f50-83d0-d46480d6826a\",\"promptSource\":\"typed\",\"origin\":{\"kind\":\"human\"},\"cwd\":\"__CWD__\",\"message\":{\"role\":\"user\",\"content\":\"[redacted]\"}}", + "{\"type\":\"attachment\",\"uuid\":\"81f43afc-b12b-4edf-9339-f031d6858a5e\",\"parentUuid\":\"ea698251-f9c3-4ab5-8809-6873f47c30b6\",\"isSidechain\":false,\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\",\"timestamp\":\"2026-10-02T14:12:31.812Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"environment\"}}", + "{\"type\":\"attachment\",\"uuid\":\"9838b5c6-7348-4896-92e7-72d4e062e7dc\",\"parentUuid\":\"81f43afc-b12b-4edf-9339-f031d6858a5e\",\"isSidechain\":false,\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\",\"timestamp\":\"2026-10-02T14:12:31.812Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"model\"}}", + "{\"type\":\"attachment\",\"uuid\":\"4f6a1c2c-f364-4aec-9489-3d67288d2074\",\"parentUuid\":\"9838b5c6-7348-4896-92e7-72d4e062e7dc\",\"isSidechain\":false,\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\",\"timestamp\":\"2026-10-02T14:12:31.812Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"deferred_tools_delta\"}}", + "{\"type\":\"attachment\",\"uuid\":\"5db42321-52d5-4f64-981e-a678140baa8b\",\"parentUuid\":\"4f6a1c2c-f364-4aec-9489-3d67288d2074\",\"isSidechain\":false,\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\",\"timestamp\":\"2026-10-02T14:12:31.813Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"agent_listing_delta\"}}", + "{\"type\":\"attachment\",\"uuid\":\"7416e8db-646d-40c3-b08e-714fbb2e1ce3\",\"parentUuid\":\"5db42321-52d5-4f64-981e-a678140baa8b\",\"isSidechain\":false,\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\",\"timestamp\":\"2026-10-02T14:12:31.813Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"skill_listing\"}}", + "{\"type\":\"attachment\",\"uuid\":\"1bf37f21-5254-4d98-8073-2dbf5233abfc\",\"parentUuid\":\"7416e8db-646d-40c3-b08e-714fbb2e1ce3\",\"isSidechain\":false,\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\",\"timestamp\":\"2026-10-02T14:12:31.813Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"total_tokens_reminder\"}}", + "{\"type\":\"atis-latch\",\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\"}", + "{\"type\":\"attachment\",\"uuid\":\"fbd4a5ab-613a-497c-91ed-3259491333a0\",\"parentUuid\":\"1bf37f21-5254-4d98-8073-2dbf5233abfc\",\"isSidechain\":false,\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\",\"timestamp\":\"2026-10-02T14:12:31.889Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"session_context\"}}", + "{\"type\":\"attachment\",\"uuid\":\"f1d3d5bb-a3f9-4669-81f6-c1e588b26006\",\"parentUuid\":\"fbd4a5ab-613a-497c-91ed-3259491333a0\",\"isSidechain\":false,\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\",\"timestamp\":\"2026-10-02T14:12:31.889Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"date\"}}", + "{\"type\":\"attachment\",\"uuid\":\"44f7cfa5-bb63-4675-8ece-4d6236b3dad2\",\"parentUuid\":\"f1d3d5bb-a3f9-4669-81f6-c1e588b26006\",\"isSidechain\":false,\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\",\"timestamp\":\"2026-10-02T14:12:31.892Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"remote_session_change\"}}", + "{\"type\":\"attachment\",\"uuid\":\"06e1c408-a9b0-45b5-91e0-b10c5e02a429\",\"parentUuid\":\"44f7cfa5-bb63-4675-8ece-4d6236b3dad2\",\"isSidechain\":false,\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\",\"timestamp\":\"2026-10-02T14:12:31.893Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"prompt_snapshot\"}}", + "{\"type\":\"last-prompt\",\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\"}", + "{\"type\":\"mode\",\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\"}", + "{\"type\":\"atis-latch\",\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\"}", + "{\"type\":\"ai-title\",\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\"}", + "{\"type\":\"assistant\",\"uuid\":\"41dde325-f297-4ffa-9ae6-9f0e1fe6800d\",\"parentUuid\":\"06e1c408-a9b0-45b5-91e0-b10c5e02a429\",\"isSidechain\":false,\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\",\"timestamp\":\"2026-10-02T14:12:33.407Z\",\"version\":\"2.1.284\",\"requestId\":\"req_011CfdYhg3VxSMggGyFvaSNP\",\"cwd\":\"__CWD__\",\"message\":{\"role\":\"assistant\",\"id\":\"msg_011CfdYhgKcmA8faXUSDHh2J\",\"stop_reason\":\"end_turn\",\"content\":[{\"type\":\"thinking\"}]}}", + "{\"type\":\"assistant\",\"uuid\":\"6eaf5175-af71-4f70-8a05-fefee950575d\",\"parentUuid\":\"41dde325-f297-4ffa-9ae6-9f0e1fe6800d\",\"isSidechain\":false,\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\",\"timestamp\":\"2026-10-02T14:12:33.426Z\",\"version\":\"2.1.284\",\"requestId\":\"req_011CfdYhg3VxSMggGyFvaSNP\",\"cwd\":\"__CWD__\",\"message\":{\"role\":\"assistant\",\"id\":\"msg_011CfdYhgKcmA8faXUSDHh2J\",\"stop_reason\":\"end_turn\",\"content\":[{\"type\":\"text\",\"text\":\"[redacted]\"}]}}", + "{\"type\":\"attachment\",\"uuid\":\"4d51ec7b-d983-4b79-87c0-12952efa0cb6\",\"parentUuid\":\"6eaf5175-af71-4f70-8a05-fefee950575d\",\"isSidechain\":false,\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\",\"timestamp\":\"2026-10-02T14:12:33.456Z\",\"version\":\"2.1.284\",\"cwd\":\"__CWD__\",\"attachment\":{\"type\":\"prompt_snapshot\"}}", + "{\"type\":\"system\",\"subtype\":\"turn_duration\",\"uuid\":\"f9eddc9e-5001-4ff2-aaab-4f5041b98278\",\"parentUuid\":\"4d51ec7b-d983-4b79-87c0-12952efa0cb6\",\"isSidechain\":false,\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\",\"timestamp\":\"2026-10-02T14:12:33.465Z\",\"version\":\"2.1.284\",\"isMeta\":false,\"cwd\":\"__CWD__\"}", + "{\"type\":\"last-prompt\",\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\"}", + "{\"type\":\"ai-title\",\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\"}", + "{\"type\":\"mode\",\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\"}", + "{\"type\":\"atis-latch\",\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\"}", + "{\"type\":\"file-history-snapshot\"}", + "{\"type\":\"cost-state\",\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\"}", + "{\"type\":\"cost-state\",\"sessionId\":\"4afc15c8-ba09-4a60-9d57-28d59bd43639\"}" + ] + } + } +} diff --git a/test/helpers/touchfiles-data.ts b/test/helpers/touchfiles-data.ts index 6de621a71..0fd0e3558 100644 --- a/test/helpers/touchfiles-data.ts +++ b/test/helpers/touchfiles-data.ts @@ -83,6 +83,8 @@ export const E2E_TOUCHFILES: Record = { // these entries exist so the canaries themselves stay tier-classified) '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'], // P4 first-run scaffold (activation lift) — the detection binary end-to-end // through the real runner, plus the script wiring that gates + maps it @@ -1142,6 +1144,7 @@ export const E2E_TIERS: Record = { // clean room breaks, every other eval's signal is contaminated) 'hermetic-canary': 'gate', 'hermetic-sentinel': 'gate', + 'autoplan-journal-drift': 'periodic', // SKILL.md setup — gate (if setup breaks, no skill works) 'skillmd-setup-discovery': 'gate', @@ -1606,6 +1609,7 @@ export const E2E_KINDS: Record = { 'aside-canary-quick': 'rule', 'hermetic-canary': 'rule', 'hermetic-sentinel': 'rule', + 'autoplan-journal-drift': 'rule', 'skillmd-setup-discovery': 'rule', 'skillmd-no-local-binary': 'rule', 'skillmd-outside-git': 'rule', diff --git a/test/plan-count-transcript.test.ts b/test/plan-count-transcript.test.ts index 49d97bd17..0a9996a63 100644 --- a/test/plan-count-transcript.test.ts +++ b/test/plan-count-transcript.test.ts @@ -2,7 +2,9 @@ import { afterEach, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { readPlanCountTranscript, unresolvedPlanQuestionCalls } from './helpers/plan-count-transcript'; +import { randomUUID } from 'node:crypto'; +import { readPlanCountTranscript, unresolvedPlanQuestionCalls, readOwnedClaudePublicTranscript, nativePathSpelling, + ownedNativePath, sameNativePath } from './helpers/plan-count-transcript'; import { nativePlanCallFingerprint, planCountQuestionPhase, engStep0Boundary } from './helpers/claude-pty-runner'; const dirs: string[] = []; @@ -206,3 +208,202 @@ describe('native plan approval request evidence', () => { expect(f.read().planReadyRequests).toBeUndefined(); }); }); + +describe('native journal roots: SessionStart preamble, resumed genesis and reason codes', () => { + function rooted() { + const config = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'journal-roots-'))); + dirs.push(config); + const cwd = path.join(config, 'project'); + const project = path.join(config, 'projects', 'project'); + fs.mkdirSync(cwd); fs.mkdirSync(project, { recursive: true }); + const sessionId = 'session-roots'; + const file = path.join(project, `${sessionId}.jsonl`); + let tick = 0; + const node = (parentUuid: string | null, extra: object) => ({ uuid: randomUUID(), parentUuid, cwd, sessionId, + isSidechain: false, version: '2.1.284', timestamp: new Date(Date.parse('2026-10-02T14:00:00Z') + tick++ * 1000).toISOString(), ...extra }); + const hook = (parentUuid: string | null, extra: object = {}) => node(parentUuid, { type: 'attachment', + attachment: { type: 'hook_success', hookEvent: 'SessionStart', hookName: 'SessionStart:startup' }, ...extra }); + const preamble = (count: number) => { + const chain = [hook(null)]; + while (chain.length < count) chain.push(hook(chain.at(-1)!.uuid)); + return chain; + }; + const user = (parentUuid: string | null, extra: object = {}) => node(parentUuid, { type: 'user', + message: { role: 'user', content: 'Plan the retry change.' }, origin: { kind: 'human' }, promptSource: 'typed', promptId: randomUUID(), ...extra }); + const say = (parentUuid: string, text: string, extra: object = {}) => node(parentUuid, { type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text }] }, ...extra }); + const write = (rows: object[], tail = '\n') => fs.writeFileSync(file, rows.map(r => JSON.stringify(r)).join('\n') + tail); + const owned = () => readOwnedClaudePublicTranscript(file, cwd, sessionId); + const plain = () => readPlanCountTranscript(config, cwd); + const messages = () => owned().events.filter(e => e.kind === 'message').map(e => (e as { text: string }).text); + return { config, cwd, project, file, sessionId, node, hook, preamble, user, say, write, owned, plain, messages }; + } + + test('a user root without a preamble stays owned', () => { + const j = rooted(), prompt = j.user(null); + j.write([prompt, j.say(prompt.uuid, 'Reviewed.')]); + expect(j.owned().transcript.status).toBe('ready'); + expect(j.messages()).toEqual(['Reviewed.']); + }); + + for (const count of [1, 3]) + test(`${count} leading SessionStart attachment(s) root the first user turn for both readers`, () => { + const j = rooted(), chain = j.preamble(count), prompt = j.user(chain.at(-1)!.uuid); + j.write([...chain, prompt, j.say(prompt.uuid, 'Reviewed.')]); + const read = j.owned(); + expect(read.transcript).toMatchObject({ status: 'ready' }); + expect(read.transcript.reason).toBeUndefined(); + expect(read.events.map(e => e.kind)).toEqual(['user_turn', 'message']); + expect(j.plain().assistantMessages.map(m => m.text)).toEqual(['Reviewed.']); + }); + + test('a preamble flushed after its children still roots ownership; the plain reader follows a later cwd change', () => { + const j = rooted(), chain = j.preamble(3), prompt = j.user(chain.at(-1)!.uuid); + const other = path.join(j.config, 'elsewhere'); fs.mkdirSync(other); + const first = j.say(prompt.uuid, 'Before cd.'), moved = j.say(first.uuid, 'After cd.', { cwd: other }); + j.write([prompt, first, moved, ...chain.toReversed()]); + expect(j.messages()).toEqual(['Before cd.', 'After cd.']); + // Origin seeding uses the causal root, not physical order, so the native + // continuation across Bash cd keeps its ownership. + expect(j.plain().assistantMessages.map(m => m.text)).toEqual(['Before cd.', 'After cd.']); + }); + + test('a resumed fork whose first record is a compact boundary with an absent logical parent is a root', () => { + const j = rooted(); + const boundary = j.node(null, { type: 'system', subtype: 'compact_boundary', logicalParentUuid: randomUUID(), content: 'Conversation compacted' }); + const summary = j.user(boundary.uuid, { isCompactSummary: true, origin: undefined, promptSource: undefined }); + const prompt = j.user(summary.uuid); + j.write([{ type: 'ai-title', sessionId: j.sessionId }, boundary, summary, prompt, j.say(prompt.uuid, 'Resumed.')]); + expect(j.owned().transcript.status).toBe('ready'); + expect(j.messages()).toEqual(['Resumed.']); + // Mid-file, the same boundary may be an unflushed ancestor: no ownership, no hard code. + const stray = j.hook(null); + j.write([stray, boundary, summary, prompt, j.say(prompt.uuid, 'Resumed.')]); + expect(j.owned().transcript).toMatchObject({ status: 'missing', calls: [] }); + expect(j.owned().transcript.reason).toBeUndefined(); + expect(j.owned().events).toEqual([]); + }); + + test('competing roots stay hard: a second user root, a second preamble carrying a turn, or a genesis beside a user root', () => { + const j = rooted(), chain = j.preamble(2), prompt = j.user(chain.at(-1)!.uuid); + const base = [...chain, prompt, j.say(prompt.uuid, 'Reviewed.')]; + j.write([...base, j.hook(null)]); + expect(j.owned().transcript.status).toBe('ready'); + const second = j.hook(null); + for (const extra of [[j.user(null)], [second, j.user(second.uuid)]]) { + j.write([...base, ...extra]); + expect(j.owned().transcript).toMatchObject({ status: 'error', reason: 'competing_root' }); + expect(j.owned().events).toEqual([]); + } + const boundary = j.node(null, { type: 'system', subtype: 'compact_boundary', logicalParentUuid: randomUUID() }); + const resumed = j.user(boundary.uuid); + j.write([boundary, resumed, j.user(null)]); + expect(j.owned().transcript.reason).toBe('competing_root'); + }); + + test('foreign cwd is hard only when the spelling and the real directory both differ', () => { + const j = rooted(), chain = j.preamble(1), prompt = j.user(chain[0]!.uuid); + const other = path.join(j.config, 'other-project'); fs.mkdirSync(other); + j.write([{ ...chain[0]!, cwd: other }, prompt]); + expect(j.owned().transcript.reason).toBe('foreign_cwd'); + j.write([{ ...chain[0]!, cwd: j.cwd + path.sep }, { ...prompt, cwd: j.cwd + path.sep }]); + expect(j.owned().transcript.reason).toBe('unrecognized_shape:cwd_spelling'); + }); + + test('sidechain, agent and cyclic ancestry are hard codes', () => { + const j = rooted(); + const [head] = j.preamble(1), prompt = j.user(head!.uuid); + j.write([{ ...head!, isSidechain: true }, prompt]); + expect(j.owned().transcript.reason).toBe('sidechain'); + j.write([{ ...head!, agentId: 'agent-1' }, prompt]); + expect(j.owned().transcript.reason).toBe('agent'); + j.write([{ ...head!, parentUuid: prompt.uuid }, prompt]); + expect(j.owned().transcript).toMatchObject({ status: 'error', reason: 'cycle' }); + }); + + test('unknown root shapes report a typed code and the record types, never content or ownership', () => { + const j = rooted(); + const command = j.node(null, { type: 'system', subtype: 'local_command', content: '/model' }); + const setup = j.hook(null, { attachment: { type: 'hook_success', hookEvent: 'Setup' } }); + const assistantFirst = j.hook(null); + for (const [rows, code] of [ + [[command, j.user(command.uuid)], 'unrecognized_shape:preamble:system:local_command'], + [[setup, j.user(setup.uuid)], 'unrecognized_shape:preamble:attachment:hook_success'], + [[assistantFirst, j.say(assistantFirst.uuid, 'No prompt yet.')], 'unrecognized_shape:first_turn:assistant'], + ] as const) { + const current = j.node(rows[1].uuid, { type: 'assistant', message: { role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_current', name: 'Read', input: { file_path: '/x' } }] } }); + j.write([...rows, current]); + const read = j.owned(); + expect(read.transcript).toMatchObject({ status: 'missing', reason: code, calls: [], assistantMessages: [] }); + expect(read.events).toEqual([]); + expect(read.diagnostic).toMatchObject({ claudeVersion: '2.1.284', toolUseIds: ['toolu_current'], complete: true }); + expect(read.diagnostic!.rootShape[0]).toBe(code === 'unrecognized_shape:preamble:system:local_command' ? 'system:local_command' : 'attachment:hook_success'); + expect(JSON.stringify(read.diagnostic)).not.toContain('Plan the retry change'); + expect(JSON.stringify(read.diagnostic)).not.toContain('/model'); + } + }); + + test('malformed, partial and changing journals are retry codes, never shape advisories', () => { + const j = rooted(), prompt = j.user(null); + j.write([prompt]); + fs.appendFileSync(j.file, 'not JSON\n'); + expect(j.owned().transcript.reason).toBe('malformed'); + j.write([prompt, j.say(prompt.uuid, 'Reviewed.')], '\n{"partial":'); + expect(j.owned().transcript.status).toBe('ready'); + expect(j.owned().diagnostic).toBeUndefined(); + expect(readOwnedClaudePublicTranscript(path.join(j.project, 'absent.jsonl'), j.cwd, 'absent').transcript.reason).toBeUndefined(); + }); + + test('win32 spellings fold before validation; dot segments stay rejected', () => { + const w = path.win32; + expect(nativePathSpelling('C:/Users/a/proj', w)).toBe('C:\\Users\\a\\proj'); + expect(nativePathSpelling('c:\\Users\\a', w)).toBe('C:\\Users\\a'); + expect(nativePathSpelling('/c/Users/a', w)).toBe('C:\\Users\\a'); + expect(nativePathSpelling('\\\\server\\share\\x', w)).toBe('\\\\server\\share\\x'); + expect(nativePathSpelling('/c/Users/a', path.posix)).toBe('/c/Users/a'); + for (const good of ['C:/Users/a/proj', '/c/Users/a', 'c:\\x', '\\\\server\\share\\x']) expect(ownedNativePath(good, w)).toBe(true); + expect(nativePathSpelling('/cc/x', w)).toBe('\\cc\\x'); + for (const bad of ['C:/Users/a/../b', 'C:\\Users\\.\\a', '/c', 'relative\\x', 'C:\\a\\\\b', 42]) + expect(ownedNativePath(bad, w)).toBe(false); + expect(ownedNativePath('/c/Users/a', path.posix)).toBe(true); + expect(sameNativePath('C:/x/y', 'c:\\x\\y', w)).toBe(true); + expect(sameNativePath('/c/x', 'C:\\x', w)).toBe(true); + expect(sameNativePath('C:\\x\\..\\y', 'C:\\y', w)).toBe(false); + expect(sameNativePath('C:/x', 'C:\\x', path.posix)).toBe(false); + }); + + describe('real Claude Code 2.1.284 journals (redacted)', () => { + const real = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures', 'claude-native-journal-roots-2.1.284.json'), 'utf8')) as { + claudeCodeVersion: string; journals: Record; + }; + const load = (name: string) => { + const j = rooted(), journal = real.journals[name]!; + const file = path.join(j.project, `${journal.sessionId}.jsonl`); + fs.writeFileSync(file, journal.lines.map(line => line.replaceAll('__CWD__', JSON.stringify(j.cwd).slice(1, -1))).join('\n') + '\n'); + const records = journal.lines.map(line => JSON.parse(line)); + return { j, file, records, read: readOwnedClaudePublicTranscript(file, j.cwd, journal.sessionId) }; + }; + test('the fixture is structure only and names its Claude Code version', () => { + expect(real.claudeCodeVersion).toBe('2.1.284'); + expect(Object.keys(real.journals).sort()).toEqual(['clear', 'compact', 'forkAfterCompact', 'startup']); + for (const journal of Object.values(real.journals)) for (const line of journal.lines) { + const record = JSON.parse(line); + if (typeof record.message?.content === 'string') expect(record.message.content).toBe('[redacted]'); + if (record.version) expect(record.version).toBe('2.1.284'); + } + }); + for (const [name, head] of [['startup', 'attachment'], ['clear', 'attachment'], ['compact', 'attachment'], ['forkAfterCompact', 'system']] as const) + test(`${name}: the owned reader accepts the real root shape`, () => { + const { j, records, read } = load(name); + const first = records.find(r => r.uuid)!; + expect(first.type).toBe(head); + expect(first.parentUuid).toBeNull(); + if (head === 'attachment') expect(first.attachment).toMatchObject({ hookEvent: 'SessionStart' }); + else expect(records.some(r => r.uuid === first.logicalParentUuid)).toBe(false); + expect(read.transcript.status).toBe('ready'); + expect(read.events.filter(e => e.kind === 'message').length).toBeGreaterThan(0); + expect(j.plain().assistantMessages.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/test/skill-e2e-autoplan-journal-drift.test.ts b/test/skill-e2e-autoplan-journal-drift.test.ts new file mode 100644 index 000000000..934b85558 --- /dev/null +++ b/test/skill-e2e-autoplan-journal-drift.test.ts @@ -0,0 +1,87 @@ +/** + * Autoplan journal drift canary (periodic tier, rule kind, ~$0.02, haiku). + * + * The /autoplan publication guard reads Claude Code's own session journal and + * must prove which records belong to the parent session. Claude Code has + * changed that journal's root shape before (SessionStart hook attachments, + * #3007/#2968; resumed forks that begin at a compact boundary, #2977), and each + * change bricked /autoplan for users until a release. This canary captures real + * journals from the pinned `claude` with a SessionStart hook installed, for + * startup, /compact and `--resume --fork-session`, and runs the guard's reader on + * them, so the next format change fails this lane instead of users. + * + * It never files issues; the periodic report job owns that. + */ +import { expect } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { JUDGE_MS } from './helpers/eval-budgets'; +import { describeIfSelected, testIfSelected } from './helpers/e2e-helpers'; +import { resolveClaudeCommand } from '../lib/claude-bin'; +import { readOwnedClaudePublicTranscript, readPlanCountTranscript } from '../lib/claude-public-transcript'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const CANARY_MODEL = 'claude-haiku-4-5-20251001'; +const PINNED = /@anthropic-ai\/claude-code@(\d+\.\d+\.\d+)/.exec( + fs.readFileSync(path.join(ROOT, '.github/docker/Dockerfile.ci'), 'utf8'))?.[1]; + +describeIfSelected('autoplan journal drift canary', ['autoplan-journal-drift'], () => { + testIfSelected('autoplan-journal-drift', async () => { + if (!process.env.ANTHROPIC_API_KEY) throw new Error('autoplan-journal-drift requires ANTHROPIC_API_KEY; refusing to skip'); + const claude = resolveClaudeCommand(); + if (!claude || !PINNED) throw new Error('autoplan-journal-drift needs the claude CLI and the Dockerfile.ci pin'); + const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-journal-drift-'))); + try { + const config = path.join(base, 'home', '.claude'), project = path.join(base, 'project'); + fs.mkdirSync(config, { recursive: true }); fs.mkdirSync(project); + // Two SessionStart hooks, one plain and one with additionalContext, the + // shape plugins install and #3007 reported. + fs.writeFileSync(path.join(config, 'settings.json'), JSON.stringify({ hooks: { SessionStart: [{ hooks: [ + { type: 'command', command: 'echo drift-canary-hook' }, + { type: 'command', command: `echo '${JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'drift canary' } })}'` }, + ] }] } })); + const env = { PATH: process.env.PATH ?? '', HOME: path.join(base, 'home'), ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + CLAUDE_CONFIG_DIR: config, DISABLE_AUTOUPDATER: '1', TERM: 'dumb' }; + const claudeRun = (...args: string[]) => { + const child = spawnSync(claude.command, [...claude.argsPrefix, ...args], { cwd: project, env, encoding: 'utf8', timeout: 90_000 }); + if (child.status !== 0) throw new Error(`claude ${args.join(' ')} exited ${child.status ?? child.signal}: ${child.stderr.slice(-400)}`); + return child.stdout; + }; + expect(claudeRun('--version')).toContain(PINNED); + const journals = () => { + const projects = path.join(config, 'projects'); + return fs.readdirSync(projects).flatMap(dir => fs.readdirSync(path.join(projects, dir)) + .filter(name => name.endsWith('.jsonl')).map(name => path.join(projects, dir, name))); + }; + const owned = (file: string) => readOwnedClaudePublicTranscript(file, project, path.basename(file, '.jsonl')); + const shape = (file: string) => fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map(line => JSON.parse(line)) + .filter(r => r.uuid).map(r => [r.type, r.subtype, r.attachment?.type].filter(Boolean).join(':')).slice(0, 6); + const expectOwned = (file: string, label: string) => { + const read = owned(file); + if (read.transcript.status !== 'ready') + throw new Error(`${label}: Claude Code ${PINNED} journal is not owned (${read.transcript.reason ?? read.transcript.status}); head ${JSON.stringify(shape(file))}`); + expect(read.events.some(e => e.kind === 'message')).toBe(true); + }; + + claudeRun('-p', 'Reply with the single word: ok', '--model', CANARY_MODEL, '--strict-mcp-config'); + const [startup] = journals(); + expect(journals()).toHaveLength(1); + expectOwned(startup!, 'startup'); + expect(readPlanCountTranscript(config, project).status).toBe('ready'); + const session = path.basename(startup!, '.jsonl'); + + claudeRun('-p', '/compact', '--resume', session, '--model', CANARY_MODEL, '--strict-mcp-config'); + expect(fs.readFileSync(startup!, 'utf8')).toContain('"compact_boundary"'); + expectOwned(startup!, 'compact'); + + claudeRun('-p', 'Reply with the single word: ok', '--resume', session, '--fork-session', '--model', CANARY_MODEL, '--strict-mcp-config'); + const fork = journals().find(file => file !== startup); + expect(fork).toBeDefined(); + expectOwned(fork!, 'fork after compact'); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } + }, JUDGE_MS * 3); +}); diff --git a/test/windows-free-selection-autoplan-guard.test.ts b/test/windows-free-selection-autoplan-guard.test.ts new file mode 100644 index 000000000..1f3896996 --- /dev/null +++ b/test/windows-free-selection-autoplan-guard.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from 'bun:test'; +import { curateWindowsSafe } from '../scripts/test-free-shards'; + +// The /autoplan guard's path folding needs real win32 coverage: these suites +// must stay in the curated windows-free-tests subset (no POSIX-only pattern). +test('the windows-free-tests lane selects the autoplan guard hook and reader suites', () => { + const files = ['test/autoplan-publication-hook.test.ts', 'test/autoplan-publication-guard.test.ts', + 'test/plan-count-transcript.test.ts', 'test/plan-count-cross-cwd-ancestry.test.ts', 'test/plan-count-session-cwd.test.ts']; + const { safe, excluded } = curateWindowsSafe(files); + expect(excluded).toEqual([]); + expect(safe).toEqual(files); +});