mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
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:
1 parent
9e62b87462
commit
bfd27c1b8f
10 files changed
+1443
-109
No files matched your search
@@ -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.' });
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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\"}"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user