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 <elmesery.dev@gmail.com>
This commit is contained in:
committed 2026-10-02 15:06:08 +00:00
1 parent 9e62b87462
commit bfd27c1b8f
10 files changed
+1443 -109

No files matched your search

+159 -37
View File
@@ -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<Phase, number> = { ceo: 1, design: 2, dx: 2.5, eng: 3, task
const object = (x: unknown): x is Record<string, any> => 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<string, unknown> }, 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(`<!-- /autoplan restore point: ${reference} -->\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(`<!-- /autoplan restore point: ${reference} -->`.length)) ||
!active.startsWith(`<!-- /autoplan restore point: ${reference} -->`) || 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<number>();
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: <root>/.git/worktrees/<name>/gitdir names
* <worktree>/.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<typeof readOwnedClaudePublicTranscript>;
/** Positive identity conflicts: retrying cannot change them, so they deny with the supported fallback. */
const HARD_CAUSE: Partial<Record<OwnedTranscriptReason, string>> = {
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<object> {
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.' });
+109
View File
@@ -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/<project>/<session>.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:<detail>`)
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
`<state root>/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.
+205 -68
View File
@@ -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, any>): 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<string, any>): 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<string, typeof nodes[number]>();
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<string, Node>();
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, any>): string | undefined => uuid(r.parentUuid) ? r.parentUuid :
const anyById = new Map<string, Record<string, any>>();
for (const { record } of parsed) if (nativeUuid(record.uuid) && !anyById.has(record.uuid)) anyById.set(record.uuid, record);
const excluded = (r: Record<string, any>): OwnedTranscriptReason => r.agentId != null ? 'agent' :
r.isSidechain !== false ? 'sidechain' : 'unrecognized_shape:record_metadata';
const parent = (r: Record<string, any>): 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<string>();
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<Node>(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<string, number[]>();
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<string, Node[]>();
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<Node>([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<string>();
let causalMembership: Set<string> | 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<string> | 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<string>();
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 = /^<command-message>autoplan<\/command-message>\n<command-name>\/autoplan<\/command-name>(?:\n<command-args>[\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);
}
+317 -3
View File
@@ -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<typeof fixture>, 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<typeof fixture>, project = f.cwd) =>
withNativeProjectDirectory(project, () => runPublicationHook(f.input, ROOT));
const status = (f: ReturnType<typeof fixture>) =>
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<T>(env: Record<string, string | undefined>, 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<string, string | undefined> = { 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: <repo>/.git/worktrees/<name>/gitdir <-> <worktree>/.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<typeof fixture>, 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');
});
});
+129
View File
@@ -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<string, string> = {}): Promise<any> {
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);
});
});
+219
View File
@@ -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\"}"
]
}
}
}
+4
View File
@@ -83,6 +83,8 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
// 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<string, 'gate' | 'periodic' | 'marathon'> = {
// 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<string, 'rule' | 'behavior' | 'judge'> = {
'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',
+202 -1
View File
@@ -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: '<command-name>/model</command-name>' });
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<string, { sessionId: string; lines: string[] }>;
};
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);
});
});
});
@@ -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);
});
@@ -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);
});