Files
gstack/autoplan/bin/phase-publication-hook.ts
Garry Tanandcapy-ai[bot] c279ce045d fix(autoplan): linked worktrees resolve from a project directory below the repo root (#3296) (#3383)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-25 12:27:01 +00:00

784 lines
53 KiB
TypeScript

#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/** 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, canonicalDestination } from '../../bin/gstack-autoplan-snapshot';
import { autoplanPhaseCompletions } from '../../lib/autoplan-phase-publication';
import { nativePathSpelling, ownedNativePath, sameNativePath, type ClaudeParentPublicEvent } from '../../lib/claude-journal-records';
import { ownedRecordLimit, ownedRetainedLimit, type JournalPrefix, type OwnedRead, type OwnedReadMeasure } from '../../lib/claude-owned-journal';
import { readGuardJournal, textResult, DEDUP_REPLY } from './guard-journal';
import { REASONS, reasonCode, reasonText, type Detail, type ReasonCode } from './guard-reasons';
import { logGuardDecision } from './guard-log';
import { approveOwnedRead, renderSectionBase, userRenderRoot } from './owned-read';
const PHASES = ['ceo', 'design', 'dx', 'eng', 'tasks'] as const;
type Phase = typeof PHASES[number];
type Event = ClaudeParentPublicEvent;
type Use = Event & { kind: 'use' };
type Tool = Extract<Event, { toolUseId: string }>;
type Turn = Extract<Event, { kind: 'end_turn' | 'user_turn' }>;
const isUse = (e: Event): e is Use => e.kind === 'use';
/** ENG-5: one pass groups each tool id's uses and results, so evaluation never rescans per tool. */
function byTool(events: readonly Event[]) {
const uses = new Map<string, Use[]>(), results = new Map<string, Tool[]>();
for (const e of events) if (e.kind === 'use' || e.kind === 'result') {
const map = (e.kind === 'use' ? uses : results) as Map<string, Tool[]>, list = map.get(e.toolUseId) ?? [];
list.push(e); map.set(e.toolUseId, list);
}
return { uses: (id: string) => uses.get(id) ?? [], results: (id: string) => results.get(id) ?? [] };
}
type Tools = ReturnType<typeof byTool>;
const number: Record<Phase, number> = { ceo: 1, design: 2, dx: 2.5, eng: 3, tasks: 4 };
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');
/** 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())));
/**
* The file a Write/Edit path names, realpathed so a symlinked plan directory
* compares equal to init's canonical paths. realpath resolves a leaf link too; a
* missing target or dangling link keeps the literal spelling. An aliased active
* plan never binds, because invocation() reads it through read(), which denies aliases.
*/
const canonicalRequested = (cwd: string, file: unknown): string | undefined => {
if (typeof file !== 'string') return;
const literal = requestedPath(cwd, file);
try { return fs.realpathSync(literal); } catch { return literal; }
};
/** Keys Claude Code's schema parse may drop from the journaled raw input (Agent's fork-subagent gate drops run_in_background). */
export const SCHEMA_STRIPPED: Record<string, readonly string[]> = { Agent: ['run_in_background'] };
/** The reviewer dispatch input the guard accepts; snapshot manifests are unchanged by it. */
export const AGENT_KEYS: readonly string[] = ['prompt', 'description', 'subagent_type', 'run_in_background'];
/** The newest Claude Code release the pinned PTY runs and the schema canary have checked. */
export const CHECKED_CLAUDE_CODE = '2.1.292';
/**
* Claude records the model's raw input in the journal but hands PreToolUse its
* schema-parsed form: file_path resolved (C:\x/y becomes C:\x\y) and keys the
* schema lacks dropped. Compare file_path as the file it names, drop only the
* documented strips; every other field stays exact.
*/
export const nativeToolInput = (input: unknown, cwd: string, tool?: string): unknown => {
if (!object(input)) return input;
const out: Record<string, unknown> = { ...input };
if (typeof out.file_path === 'string') out.file_path = requestedPath(cwd, out.file_path);
for (const key of SCHEMA_STRIPPED[tool ?? ''] ?? []) delete out[key];
return out;
};
const versionParts = (v: string) => /^(\d+)\.(\d+)\.(\d+)/.exec(v)?.slice(1).map(Number);
/** True only for a parseable version above the checked release. */
export function newerThanChecked(version: string | undefined): boolean {
const a = version && versionParts(version), b = versionParts(CHECKED_CLAUDE_CODE)!;
if (!a) return false;
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i]! > b[i]!;
return false;
}
class BoundaryError extends Error {
constructor(readonly code: string, readonly detail: Detail = {}) { super(code); }
}
function fail(code: ReasonCode | `unrecognized_shape:${string}`, detail?: Detail): never { throw new BoundaryError(code, detail); }
export interface PublicationHookInput {
hook_event_name: 'PreToolUse'; session_id: string; transcript_path: string; cwd: string;
tool_name: string; tool_use_id: string; tool_input: Record<string, unknown>; agent_id?: string | null;
}
/** What tests and callers see; the hook runner keeps the code and detail. */
export type PublicationDecision = { allow: true; unverified?: string } | { allow: false; reason: string };
type Decision = { allow: true; unverified?: string; detail?: Detail } | { allow: false; reason: string; code: string; detail?: Detail };
const visible = (d: Decision): PublicationDecision =>
d.allow ? (d.unverified ? { allow: true, unverified: d.unverified } : { allow: true }) : { allow: false, reason: d.reason };
interface Invocation { activePlan: string; restorePath: string; originalSha256: string; start: number }
/** Stable, bounded regular bytes; links never establish an artifact identity. */
function read(file: string, immutable = false): string {
if (!ownPath(file) || !samePath(fs.realpathSync(file), file)) fail('snapshot', { cause: 'An 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 });
if (!before.isFile() || before.size > 32n * 1024n * 1024n ||
(immutable && process.platform !== 'win32' && (before.mode & 0o222n) !== 0n)) fail('snapshot', { cause: 'An artifact is not immutable bounded data' });
const bytes = fs.readFileSync(fd), 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 || before.size !== current.size ||
before.mtimeNs !== current.mtimeNs || before.size !== BigInt(bytes.length)) fail('snapshot', { cause: 'An artifact changed during read' });
const text = bytes.toString('utf8');
if (!Buffer.from(text).equals(bytes)) fail('snapshot', { cause: 'An artifact is not complete UTF-8' });
return text;
} finally { fs.closeSync(fd); }
}
function phaseName(file: unknown, cwd: string): Phase | undefined {
if (typeof file !== 'string') return;
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;
return (name[1] === 'tasks-aggregator' ? 'tasks' : name[1]!.split('-')[0]) as Phase;
}
function driver(file: unknown, cwd: string, root: string): Phase | undefined {
const phase = phaseName(file, cwd);
if (!phase) return;
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('foreign_install');
}
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 = 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;
return match[1] as Phase;
}
function candidate(use: { name?: string; input?: Record<string, unknown> }, cwd: string): boolean {
return use.name === 'Read' ? !!(phaseName(use.input?.file_path, cwd) || artifactName(use.input?.file_path, cwd)) :
use.name === 'Agent' && typeof use.input?.prompt === 'string' &&
/^You are the independent (CEO|DESIGN|DX|ENG) reviewer for this phase\.\n/.test(use.input.prompt);
}
function methodology(file: string, phase: Phase, init: Invocation) {
const directory = path.dirname(file);
if (path.basename(file) !== 'methodology.md' || path.dirname(directory) !== path.dirname(init.restorePath) ||
!path.basename(directory).startsWith(`autoplan-${phase}-methodology-`)) fail('snapshot', { cause: 'Methodology belongs to a different invocation' });
const content = read(file, true), manifestBytes = read(path.join(directory, 'methodology.json'), true);
const manifest = JSON.parse(manifestBytes);
if (!object(manifest) || manifest.phase !== phase || manifest.restorePath !== init.restorePath ||
manifest.restoreSha256 !== init.originalSha256 || manifest.methodologyPath !== file ||
manifest.sha256 !== hash(content) || manifest.bytes !== Buffer.byteLength(content) ||
manifest.lines !== content.split('\n').length) fail('snapshot', { cause: 'Methodology identity does not match this invocation' });
return { content, manifest, manifestBytes };
}
function snapshot(directory: string, phase: Phase, init: Invocation) {
if (path.dirname(directory) !== path.dirname(init.restorePath) ||
!path.basename(directory).startsWith(`autoplan-${phase}-`)) fail('snapshot', { cause: 'Native phase snapshot belongs to a different invocation' });
const manifest = JSON.parse(read(path.join(directory, 'snapshot.json'), true));
if (!object(manifest) || manifest.schemaVersion !== 2 || manifest.phase !== phase || manifest.activePlan !== init.activePlan ||
manifest.snapshotPath !== path.join(directory, `${phase}-implementation.md`) ||
manifest.sourceSnapshotPath !== path.join(directory, 'source-implementation.md') ||
manifest.nativePromptPath !== path.join(directory, 'native-prompt.md') || !object(manifest.methodology))
fail('snapshot', { cause: 'Native phase snapshot does not match this active plan' });
const implementation = read(manifest.snapshotPath, true), source = read(manifest.sourceSnapshotPath, true);
const native = read(manifest.nativePromptPath, true), m = methodology(manifest.methodology.methodologyPath, phase, init);
if (manifest.sha256 !== hash(implementation) || manifest.sourceSha256 !== hash(source) ||
manifest.sourceBytes !== Buffer.byteLength(source) || manifest.nativePromptSha256 !== hash(native) ||
manifest.nativePromptBytes !== Buffer.byteLength(native) || manifest.nativePromptLines !== native.split('\n').length ||
manifest.methodology.manifestSha256 !== hash(m.manifestBytes) || manifest.methodology.sha256 !== hash(m.content) ||
manifest.methodology.bytes !== Buffer.byteLength(m.content) || manifest.methodology.lines !== m.content.split('\n').length)
fail('snapshot', { cause: 'Native phase snapshot bytes are unavailable or changed' });
return manifest;
}
function consumption(use: { name?: string; input?: Record<string, unknown> }, cwd: string, root: string,
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(requestedPath(cwd, use.input!.file_path as string))) };
const phase = artifactName(use.input?.file_path, cwd, includeClose);
if (!phase) return;
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 };
}
snapshot(path.dirname(file), phase, init);
if (base === 'close-packet.md') closePacket(file, phase, init, false);
return { phase, kind: 'Read', content: read(file, true) };
}
if (use.name !== 'Agent' || typeof use.input?.prompt !== 'string') return;
const prompt = use.input.prompt, phase = /^You are the independent (CEO|DESIGN|DX|ENG) reviewer for this phase\.\n/.exec(prompt)?.[1]?.toLowerCase() as Phase | undefined;
if (!phase) return;
const extra = Object.keys(use.input).filter(key => !AGENT_KEYS.includes(key));
if (use.input.subagent_type !== undefined && use.input.subagent_type !== 'general-purpose') extra.push('subagent_type');
if (extra.length) fail('agent_key', { keys: extra.sort() });
const file = JSON.parse(/^Read file: ("[^\n]+")$/m.exec(prompt)?.[1] ?? 'null');
if (!ownPath(file) || path.basename(file) !== 'native-prompt.md') fail('snapshot', { cause: 'Native phase dispatch is not bound to its immutable input' });
const manifest = snapshot(path.dirname(file), phase, init);
if (manifest.nativePromptPath !== file || manifest.nativeDispatchPrompt !== prompt) fail('dispatch_prompt');
return { phase, kind: 'Agent' };
}
/** The early test detector uses these same artifact checks, with its owned public events. */
export function boundAutoplanPhaseConsumption(events: Event[], use: Use, cwd: string, root: string): Consumer | undefined {
if (!candidate(use, cwd)) return;
return consumption(use, cwd, root, invocation(events.filter(e => e.order < use.order), root));
}
/** Authenticate the existing direct-create result; this does not prove its shell command's origin. */
function checkpointResult(result: Event, tools: Tools, init: Invocation): { phase: Phase; path: string } | undefined {
if (result.kind !== 'result') return;
const use = tools.uses(result.toolUseId)[0];
if (use?.name !== 'Bash' || use.order >= result.order) return;
const text = textResult(result);
if (text === undefined) return;
const output = JSON.parse(text);
if (!object(output) || !['ceo', 'design', 'dx', 'eng'].includes(output.phase) ||
!ownPath(output.snapshotPath) || typeof output.nativePrompt !== 'string' || !object(output.baselineEdits)) return;
const { nativePrompt, baselineEdits, ...identity } = output;
const manifest = snapshot(path.dirname(output.snapshotPath), output.phase, init);
if (!isDeepStrictEqual(identity, manifest) || nativePrompt !== read(manifest.nativePromptPath, true) ||
baselineEdits.record !== `<!-- autoplan-baseline-edits:${output.phase} ${JSON.stringify({ sourceSha256: manifest.sourceSha256, replacements: [] })} -->` ||
typeof baselineEdits.instructions !== 'string') return;
return { phase: output.phase, path: output.snapshotPath };
}
/** Only the documented literal init argv, optionally after literal cd. No shell evaluation. */
function initArguments(command: unknown, root: string): string[] | undefined {
if (typeof command !== 'string') return;
// Bash keeps backslashes before ordinary characters in double quotes (e.g.
// a native Windows path); escapes, substitutions and shell operators stay out.
const literal = String.raw`(?:"(?:[^"\n\r$\x60\\]|\\[^"$\x60\\\n\r])*"|'[^'\n\r]*'|[^\s"'\\$\x60;&|<>]+)`;
const normalized = command.replace(/\\\r?\n/g, ' ');
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 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)) return;
// A mistyped tool path is unbindable, not a hook crash.
try { if (!samePath(fs.realpathSync(args[0]!), path.join(root, 'bin', 'gstack-autoplan-snapshot.ts'))) return; }
catch { return; }
return args.slice(1);
}
const INIT_MISMATCH_CAUSE = 'Snapshot init returned paths that do not match the canonical form of its own command arguments';
const INIT_FAILED_CAUSE = 'The most recent snapshot init call failed; re-run the same init command with the same three paths';
/** Diagnostic only, never binds: a Bash call that ran snapshot init in a shape the guard cannot bind (#3045). */
const UNBINDABLE_INIT = /(?:gstack-autoplan-snapshot|SNAPSHOT_TOOL|\$\{?[A-Z_]+\}?"?)\S*\s+init\s/;
function invocation(events: Event[], root: string): Invocation {
let bound: Invocation | undefined;
let chosen: Record<string, any> | undefined;
let unbindable = false;
// True while the most recent bindable init use errored (a later success clears it).
let lastErrored = false;
const tools = byTool(events);
for (const use of events) {
if (use.kind !== 'use' || use.name !== 'Bash') continue;
const args = initArguments(use.input?.command, root);
if (!args) { unbindable ||= typeof use.input?.command === 'string' && UNBINDABLE_INIT.test(use.input.command) && /\.md\b/.test(use.input.command); continue; }
const results = tools.results(use.toolUseId).filter(x => x.order > use.order);
if (results.length !== 1) fail('init_mismatch');
// An errored init published nothing; a later correct init may still bind.
if (results[0]!.isError === true) { lastErrored = true; continue; }
const text = textResult(results[0]!);
if (text === undefined) fail('init_failed');
const result = JSON.parse(text);
// init reports canonical destinations (macOS /tmp is /private/tmp); canonicalize the argv the same way.
let active: string, restore: string;
try { active = canonicalDestination(args[1]!); restore = canonicalDestination(args[2]!); }
catch { fail('init_mismatch', { cause: INIT_MISMATCH_CAUSE }); }
if (!object(result) || !samePath(result.sourcePlan, fs.realpathSync(args[0]!)) || !samePath(result.activePlan, active) ||
!samePath(result.restorePath, restore) || typeof result.reused !== 'boolean' || !positive(result.originalBytes) ||
!/^[a-f0-9]{64}$/.test(result.originalSha256)) fail('init_mismatch', { cause: INIT_MISMATCH_CAUSE });
lastErrored = false;
if (result.reused && bound && bound.activePlan === result.activePlan && bound.restorePath === result.restorePath) continue;
chosen = result;
bound = { activePlan: result.activePlan, restorePath: result.restorePath,
originalSha256: result.originalSha256, start: results[0]!.order };
}
if (lastErrored) fail('init_failed', { cause: INIT_FAILED_CAUSE });
if ((!chosen || !bound) && unbindable) fail('init_unbindable');
if (!chosen || !bound) fail('init_required');
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 ||
// 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('init_mismatch');
return bound;
}
/** A cache ACK reuses only an earlier native range whose bytes are still exact. */
export function autoplanReadRange(use: Use, result: Event, content: string, history: Event[] = []): { start: number; end: number } | undefined {
while (true) {
if (use.name !== 'Read' || result.kind !== 'result' || result.toolUseId !== use.toolUseId ||
result.sessionId !== use.sessionId || result.isError !== false || result.order <= use.order || !object(result.file)) return;
if (textResult(result) !== DEDUP_REPLY ||
!isDeepStrictEqual(Object.keys(result.file), ['filePath']) || !samePath(result.file.filePath, use.input?.file_path)) break;
// Pinned native dedup requires the same offset/limit and a non-truncated prior
// Read. Seeded-context notices without that native delivery supply no range.
const prior = history.filter((e): e is Use => e.kind === 'use' && e.name === 'Read' &&
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);
const replies = history.filter(e => e.kind === 'result' && e.sessionId === prior.sessionId && e.toolUseId === prior.toolUseId);
// The detector permits identical replayed records; conflicting native use
// or result payloads never establish a cache witness. The guard stays stricter.
if (uses.some(e => !sameRecord(e, prior)) || !replies.length || replies.some(e => !sameRecord(e, replies[0]!)) ||
replies[0]!.order >= use.order) return;
use = uses[0]!; result = replies[0]!;
}
const f = result.file, lines = content.split('\n');
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)) ||
// 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 };
}
function closePacket(file: string, phase: Phase, init: Invocation, current = true): string {
const directory = path.dirname(file), stateRoot = path.dirname(init.restorePath);
if (path.basename(file) !== 'close-packet.md' || path.dirname(directory) !== stateRoot ||
!path.basename(directory).startsWith(`autoplan-${phase}-`)) fail('close_stale', { cause: 'The close packet does not belong to the current phase' });
const content = read(file, true), binding = JSON.parse(/^Binding: (.+)$/m.exec(content)?.[1] ?? 'null');
const snapshot = JSON.parse(read(path.join(directory, 'snapshot.json'), true));
if (!object(binding) || binding.phase !== phase || binding.activePlan !== init.activePlan ||
binding.reviewInputPath !== path.join(directory, `${phase}-implementation.md`) ||
binding.report?.number !== String(number[phase]) || snapshot.schemaVersion !== 2 || snapshot.phase !== phase ||
snapshot.activePlan !== init.activePlan || snapshot.snapshotPath !== binding.reviewInputPath ||
snapshot.sha256 !== binding.reviewInputSha256 || snapshot.sourceSha256 !== binding.sourceSha256 ||
hash(read(binding.reviewInputPath, true)) !== binding.reviewInputSha256 ||
snapshot.sourceSnapshotPath !== path.join(directory, 'source-implementation.md') ||
hash(read(snapshot.sourceSnapshotPath, true)) !== binding.sourceSha256 ||
(current && hash(extractImplementationPlan(read(init.activePlan))) !== binding.sourceSha256))
fail('close_stale', { cause: 'The close packet no longer matches the current phase input' });
const checkpoint = binding.checkpointPath;
if (!ownPath(checkpoint) || path.dirname(path.dirname(checkpoint)) !== stateRoot ||
!path.basename(path.dirname(checkpoint)).startsWith(`autoplan-${phase}-`) || path.basename(checkpoint) !== `${phase}-implementation.md`)
fail('close_stale', { cause: 'The close checkpoint is foreign' });
const prior = JSON.parse(read(path.join(path.dirname(checkpoint), 'snapshot.json'), true));
if (prior.phase !== phase || prior.activePlan !== init.activePlan || prior.snapshotPath !== checkpoint ||
prior.sha256 !== hash(read(checkpoint, true))) fail('close_stale', { cause: 'The close checkpoint identity is unavailable' });
const methodology = snapshot.methodology;
if (!object(methodology) || !ownPath(methodology.methodologyPath) ||
path.dirname(path.dirname(methodology.methodologyPath)) !== stateRoot ||
!path.basename(path.dirname(methodology.methodologyPath)).startsWith(`autoplan-${phase}-`)) fail('close_stale', { cause: 'The close methodology is foreign' });
const manifestBytes = read(path.join(path.dirname(methodology.methodologyPath), 'methodology.json'), true);
const manifest = JSON.parse(manifestBytes);
if (hash(manifestBytes) !== methodology.manifestSha256 || manifest.phase !== phase ||
manifest.restorePath !== init.restorePath || manifest.restoreSha256 !== init.originalSha256 ||
manifest.methodologyPath !== methodology.methodologyPath || manifest.sha256 !== methodology.sha256 ||
hash(read(methodology.methodologyPath, true)) !== methodology.sha256) fail('close_stale', { cause: 'The close methodology belongs to a different invocation' });
if (current) try { checkPhaseImplementation(phase, init.activePlan, checkpoint,
prior.sourceSha256 === binding.sourceSha256 ? 'unchanged' : 'changed'); }
catch (error) { fail('close_edits', { cause: `The current Implementation plan fails its phase check: ${(error as Error).message}` }); }
return content;
}
/**
* A skill hook survives end_turn; unrelated human intervals are never phase
* evidence. One forward pass: entry i says whether the prefix events[0, i) is
* disarmed, so the last entry answers for the whole list. An end_turn while a
* reviewer Agent this session launched in the background has no completion
* notice does not end the invocation, so a typed turn then does not disarm it.
*/
function disarmedAt(events: Event[], root: string): boolean[] {
const out: boolean[] = [], launched = new Set<string>(), notified = new Set<string>(), reviewers = new Set<string>();
let endTurn = false, human: Turn | undefined, endBeforeHuman = false, initAfter = false;
for (const e of events) {
out.push(!!human && !human.autoplan && endBeforeHuman && !initAfter);
if (isUse(e) && e.name === 'Agent' && candidate(e, '')) reviewers.add(e.toolUseId);
else if (e.kind === 'result' && e.async && reviewers.has(e.toolUseId)) launched.add(e.toolUseId);
else if (e.kind === 'task_notification') notified.add(e.notifiedToolUseId);
else if (e.kind === 'end_turn' && ![...launched].some(id => !notified.has(id))) endTurn = true;
else if (e.kind === 'user_turn') { human = e; endBeforeHuman = endTurn; initAfter = false; }
else if (human && isUse(e) && e.name === 'Bash' && initArguments(e.input?.command, root)) initAfter = true;
}
out.push(!!human && !human.autoplan && endBeforeHuman && !initAfter);
return out;
}
/** Only exact reversible successful Edits can establish a report-only change. */
function verifyCloseEdits(events: Event[], closeOrder: number, init: Invocation, cwd: string): void {
const edits = events.filter((e): e is Use => e.kind === 'use' && e.order > closeOrder &&
['Write', 'Edit'].includes(e.name ?? '') && samePath(canonicalRequested(cwd, e.input?.file_path), init.activePlan));
if (!edits.length) return;
const current = read(init.activePlan), tools = byTool(events);
let prior = current;
for (const use of edits.toReversed()) {
const results = tools.results(use.toolUseId);
if (results.length !== 1) fail('mutation_pending');
if (results[0]!.isError === true) continue;
const input = use.input;
if (results[0]!.isError !== false || use.name !== 'Edit' || !object(input) ||
typeof input.old_string !== 'string' || !input.old_string || typeof input.new_string !== 'string' ||
!input.new_string || (input.replace_all !== undefined && input.replace_all !== false))
fail('close_edits', { cause: 'Post-close mutation history cannot be reconstructed exactly' });
const at = prior.indexOf(input.new_string);
if (at < 0 || prior.indexOf(input.new_string, at + input.new_string.length) !== -1)
fail('close_edits', { cause: 'Post-close Edit history is ambiguous or incomplete' });
const before = prior.slice(0, at) + input.old_string + prior.slice(at + input.new_string.length);
if (before.indexOf(input.old_string) !== at || before.indexOf(input.old_string, at + input.old_string.length) !== -1)
fail('close_edits', { cause: 'Post-close Edit history does not match its unique native old_string' });
prior = before;
}
const requirements = (plan: string) => {
const implementation = extractImplementationPlan(plan), at = plan.indexOf(implementation);
if (at < 0 || plan.indexOf(implementation, at + implementation.length) !== -1)
fail('close_edits', { cause: 'The review-record position is ambiguous' });
return [...acceptedBlocks(plan.slice(at + implementation.length))].map(([phase, block]) => [phase, block.raw]);
};
if (extractImplementationPlan(prior) !== extractImplementationPlan(current) ||
!isDeepStrictEqual(requirements(prior), requirements(current)))
fail('close_edits', { cause: 'The Implementation or accepted requirements changed after the close Read' });
}
/** Both messageIds known and equal; a record without one is its own message. */
const sameMessage = (a: Event, b: Event) => a.messageId !== undefined && a.messageId === b.messageId;
interface Flush { current: Use; journaled: boolean }
function requirePublication(phase: Phase, entryOrder: number, entered: Event[], init: Invocation, current: boolean,
cwd: string, checkpoint?: string, flush?: Flush): 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(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('close_required', { phase: number[phase] });
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('close_stale', { cause: `The Phase ${number[phase]} close packet belongs to an earlier checkpoint` });
let closeOrder = -1;
const tools = byTool(entered);
for (const use of closeReads.filter(e => native(e) === latestPath)) {
const results = tools.results(use.toolUseId);
if (results.length !== 1) continue;
const range = autoplanReadRange(use, results[0]!, content, entered);
if (!range) continue;
for (let line = range.start; line <= range.end; line++) covered.add(line);
closeOrder = Math.max(closeOrder, results[0]!.order);
}
if (covered.size !== content.split('\n').length) fail('close_incomplete', { phase: number[phase] });
const pending = entered.some(e => e.kind === 'use' && e.order > closeOrder && ['Write', 'Edit'].includes(e.name ?? '') &&
samePath(canonicalRequested(cwd, e.input?.file_path), init.activePlan) && !tools.results(e.toolUseId).length);
if (pending) fail('mutation_pending');
if (current) verifyCloseEdits(entered, closeOrder, init, cwd);
const reports = entered.filter((e): e is Event & { kind: 'message' } => e.kind === 'message' && e.order > closeOrder &&
autoplanPhaseCompletions({ status: 'ready', calls: [], assistantMessages: [e] }, 0).some(hit => hit.phase === number[phase]));
if (!reports.length) fail('publication_missing', { phase: number[phase] });
if (!flush) return;
// Text in the same assistant message as a guarded call can be invisible to
// PreToolUse. A report counts only when it is outside the journaled current
// call's message and a later journaled record (a result or another message)
// follows it, so the outcome never depends on when Claude Code flushes.
const later = flush.journaled ? [...entered, flush.current] : entered;
if (reports.some(r => !(flush.journaled && sameMessage(r, flush.current)) &&
later.some(e => e.order > r.order && (e.kind === 'result' || !sameMessage(e, r))))) return;
const repeated = entered.some(e => e.kind === 'result' && e.isError === true && JSON.stringify(e.content ?? '').includes('(code publication_unflushed'));
fail(repeated ? 'publication_repeat' : 'publication_unflushed', { phase: number[phase] });
}
/** The phase a guarded call enters, from its input alone (no artifact I/O). */
function targetPhase(use: { name?: string; input?: Record<string, unknown> }, cwd: string): Phase | undefined {
if (use.name === 'Read') return phaseName(use.input?.file_path, cwd) ?? artifactName(use.input?.file_path, cwd);
if (use.name === 'Agent' && typeof use.input?.prompt === 'string')
return /^You are the independent (CEO|DESIGN|DX|ENG) reviewer for this phase\.\n/.exec(use.input.prompt)?.[1]?.toLowerCase() as Phase | undefined;
}
/** Journal path: the current call is journaled; its record must match the payload exactly, minus documented strips. */
export const evaluateAutoplanPublication = (input: PublicationHookInput, root: string, events: Event[], claudeVersion?: string) =>
visible(journalDecision(input, root, events, claudeVersion));
/** Payload path; see payloadDecision. */
export const evaluatePayloadPublication = (input: PublicationHookInput, root: string, events: Event[]) =>
visible(payloadDecision(input, root, events));
const unguarded = (input: PublicationHookInput) => !candidate({ name: input.tool_name, input: input.tool_input }, input.cwd) || !!input.agent_id;
function journalDecision(input: PublicationHookInput, root: string, events: Event[], claudeVersion?: string): Decision {
return decideSafely(() => {
if (unguarded(input)) return { allow: true };
consistentEvents(input, events);
const current = events.filter((e): e is Use => isUse(e) && e.toolUseId === input.tool_use_id);
if (current.length !== 1 || current[0]!.name !== input.tool_name) fail('current_mismatch');
const journaled = nativeToolInput(current[0]!.input, input.cwd, input.tool_name) as Record<string, unknown>;
const payload = nativeToolInput(input.tool_input, input.cwd, input.tool_name) as Record<string, unknown>;
if (!isDeepStrictEqual(journaled, payload)) {
// A pure strip by a newer Claude Code's schema parse is its doing, not the model's.
const stripped = Object.keys(journaled).filter(key => !(key in payload));
if (stripped.length && newerThanChecked(claudeVersion) &&
isDeepStrictEqual(Object.fromEntries(Object.keys(payload).map(key => [key, journaled[key]])), payload))
fail('unseen_version', { keys: stripped.sort() });
fail('current_mismatch');
}
return phaseDecision(input, root, events.filter(e => e.order < current[0]!.order), current[0]!, true);
});
}
/**
* Payload path: a ready journal lacks the current call (Claude Code had not
* flushed it). The payload, placed after the last journaled record, stands in
* for it; the previous phase's report must still be journaled and flushed.
*/
function payloadDecision(input: PublicationHookInput, root: string, events: Event[]): Decision {
return decideSafely(() => {
if (unguarded(input)) return { allow: true };
consistentEvents(input, events);
if (events.some(e => (e.kind === 'use' || e.kind === 'result') && e.toolUseId === input.tool_use_id)) fail('current_mismatch');
const last = events.at(-1)!;
const current: Use = { kind: 'use', sessionId: input.session_id, timestamp: last.timestamp, order: last.order + 1,
toolUseId: input.tool_use_id, name: input.tool_name, input: input.tool_input };
// The payload has no messageId; the last journaled assistant message may be this call's.
const lastMessage = events.findLast(e => e.messageId !== undefined && e.kind !== 'result')?.messageId;
return phaseDecision(input, root, events, lastMessage ? { ...current, messageId: lastMessage } : current, false);
});
}
/** ENG-2: a tool still waiting for its result outside the last journaled message means the journal lags more than one message. */
export function journalLagged(events: Event[]): boolean {
const lastMessage = events.findLast(e => e.messageId !== undefined && e.kind !== 'result')?.messageId, tools = byTool(events);
return events.some(e => isUse(e) && (e.messageId === undefined || e.messageId !== lastMessage) && !tools.results(e.toolUseId).length);
}
function consistentEvents(input: PublicationHookInput, events: Event[]): void {
if (!events.length || events.some((e, i) => e.sessionId !== input.session_id || !Number.isSafeInteger(e.order) ||
(i > 0 && e.order <= events[i - 1]!.order))) fail('event_order');
const identities = new Set<string>();
for (const event of events) if (event.kind === 'use' || event.kind === 'result') {
const identity = `${event.kind}:${event.toolUseId}`;
if (identities.has(identity)) fail('tool_identity');
identities.add(identity);
}
}
function decideSafely(work: () => Decision): Decision {
try { return work(); } catch (error) {
const code = error instanceof BoundaryError ? error.code : 'evidence';
const detail = error instanceof BoundaryError ? error.detail : {};
if (REASONS[reasonCode(code)].disposition === 'unverified') return { allow: true, unverified: code, detail };
return { allow: false, reason: reasonText(code, detail), code, detail };
}
}
/** Shared by both paths. `current` is the journaled record or the payload stand-in; `before` precedes it. */
function phaseDecision(input: PublicationHookInput, root: string, before: Event[], current: Use, journaled: boolean): Decision {
const requested = { name: input.tool_name, input: input.tool_input };
// Pinned Claude retains skill hooks after end_turn. Only an authenticated
// later human request can release the old invocation; tool results and
// compaction never do. A native slash or an actual init re-arms the guard.
const disarmed = disarmedAt(before, root);
if (disarmed.at(-1)) return { allow: true };
const human = before.filter((e): e is Turn => e.kind === 'user_turn').at(-1);
if (human?.autoplan && !before.some(e => e.kind === 'use' && e.name === 'Bash' && e.order > human.order &&
initArguments(e.input?.command, root))) fail('init_own');
const init = invocation(before, root);
const entered = before.filter((e, i) => e.order > init.start && !disarmed[i]);
const target = consumption(requested, input.cwd, root, init)!.phase;
let phase: Phase | undefined, entryOrder = init.start, checkpoint: string | undefined;
const seenCheckpoints = new Set<string>(), preparedCheckpoints = new Map<Phase, string>(), tools = byTool(entered);
for (const use of entered) {
if (use.kind === 'result') {
let created: ReturnType<typeof checkpointResult>;
try { created = checkpointResult(use, tools, init); } catch { continue; }
if (!created || seenCheckpoints.has(created.path)) continue;
seenCheckpoints.add(created.path);
if (phase && number[created.phase] < number[phase]) {
// A fresh checkpoint reopens an affected phase after a later phase.
// Historical Reads and reflected create results do not reopen it.
phase = created.phase; entryOrder = use.order; checkpoint = created.path;
} else if (phase === created.phase) {
// CEO's later voice snapshot does not replace its Step-0 checkpoint.
checkpoint ??= created.path;
} else if (!preparedCheckpoints.has(created.phase)) preparedCheckpoints.set(created.phase, created.path);
continue;
}
if (!isUse(use) || !['Read', 'Agent'].includes(use.name ?? '')) continue;
const results = tools.results(use.toolUseId);
if (results.length !== 1 || results[0]!.isError !== false || results[0]!.order <= use.order) continue;
let next: Consumer | undefined;
try { next = consumption(use, input.cwd, root, init, true); } catch { continue; }
if (!next || (next.kind === 'Read' && !autoplanReadRange(use, results[0]!, next.content!, before))) continue;
if (!phase || number[next.phase] > number[phase]) {
// An unguarded earlier delivery cannot erase its predecessor's missing
// publication. Recovery still uses that predecessor's existing close.
if (phase) try { requirePublication(phase, entryOrder, entered.filter(e => e.order < use.order), init, false, input.cwd, checkpoint); }
catch { continue; }
phase = next.phase; entryOrder = use.order;
checkpoint = preparedCheckpoints.get(phase); preparedCheckpoints.delete(phase);
}
}
// One batch rule on both paths: guarded calls in the current call's message
// that target the same phase are one batch; another phase is never batched.
const siblings = new Set(entered.filter(e => isUse(e) && candidate(e, input.cwd) && sameMessage(e, current)));
if ([...siblings].some(e => targetPhase(e as Use, input.cwd) !== target)) fail('cross_phase_batch');
if (entered.some(e => e.kind === 'use' && candidate(e, input.cwd) && !siblings.has(e) &&
!tools.results(e.toolUseId).length)) fail('entry_pending');
if (!phase) {
if (target !== 'ceo') fail('phase_order');
return { allow: true };
}
if (number[target] <= number[phase]) return { allow: true };
requirePublication(phase, entryOrder, entered, init, true, input.cwd, checkpoint, { current, journaled });
return { allow: true };
}
/**
* 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. A project
* directory below the root (#3296) uses the first .git found walking up; a
* .git file there means a linked worktree or submodule, which has no registry.
*/
export function linkedWorktrees(projectDir: string): string[] {
let entries: fs.Dirent[], admin: string | undefined;
try {
for (let dir = path.resolve(projectDir); ; dir = path.dirname(dir)) {
const dotGit = fs.statSync(path.join(dir, '.git'), { throwIfNoEntry: false });
if (dotGit) { if (dotGit.isDirectory()) admin = path.join(dir, '.git', 'worktrees'); break; }
if (path.dirname(dir) === dir) break;
}
if (!admin) return [];
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: Decision | PublicationDecision): object {
return decision.allow ? {} : { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny',
permissionDecisionReason: `[autoplan] ${decision.reason}` } };
}
type EvaluationPath = 'journal' | 'payload' | 'none';
const mib = (bytes: number) => { const m = bytes / (1024 * 1024); return `${m >= 10 ? Math.round(m) : Number(m.toPrecision(2))} MiB`; };
/**
* UC1: Claude Code left this call uncheckable. No permissionDecision, so Claude
* Code's own permission check still runs; the user sees the warning (stderr and
* systemMessage), the model is reminded to publish, and the log says unverified.
*/
function unverifiedOutput(code: string, detail: Detail, version: string | undefined, via: EvaluationPath, recordTypes?: string[]): object {
logGuardDecision({ decision: 'allow', disposition: 'unverified', code, path: via, claudeVersion: version, recordTypes });
const notice = reasonText(code, detail, version);
return { systemMessage: `[autoplan] ${notice}`, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext:
`[autoplan] ${notice} This call proceeds unverified. Publish each completed phase report as your own parent ` +
'assistant text, in a message whose only tool call is the Bash no-op `true autoplan-published <phase>`, before entering the next phase.' } };
}
function decided(decision: Decision, via: EvaluationPath, version?: string): object {
if (decision.allow && decision.unverified) return unverifiedOutput(decision.unverified, decision.detail ?? {}, version, via);
if (decision.allow) { logGuardDecision({ decision: 'allow', disposition: 'allow', path: via, claudeVersion: version }); return {}; }
const code = decision.code, disposition = REASONS[reasonCode(code)].disposition as 'fallback' | 'corrective' | 'transient';
logGuardDecision({ decision: 'deny', disposition, code, path: via, claudeVersion: version,
unknownKeys: code === 'agent_key' ? decision.detail?.keys : undefined });
return publicationHookOutput({ allow: false, code, reason: reasonText(code, decision.detail, version) });
}
const denied = (code: ReasonCode, version?: string, detail: Detail = {}, via: EvaluationPath = 'none') =>
decided({ allow: false, code, reason: '', detail }, via, version);
/** Read the journal for every owner the hook accepts (project root, then its linked worktrees), with the hook's own classifiers. */
export function ownedRead(journal: string, owners: string[], input: PublicationHookInput, root: string, prior: JournalPrefix | undefined,
measure: OwnedReadMeasure): OwnedRead {
return readGuardJournal(journal, owners, input.session_id, input.tool_use_id, {
init: command => { try { return initArguments(command, root); } catch { return undefined; } },
read: file => artifactName(file, input.cwd, true) && path.basename(requestedPath(input.cwd, file as string)) === 'close-packet.md' ? 'close'
: phaseName(file, input.cwd) || artifactName(file, input.cwd) ? 'phase' : undefined,
reviewer: toolInput => candidate({ name: 'Agent', input: toolInput }, input.cwd),
}, prior, measure);
}
/**
* A ready journal holding the current call takes the journal path; one that
* lacks it takes the payload path at once (Claude Code may write the record only
* with its result). The 2-second window remains for a journal that does not exist
* yet, a journal lagging more than one message, a journal with no public event or
* none after its latest human turn (Claude Code 2.1.292 can flush the turn's
* earlier calls after this one's hook; at expiry an empty journal is journal lag
* and a human-turn tail takes the payload path), and the unrecognized-shape
* double read. Claude Code's appends never fail a read; a changed prefix is `rewritten`.
*/
export async function runPublicationHook(value: unknown, root: string): Promise<object> {
return approveOwnedRead(value, await guardOutput(value, root), root);
}
async function guardOutput(value: unknown, root: string): Promise<object> {
let version: string | undefined;
try {
if (!object(value) || value.hook_event_name !== 'PreToolUse' || typeof value.tool_name !== 'string') return denied('hook_input');
if (!['Read', 'Agent'].includes(value.tool_name) || value.agent_id) return {};
if (!ownPath(value.cwd) || !ownPath(value.transcript_path) || typeof value.session_id !== 'string' ||
typeof value.tool_use_id !== 'string' || !object(value.tool_input)) return denied('hook_input');
const input = value as PublicationHookInput;
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.
// 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)) return denied('hook_input');
const journal = nativePathSpelling(input.transcript_path), owners = [projectCwd, ...linkedWorktrees(projectCwd)];
const deadline = performance.now() + 2_000;
let read: OwnedRead, first: JournalPrefix | undefined, unrecognized = false;
for (;;) {
// Every later read must see the first read's bytes unchanged (CEO-2); a mismatch is `rewritten`.
const measure: OwnedReadMeasure = {};
read = ownedRead(journal, owners, input, root, first, measure);
first ??= measure.prefix;
version = read.claudeVersion ?? read.diagnostic?.claudeVersion;
const code = read.transcript.reason, expired = performance.now() >= deadline;
if (read.transcript.status === 'ready') {
if (read.events.some(e => e.kind === 'use' && e.toolUseId === input.tool_use_id))
return decided(journalDecision(input, root, read.events, version), 'journal', version);
const tail = read.events.at(-1)?.kind;
if (tail && !journalLagged(read.events) && (tail !== 'user_turn' || expired))
return decided(payloadDecision(input, root, read.events), 'payload', version);
if (expired) return unverifiedOutput('journal_lag', {}, version, 'payload');
} else if (code === 'too_large') {
return unverifiedOutput('too_large', { size: measure.recordBytes ? mib(measure.recordBytes) : undefined, limit: mib(ownedRecordLimit()) },
version, 'none');
} else if (code === 'rewritten') {
return unverifiedOutput('rewritten', {}, version, 'none');
} else if (code === 'oversized_invocation') {
return denied(code, version, { limit: mib(ownedRetainedLimit()) });
} else if (code?.startsWith('unrecognized_shape:')) {
// A second read over the same prefix: the shape is Claude Code's, not a write in progress.
if (unrecognized) return unverifiedOutput(code, { cause: code }, version, 'none', read.diagnostic?.rootShape);
unrecognized = true;
} else if (code) return denied(code as ReasonCode, version);
if (expired && !code?.startsWith('unrecognized_shape:')) break;
await new Promise(resolve => setTimeout(resolve, 50));
}
// A journal with bytes but no owned records yet: Claude Code has not written an ancestor.
if (read.diagnostic) return unverifiedOutput('journal_lag', {}, version, 'none');
return denied('journal_missing', version);
} catch {
return denied('installation', version);
}
}
if (import.meta.main) {
let output: object;
try {
const root = fs.realpathSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'));
const bytes = await Bun.stdin.text();
output = Buffer.byteLength(bytes) > 64 * 1024 ? denied('hook_input') : await runPublicationHook(JSON.parse(bytes), root);
} catch { output = denied('hook_input'); }
const warning = (output as { systemMessage?: string }).systemMessage;
if (warning) process.stderr.write(`${warning}\n`);
process.stdout.write(JSON.stringify(output) + '\n');
}