Skill Tracer
Skills › gstack › autoplan › phase-publication-hook.ts

phase-publication-hook.ts

783 lines · autoplan/bin/phase-publication-hook.ts at 3f79d81

1#!/usr/bin/env -S bun --no-env-file --config=/dev/null
2/** A native parent publication barrier at Autoplan's exact Read boundaries. */
3import * as fs from 'node:fs';
4import * as path from 'node:path';
5import * as os from 'node:os';
6import { createHash } from 'node:crypto';
7import { fileURLToPath } from 'node:url';
8import { isDeepStrictEqual } from 'node:util';
9import { extractImplementationPlan, checkPhaseImplementation, acceptedBlocks, canonicalDestination } from '../../bin/gstack-autoplan-snapshot';
10import { autoplanPhaseCompletions } from '../../lib/autoplan-phase-publication';
11import { nativePathSpelling, ownedNativePath, sameNativePath, type ClaudeParentPublicEvent } from '../../lib/claude-journal-records';
12import { ownedRecordLimit, ownedRetainedLimit, type JournalPrefix, type OwnedRead, type OwnedReadMeasure } from '../../lib/claude-owned-journal';
13import { readGuardJournal, textResult, DEDUP_REPLY } from './guard-journal';
14import { REASONS, reasonCode, reasonText, type Detail, type ReasonCode } from './guard-reasons';
15import { logGuardDecision } from './guard-log';
16import { approveOwnedRead, renderSectionBase, userRenderRoot } from './owned-read';
18const PHASES = ['ceo', 'design', 'dx', 'eng', 'tasks'] as const;
19type Phase = typeof PHASES[number];
20type Event = ClaudeParentPublicEvent;
21type Use = Event & { kind: 'use' };
22type Tool = Extract<Event, { toolUseId: string }>;
23type Turn = Extract<Event, { kind: 'end_turn' | 'user_turn' }>;
24const isUse = (e: Event): e is Use => e.kind === 'use';
25/** ENG-5: one pass groups each tool id's uses and results, so evaluation never rescans per tool. */
26function byTool(events: readonly Event[]) {
27 const uses = new Map<string, Use[]>(), results = new Map<string, Tool[]>();
28 for (const e of events) if (e.kind === 'use' || e.kind === 'result') {
29 const map = (e.kind === 'use' ? uses : results) as Map<string, Tool[]>, list = map.get(e.toolUseId) ?? [];
30 list.push(e); map.set(e.toolUseId, list);
31 }
32 return { uses: (id: string) => uses.get(id) ?? [], results: (id: string) => results.get(id) ?? [] };
34type Tools = ReturnType<typeof byTool>;
35const number: Record<Phase, number> = { ceo: 1, design: 2, dx: 2.5, eng: 3, tasks: 4 };
36const object = (x: unknown): x is Record<string, any> => x !== null && typeof x === 'object' && !Array.isArray(x);
37const positive = (x: unknown): x is number => Number.isSafeInteger(x) && (x as number) > 0;
38const hash = (x: string | Buffer) => createHash('sha256').update(x).digest('hex');
39/** Fold the native spelling first, then require it to be absolute and already normal (`..` stays rejected). */
40const ownPath = (value: unknown): value is string => ownedNativePath(value);
41/** One native spelling on both sides of every path compare. */
42const samePath = (a: unknown, b: unknown): boolean => sameNativePath(a, b);
43/** Claude's Read expands a leading ~ before the tool runs; resolve the same file. */
44const requestedPath = (cwd: string, file: string) =>
45 nativePathSpelling(path.resolve(cwd, file.replace(/^~(?=[\\/]|$)/, () => os.homedir())));
46/**
47 * The file a Write/Edit path names, realpathed so a symlinked plan directory
48 * compares equal to init's canonical paths. realpath resolves a leaf link too; a
49 * missing target or dangling link keeps the literal spelling. An aliased active
50 * plan never binds, because invocation() reads it through read(), which denies aliases.
51 */
52const canonicalRequested = (cwd: string, file: unknown): string | undefined => {
53 if (typeof file !== 'string') return;
54 const literal = requestedPath(cwd, file);
55 try { return fs.realpathSync(literal); } catch { return literal; }
56};
57/** Keys Claude Code's schema parse may drop from the journaled raw input (Agent's fork-subagent gate drops run_in_background). */
58export const SCHEMA_STRIPPED: Record<string, readonly string[]> = { Agent: ['run_in_background'] };
59/** The reviewer dispatch input the guard accepts; snapshot manifests are unchanged by it. */
60export const AGENT_KEYS: readonly string[] = ['prompt', 'description', 'subagent_type', 'run_in_background'];
61/** The newest Claude Code release the pinned PTY runs and the schema canary have checked. */
62export const CHECKED_CLAUDE_CODE = '2.1.292';
63/**
64 * Claude records the model's raw input in the journal but hands PreToolUse its
65 * schema-parsed form: file_path resolved (C:\x/y becomes C:\x\y) and keys the
66 * schema lacks dropped. Compare file_path as the file it names, drop only the
67 * documented strips; every other field stays exact.
68 */
69export const nativeToolInput = (input: unknown, cwd: string, tool?: string): unknown => {
70 if (!object(input)) return input;
71 const out: Record<string, unknown> = { ...input };
72 if (typeof out.file_path === 'string') out.file_path = requestedPath(cwd, out.file_path);
73 for (const key of SCHEMA_STRIPPED[tool ?? ''] ?? []) delete out[key];
74 return out;
75};
76const versionParts = (v: string) => /^(\d+)\.(\d+)\.(\d+)/.exec(v)?.slice(1).map(Number);
77/** True only for a parseable version above the checked release. */
78export function newerThanChecked(version: string | undefined): boolean {
79 const a = version && versionParts(version), b = versionParts(CHECKED_CLAUDE_CODE)!;
80 if (!a) return false;
81 for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i]! > b[i]!;
82 return false;
84class BoundaryError extends Error {
85 constructor(readonly code: string, readonly detail: Detail = {}) { super(code); }
87function fail(code: ReasonCode | `unrecognized_shape:${string}`, detail?: Detail): never { throw new BoundaryError(code, detail); }
88export interface PublicationHookInput {
89 hook_event_name: 'PreToolUse'; session_id: string; transcript_path: string; cwd: string;
90 tool_name: string; tool_use_id: string; tool_input: Record<string, unknown>; agent_id?: string | null;
92/** What tests and callers see; the hook runner keeps the code and detail. */
93export type PublicationDecision = { allow: true; unverified?: string } | { allow: false; reason: string };
94type Decision = { allow: true; unverified?: string; detail?: Detail } | { allow: false; reason: string; code: string; detail?: Detail };
95const visible = (d: Decision): PublicationDecision =>
96 d.allow ? (d.unverified ? { allow: true, unverified: d.unverified } : { allow: true }) : { allow: false, reason: d.reason };
97interface Invocation { activePlan: string; restorePath: string; originalSha256: string; start: number }
99/** Stable, bounded regular bytes; links never establish an artifact identity. */
100function read(file: string, immutable = false): string {
101 if (!ownPath(file) || !samePath(fs.realpathSync(file), file)) fail('snapshot', { cause: 'An artifact path is unavailable or aliased' });
102 const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
103 try {
104 const before = fs.fstatSync(fd, { bigint: true });
105 if (!before.isFile() || before.size > 32n * 1024n * 1024n ||
106 (immutable && process.platform !== 'win32' && (before.mode & 0o222n) !== 0n)) fail('snapshot', { cause: 'An artifact is not immutable bounded data' });
107 const bytes = fs.readFileSync(fd), after = fs.fstatSync(fd, { bigint: true }), current = fs.lstatSync(file, { bigint: true });
108 if (!current.isFile() || before.dev !== current.dev || before.ino !== current.ino ||
109 before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.size !== current.size ||
110 before.mtimeNs !== current.mtimeNs || before.size !== BigInt(bytes.length)) fail('snapshot', { cause: 'An artifact changed during read' });
111 const text = bytes.toString('utf8');
112 if (!Buffer.from(text).equals(bytes)) fail('snapshot', { cause: 'An artifact is not complete UTF-8' });
113 return text;
114 } finally { fs.closeSync(fd); }
117function phaseName(file: unknown, cwd: string): Phase | undefined {
118 if (typeof file !== 'string') return;
119 const requested = requestedPath(cwd, file);
120 const name = /^((?:ceo|design|dx|eng)-phase|tasks-aggregator)\.md$/.exec(path.basename(requested));
121 if (!name || path.basename(path.dirname(requested)) !== 'sections' ||
122 path.basename(path.dirname(path.dirname(requested))) !== 'autoplan') return;
123 return (name[1] === 'tasks-aggregator' ? 'tasks' : name[1]!.split('-')[0]) as Phase;
126function driver(file: unknown, cwd: string, root: string): Phase | undefined {
127 const phase = phaseName(file, cwd);
128 if (!phase) return;
129 const requested = requestedPath(cwd, file as string), base = path.basename(requested);
130 const canonical = path.join(root, 'autoplan', 'sections', base), actual = fs.realpathSync(requested);
131 if (samePath(fs.realpathSync(canonical), canonical)) {
132 if (samePath(actual, canonical)) return phase;
133 // setup serves the gbrain :user render (#2569) from exactly one render root,
134 // whose section links point back into it. That render is this installation
135 // only at its exact path and only with the install's bytes after that rewrite.
136 const render = userRenderRoot();
137 if (render && samePath(actual, path.join(render, 'autoplan', 'sections', base)) &&
138 read(actual) === renderSectionBase(read(canonical), render)) return phase;
139 }
140 return fail('foreign_install');
143interface Consumer { phase: Phase; content?: string; kind: 'Read' | 'Agent' }
144function artifactName(file: unknown, cwd: string, includeClose = false): Phase | undefined {
145 if (typeof file !== 'string') return;
146 const requested = requestedPath(cwd, file), base = path.basename(requested);
147 const match = /^autoplan-(ceo|design|dx|eng)-.+$/.exec(path.basename(path.dirname(requested)));
148 if (!match || !['methodology.md', 'methodology.json', 'native-prompt.md', 'snapshot.json',
149 'source-implementation.md', `${match[1]}-implementation.md`, ...(includeClose ? ['close-packet.md'] : [])].includes(base)) return;
150 return match[1] as Phase;
152function candidate(use: { name?: string; input?: Record<string, unknown> }, cwd: string): boolean {
153 return use.name === 'Read' ? !!(phaseName(use.input?.file_path, cwd) || artifactName(use.input?.file_path, cwd)) :
154 use.name === 'Agent' && typeof use.input?.prompt === 'string' &&
155 /^You are the independent (CEO|DESIGN|DX|ENG) reviewer for this phase\.\n/.test(use.input.prompt);
157function methodology(file: string, phase: Phase, init: Invocation) {
158 const directory = path.dirname(file);
159 if (path.basename(file) !== 'methodology.md' || path.dirname(directory) !== path.dirname(init.restorePath) ||
160 !path.basename(directory).startsWith(`autoplan-${phase}-methodology-`)) fail('snapshot', { cause: 'Methodology belongs to a different invocation' });
161 const content = read(file, true), manifestBytes = read(path.join(directory, 'methodology.json'), true);
162 const manifest = JSON.parse(manifestBytes);
163 if (!object(manifest) || manifest.phase !== phase || manifest.restorePath !== init.restorePath ||
164 manifest.restoreSha256 !== init.originalSha256 || manifest.methodologyPath !== file ||
165 manifest.sha256 !== hash(content) || manifest.bytes !== Buffer.byteLength(content) ||
166 manifest.lines !== content.split('\n').length) fail('snapshot', { cause: 'Methodology identity does not match this invocation' });
167 return { content, manifest, manifestBytes };
169function snapshot(directory: string, phase: Phase, init: Invocation) {
170 if (path.dirname(directory) !== path.dirname(init.restorePath) ||
171 !path.basename(directory).startsWith(`autoplan-${phase}-`)) fail('snapshot', { cause: 'Native phase snapshot belongs to a different invocation' });
172 const manifest = JSON.parse(read(path.join(directory, 'snapshot.json'), true));
173 if (!object(manifest) || manifest.schemaVersion !== 2 || manifest.phase !== phase || manifest.activePlan !== init.activePlan ||
174 manifest.snapshotPath !== path.join(directory, `${phase}-implementation.md`) ||
175 manifest.sourceSnapshotPath !== path.join(directory, 'source-implementation.md') ||
176 manifest.nativePromptPath !== path.join(directory, 'native-prompt.md') || !object(manifest.methodology))
177 fail('snapshot', { cause: 'Native phase snapshot does not match this active plan' });
178 const implementation = read(manifest.snapshotPath, true), source = read(manifest.sourceSnapshotPath, true);
179 const native = read(manifest.nativePromptPath, true), m = methodology(manifest.methodology.methodologyPath, phase, init);
180 if (manifest.sha256 !== hash(implementation) || manifest.sourceSha256 !== hash(source) ||
181 manifest.sourceBytes !== Buffer.byteLength(source) || manifest.nativePromptSha256 !== hash(native) ||
182 manifest.nativePromptBytes !== Buffer.byteLength(native) || manifest.nativePromptLines !== native.split('\n').length ||
183 manifest.methodology.manifestSha256 !== hash(m.manifestBytes) || manifest.methodology.sha256 !== hash(m.content) ||
184 manifest.methodology.bytes !== Buffer.byteLength(m.content) || manifest.methodology.lines !== m.content.split('\n').length)
185 fail('snapshot', { cause: 'Native phase snapshot bytes are unavailable or changed' });
186 return manifest;
188function consumption(use: { name?: string; input?: Record<string, unknown> }, cwd: string, root: string,
189 init: Invocation, includeClose = false): Consumer | undefined {
190 if (use.name === 'Read') {
191 const direct = driver(use.input?.file_path, cwd, root);
192 if (direct) return { phase: direct, kind: 'Read', content: read(fs.realpathSync(requestedPath(cwd, use.input!.file_path as string))) };
193 const phase = artifactName(use.input?.file_path, cwd, includeClose);
194 if (!phase) return;
195 const file = requestedPath(cwd, use.input!.file_path as string), base = path.basename(file);
196 if (base === 'methodology.md' || base === 'methodology.json') {
197 const m = methodology(path.join(path.dirname(file), 'methodology.md'), phase, init);
198 return { phase, kind: 'Read', content: base === 'methodology.md' ? m.content : m.manifestBytes };
199 }
200 snapshot(path.dirname(file), phase, init);
201 if (base === 'close-packet.md') closePacket(file, phase, init, false);
202 return { phase, kind: 'Read', content: read(file, true) };
203 }
204 if (use.name !== 'Agent' || typeof use.input?.prompt !== 'string') return;
205 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;
206 if (!phase) return;
207 const extra = Object.keys(use.input).filter(key => !AGENT_KEYS.includes(key));
208 if (use.input.subagent_type !== undefined && use.input.subagent_type !== 'general-purpose') extra.push('subagent_type');
209 if (extra.length) fail('agent_key', { keys: extra.sort() });
210 const file = JSON.parse(/^Read file: ("[^\n]+")$/m.exec(prompt)?.[1] ?? 'null');
211 if (!ownPath(file) || path.basename(file) !== 'native-prompt.md') fail('snapshot', { cause: 'Native phase dispatch is not bound to its immutable input' });
212 const manifest = snapshot(path.dirname(file), phase, init);
213 if (manifest.nativePromptPath !== file || manifest.nativeDispatchPrompt !== prompt) fail('dispatch_prompt');
214 return { phase, kind: 'Agent' };
217/** The early test detector uses these same artifact checks, with its owned public events. */
218export function boundAutoplanPhaseConsumption(events: Event[], use: Use, cwd: string, root: string): Consumer | undefined {
219 if (!candidate(use, cwd)) return;
220 return consumption(use, cwd, root, invocation(events.filter(e => e.order < use.order), root));
223/** Authenticate the existing direct-create result; this does not prove its shell command's origin. */
224function checkpointResult(result: Event, tools: Tools, init: Invocation): { phase: Phase; path: string } | undefined {
225 if (result.kind !== 'result') return;
226 const use = tools.uses(result.toolUseId)[0];
227 if (use?.name !== 'Bash' || use.order >= result.order) return;
228 const text = textResult(result);
229 if (text === undefined) return;
230 const output = JSON.parse(text);
231 if (!object(output) || !['ceo', 'design', 'dx', 'eng'].includes(output.phase) ||
232 !ownPath(output.snapshotPath) || typeof output.nativePrompt !== 'string' || !object(output.baselineEdits)) return;
233 const { nativePrompt, baselineEdits, ...identity } = output;
234 const manifest = snapshot(path.dirname(output.snapshotPath), output.phase, init);
235 if (!isDeepStrictEqual(identity, manifest) || nativePrompt !== read(manifest.nativePromptPath, true) ||
236 baselineEdits.record !== `<!-- autoplan-baseline-edits:${output.phase} ${JSON.stringify({ sourceSha256: manifest.sourceSha256, replacements: [] })} -->` ||
237 typeof baselineEdits.instructions !== 'string') return;
238 return { phase: output.phase, path: output.snapshotPath };
241/** Only the documented literal init argv, optionally after literal cd. No shell evaluation. */
242function initArguments(command: unknown, root: string): string[] | undefined {
243 if (typeof command !== 'string') return;
244 // Bash keeps backslashes before ordinary characters in double quotes (e.g.
245 // a native Windows path); escapes, substitutions and shell operators stay out.
246 const literal = String.raw`(?:"(?:[^"\n\r$\x60\\]|\\[^"$\x60\\\n\r])*"|'[^'\n\r]*'|[^\s"'\\$\x60;&|<>]+)`;
247 const normalized = command.replace(/\\\r?\n/g, ' ');
248 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);
249 if (!match) return;
250 const args = match.slice(1).map(x => /^["']/.test(x!) ? x!.slice(1, -1) : x!)
251 // Git Bash accepts forward slashes and hands native programs /c/x as C:/x.
252 // Fold that spelling only; every canonical-path check still applies.
253 .map(x => nativePathSpelling(x, path));
254 if (!args.every(ownPath)) return;
255 // A mistyped tool path is unbindable, not a hook crash.
256 try { if (!samePath(fs.realpathSync(args[0]!), path.join(root, 'bin', 'gstack-autoplan-snapshot.ts'))) return; }
257 catch { return; }
258 return args.slice(1);
261const INIT_MISMATCH_CAUSE = 'Snapshot init returned paths that do not match the canonical form of its own command arguments';
262const INIT_FAILED_CAUSE = 'The most recent snapshot init call failed; re-run the same init command with the same three paths';
264/** Diagnostic only, never binds: a Bash call that ran snapshot init in a shape the guard cannot bind (#3045). */
265const UNBINDABLE_INIT = /(?:gstack-autoplan-snapshot|SNAPSHOT_TOOL|\$\{?[A-Z_]+\}?"?)\S*\s+init\s/;
267function invocation(events: Event[], root: string): Invocation {
268 let bound: Invocation | undefined;
269 let chosen: Record<string, any> | undefined;
270 let unbindable = false;
271 // True while the most recent bindable init use errored (a later success clears it).
272 let lastErrored = false;
273 const tools = byTool(events);
274 for (const use of events) {
275 if (use.kind !== 'use' || use.name !== 'Bash') continue;
276 const args = initArguments(use.input?.command, root);
277 if (!args) { unbindable ||= typeof use.input?.command === 'string' && UNBINDABLE_INIT.test(use.input.command) && /\.md\b/.test(use.input.command); continue; }
278 const results = tools.results(use.toolUseId).filter(x => x.order > use.order);
279 if (results.length !== 1) fail('init_mismatch');
280 // An errored init published nothing; a later correct init may still bind.
281 if (results[0]!.isError === true) { lastErrored = true; continue; }
282 const text = textResult(results[0]!);
283 if (text === undefined) fail('init_failed');
284 const result = JSON.parse(text);
285 // init reports canonical destinations (macOS /tmp is /private/tmp); canonicalize the argv the same way.
286 let active: string, restore: string;
287 try { active = canonicalDestination(args[1]!); restore = canonicalDestination(args[2]!); }
288 catch { fail('init_mismatch', { cause: INIT_MISMATCH_CAUSE }); }
289 if (!object(result) || !samePath(result.sourcePlan, fs.realpathSync(args[0]!)) || !samePath(result.activePlan, active) ||
290 !samePath(result.restorePath, restore) || typeof result.reused !== 'boolean' || !positive(result.originalBytes) ||
291 !/^[a-f0-9]{64}$/.test(result.originalSha256)) fail('init_mismatch', { cause: INIT_MISMATCH_CAUSE });
292 lastErrored = false;
293 if (result.reused && bound && bound.activePlan === result.activePlan && bound.restorePath === result.restorePath) continue;
294 chosen = result;
295 bound = { activePlan: result.activePlan, restorePath: result.restorePath,
296 originalSha256: result.originalSha256, start: results[0]!.order };
297 }
298 if (lastErrored) fail('init_failed', { cause: INIT_FAILED_CAUSE });
299 if ((!chosen || !bound) && unbindable) fail('init_unbindable');
300 if (!chosen || !bound) fail('init_required');
301 const restore = read(bound.restorePath, true), active = read(bound.activePlan);
302 const reference = JSON.stringify(bound.restorePath).replace(/--/g, '\\u002d\\u002d');
303 if (hash(restore) !== bound.originalSha256 || Buffer.byteLength(restore) !== chosen.originalBytes ||
304 // Claude's Edit rewrites a CRLF plan's line endings, including init's LF header line.
305 !/^\r?\n/.test(active.slice(`<!-- /autoplan restore point: ${reference} -->`.length)) ||
306 !active.startsWith(`<!-- /autoplan restore point: ${reference} -->`) || bound.activePlan === bound.restorePath)
307 fail('init_mismatch');
308 return bound;
311/** A cache ACK reuses only an earlier native range whose bytes are still exact. */
312export function autoplanReadRange(use: Use, result: Event, content: string, history: Event[] = []): { start: number; end: number } | undefined {
313 while (true) {
314 if (use.name !== 'Read' || result.kind !== 'result' || result.toolUseId !== use.toolUseId ||
315 result.sessionId !== use.sessionId || result.isError !== false || result.order <= use.order || !object(result.file)) return;
316 if (textResult(result) !== DEDUP_REPLY ||
317 !isDeepStrictEqual(Object.keys(result.file), ['filePath']) || !samePath(result.file.filePath, use.input?.file_path)) break;
318 // Pinned native dedup requires the same offset/limit and a non-truncated prior
319 // Read. Seeded-context notices without that native delivery supply no range.
320 const prior = history.filter((e): e is Use => e.kind === 'use' && e.name === 'Read' &&
321 e.sessionId === use.sessionId && e.order < use.order && samePath(e.input?.file_path, use.input?.file_path)).at(-1);
322 if (!prior || (prior.input?.offset ?? 1) !== (use.input?.offset ?? 1) || prior.input?.limit !== use.input?.limit) return;
323 const sameRecord = (a: Event, b: Event) => isDeepStrictEqual({ ...a, order: 0 }, { ...b, order: 0 });
324 const uses = history.filter((e): e is Use => e.kind === 'use' && e.sessionId === prior.sessionId && e.toolUseId === prior.toolUseId);
325 const replies = history.filter(e => e.kind === 'result' && e.sessionId === prior.sessionId && e.toolUseId === prior.toolUseId);
326 // The detector permits identical replayed records; conflicting native use
327 // or result payloads never establish a cache witness. The guard stays stricter.
328 if (uses.some(e => !sameRecord(e, prior)) || !replies.length || replies.some(e => !sameRecord(e, replies[0]!)) ||
329 replies[0]!.order >= use.order) return;
330 use = uses[0]!; result = replies[0]!;
331 }
332 const f = result.file, lines = content.split('\n');
333 if (!samePath(f.filePath, use.input?.file_path) || typeof f.content !== 'string' || !positive(f.startLine) || !positive(f.numLines) ||
334 f.totalLines !== lines.length || f.startLine + f.numLines - 1 > lines.length || (use.input?.offset ?? 1) !== f.startLine ||
335 (use.input?.limit !== undefined && (!positive(use.input.limit) || f.numLines > use.input.limit)) ||
336 // Claude's Read reports a CRLF line without its CR; nothing else may differ.
337 f.content.replace(/\r(?=\n|$)/g, '') !==
338 lines.slice(f.startLine - 1, f.startLine - 1 + f.numLines).join('\n').replace(/\r(?=\n|$)/g, '')) return;
339 return { start: f.startLine, end: f.startLine + f.numLines - 1 };
342function closePacket(file: string, phase: Phase, init: Invocation, current = true): string {
343 const directory = path.dirname(file), stateRoot = path.dirname(init.restorePath);
344 if (path.basename(file) !== 'close-packet.md' || path.dirname(directory) !== stateRoot ||
345 !path.basename(directory).startsWith(`autoplan-${phase}-`)) fail('close_stale', { cause: 'The close packet does not belong to the current phase' });
346 const content = read(file, true), binding = JSON.parse(/^Binding: (.+)$/m.exec(content)?.[1] ?? 'null');
347 const snapshot = JSON.parse(read(path.join(directory, 'snapshot.json'), true));
348 if (!object(binding) || binding.phase !== phase || binding.activePlan !== init.activePlan ||
349 binding.reviewInputPath !== path.join(directory, `${phase}-implementation.md`) ||
350 binding.report?.number !== String(number[phase]) || snapshot.schemaVersion !== 2 || snapshot.phase !== phase ||
351 snapshot.activePlan !== init.activePlan || snapshot.snapshotPath !== binding.reviewInputPath ||
352 snapshot.sha256 !== binding.reviewInputSha256 || snapshot.sourceSha256 !== binding.sourceSha256 ||
353 hash(read(binding.reviewInputPath, true)) !== binding.reviewInputSha256 ||
354 snapshot.sourceSnapshotPath !== path.join(directory, 'source-implementation.md') ||
355 hash(read(snapshot.sourceSnapshotPath, true)) !== binding.sourceSha256 ||
356 (current && hash(extractImplementationPlan(read(init.activePlan))) !== binding.sourceSha256))
357 fail('close_stale', { cause: 'The close packet no longer matches the current phase input' });
358 const checkpoint = binding.checkpointPath;
359 if (!ownPath(checkpoint) || path.dirname(path.dirname(checkpoint)) !== stateRoot ||
360 !path.basename(path.dirname(checkpoint)).startsWith(`autoplan-${phase}-`) || path.basename(checkpoint) !== `${phase}-implementation.md`)
361 fail('close_stale', { cause: 'The close checkpoint is foreign' });
362 const prior = JSON.parse(read(path.join(path.dirname(checkpoint), 'snapshot.json'), true));
363 if (prior.phase !== phase || prior.activePlan !== init.activePlan || prior.snapshotPath !== checkpoint ||
364 prior.sha256 !== hash(read(checkpoint, true))) fail('close_stale', { cause: 'The close checkpoint identity is unavailable' });
365 const methodology = snapshot.methodology;
366 if (!object(methodology) || !ownPath(methodology.methodologyPath) ||
367 path.dirname(path.dirname(methodology.methodologyPath)) !== stateRoot ||
368 !path.basename(path.dirname(methodology.methodologyPath)).startsWith(`autoplan-${phase}-`)) fail('close_stale', { cause: 'The close methodology is foreign' });
369 const manifestBytes = read(path.join(path.dirname(methodology.methodologyPath), 'methodology.json'), true);
370 const manifest = JSON.parse(manifestBytes);
371 if (hash(manifestBytes) !== methodology.manifestSha256 || manifest.phase !== phase ||
372 manifest.restorePath !== init.restorePath || manifest.restoreSha256 !== init.originalSha256 ||
373 manifest.methodologyPath !== methodology.methodologyPath || manifest.sha256 !== methodology.sha256 ||
374 hash(read(methodology.methodologyPath, true)) !== methodology.sha256) fail('close_stale', { cause: 'The close methodology belongs to a different invocation' });
375 if (current) try { checkPhaseImplementation(phase, init.activePlan, checkpoint,
376 prior.sourceSha256 === binding.sourceSha256 ? 'unchanged' : 'changed'); }
377 catch (error) { fail('close_edits', { cause: `The current Implementation plan fails its phase check: ${(error as Error).message}` }); }
378 return content;
381/**
382 * A skill hook survives end_turn; unrelated human intervals are never phase
383 * evidence. One forward pass: entry i says whether the prefix events[0, i) is
384 * disarmed, so the last entry answers for the whole list. An end_turn while a
385 * reviewer Agent this session launched in the background has no completion
386 * notice does not end the invocation, so a typed turn then does not disarm it.
387 */
388function disarmedAt(events: Event[], root: string): boolean[] {
389 const out: boolean[] = [], launched = new Set<string>(), notified = new Set<string>(), reviewers = new Set<string>();
390 let endTurn = false, human: Turn | undefined, endBeforeHuman = false, initAfter = false;
391 for (const e of events) {
392 out.push(!!human && !human.autoplan && endBeforeHuman && !initAfter);
393 if (isUse(e) && e.name === 'Agent' && candidate(e, '')) reviewers.add(e.toolUseId);
394 else if (e.kind === 'result' && e.async && reviewers.has(e.toolUseId)) launched.add(e.toolUseId);
395 else if (e.kind === 'task_notification') notified.add(e.notifiedToolUseId);
396 else if (e.kind === 'end_turn' && ![...launched].some(id => !notified.has(id))) endTurn = true;
397 else if (e.kind === 'user_turn') { human = e; endBeforeHuman = endTurn; initAfter = false; }
398 else if (human && isUse(e) && e.name === 'Bash' && initArguments(e.input?.command, root)) initAfter = true;
399 }
400 out.push(!!human && !human.autoplan && endBeforeHuman && !initAfter);
401 return out;
404/** Only exact reversible successful Edits can establish a report-only change. */
405function verifyCloseEdits(events: Event[], closeOrder: number, init: Invocation, cwd: string): void {
406 const edits = events.filter((e): e is Use => e.kind === 'use' && e.order > closeOrder &&
407 ['Write', 'Edit'].includes(e.name ?? '') && samePath(canonicalRequested(cwd, e.input?.file_path), init.activePlan));
408 if (!edits.length) return;
409 const current = read(init.activePlan), tools = byTool(events);
410 let prior = current;
411 for (const use of edits.toReversed()) {
412 const results = tools.results(use.toolUseId);
413 if (results.length !== 1) fail('mutation_pending');
414 if (results[0]!.isError === true) continue;
415 const input = use.input;
416 if (results[0]!.isError !== false || use.name !== 'Edit' || !object(input) ||
417 typeof input.old_string !== 'string' || !input.old_string || typeof input.new_string !== 'string' ||
418 !input.new_string || (input.replace_all !== undefined && input.replace_all !== false))
419 fail('close_edits', { cause: 'Post-close mutation history cannot be reconstructed exactly' });
420 const at = prior.indexOf(input.new_string);
421 if (at < 0 || prior.indexOf(input.new_string, at + input.new_string.length) !== -1)
422 fail('close_edits', { cause: 'Post-close Edit history is ambiguous or incomplete' });
423 const before = prior.slice(0, at) + input.old_string + prior.slice(at + input.new_string.length);
424 if (before.indexOf(input.old_string) !== at || before.indexOf(input.old_string, at + input.old_string.length) !== -1)
425 fail('close_edits', { cause: 'Post-close Edit history does not match its unique native old_string' });
426 prior = before;
427 }
428 const requirements = (plan: string) => {
429 const implementation = extractImplementationPlan(plan), at = plan.indexOf(implementation);
430 if (at < 0 || plan.indexOf(implementation, at + implementation.length) !== -1)
431 fail('close_edits', { cause: 'The review-record position is ambiguous' });
432 return [...acceptedBlocks(plan.slice(at + implementation.length))].map(([phase, block]) => [phase, block.raw]);
433 };
434 if (extractImplementationPlan(prior) !== extractImplementationPlan(current) ||
435 !isDeepStrictEqual(requirements(prior), requirements(current)))
436 fail('close_edits', { cause: 'The Implementation or accepted requirements changed after the close Read' });
439/** Both messageIds known and equal; a record without one is its own message. */
440const sameMessage = (a: Event, b: Event) => a.messageId !== undefined && a.messageId === b.messageId;
441interface Flush { current: Use; journaled: boolean }
442function requirePublication(phase: Phase, entryOrder: number, entered: Event[], init: Invocation, current: boolean,
443 cwd: string, checkpoint?: string, flush?: Flush): void {
444 const native = (e: Event) => e.kind === 'use' && typeof e.input?.file_path === 'string' ? nativePathSpelling(e.input.file_path) : undefined;
445 const closeReads = entered.filter((e): e is Use => e.kind === 'use' && e.name === 'Read' && e.order >= entryOrder &&
446 ownPath(native(e)) && path.basename(native(e)!) === 'close-packet.md' &&
447 samePath(path.dirname(path.dirname(native(e)!)), path.dirname(init.restorePath)) &&
448 path.basename(path.dirname(native(e)!)).startsWith(`autoplan-${phase}-`));
449 if (!closeReads.length) fail('close_required', { phase: number[phase] });
450 const latestPath = native(closeReads.at(-1)!)!;
451 const content = closePacket(latestPath, phase, init, current), covered = new Set<number>();
452 if (checkpoint && JSON.parse(/^Binding: (.+)$/m.exec(content)![1]!).checkpointPath !== checkpoint)
453 fail('close_stale', { cause: `The Phase ${number[phase]} close packet belongs to an earlier checkpoint` });
454 let closeOrder = -1;
455 const tools = byTool(entered);
456 for (const use of closeReads.filter(e => native(e) === latestPath)) {
457 const results = tools.results(use.toolUseId);
458 if (results.length !== 1) continue;
459 const range = autoplanReadRange(use, results[0]!, content, entered);
460 if (!range) continue;
461 for (let line = range.start; line <= range.end; line++) covered.add(line);
462 closeOrder = Math.max(closeOrder, results[0]!.order);
463 }
464 if (covered.size !== content.split('\n').length) fail('close_incomplete', { phase: number[phase] });
465 const pending = entered.some(e => e.kind === 'use' && e.order > closeOrder && ['Write', 'Edit'].includes(e.name ?? '') &&
466 samePath(canonicalRequested(cwd, e.input?.file_path), init.activePlan) && !tools.results(e.toolUseId).length);
467 if (pending) fail('mutation_pending');
468 if (current) verifyCloseEdits(entered, closeOrder, init, cwd);
469 const reports = entered.filter((e): e is Event & { kind: 'message' } => e.kind === 'message' && e.order > closeOrder &&
470 autoplanPhaseCompletions({ status: 'ready', calls: [], assistantMessages: [e] }, 0).some(hit => hit.phase === number[phase]));
471 if (!reports.length) fail('publication_missing', { phase: number[phase] });
472 if (!flush) return;
473 // Text in the same assistant message as a guarded call can be invisible to
474 // PreToolUse. A report counts only when it is outside the journaled current
475 // call's message and a later journaled record (a result or another message)
476 // follows it, so the outcome never depends on when Claude Code flushes.
477 const later = flush.journaled ? [...entered, flush.current] : entered;
478 if (reports.some(r => !(flush.journaled && sameMessage(r, flush.current)) &&
479 later.some(e => e.order > r.order && (e.kind === 'result' || !sameMessage(e, r))))) return;
480 const repeated = entered.some(e => e.kind === 'result' && e.isError === true && JSON.stringify(e.content ?? '').includes('(code publication_unflushed'));
481 fail(repeated ? 'publication_repeat' : 'publication_unflushed', { phase: number[phase] });
484/** The phase a guarded call enters, from its input alone (no artifact I/O). */
485function targetPhase(use: { name?: string; input?: Record<string, unknown> }, cwd: string): Phase | undefined {
486 if (use.name === 'Read') return phaseName(use.input?.file_path, cwd) ?? artifactName(use.input?.file_path, cwd);
487 if (use.name === 'Agent' && typeof use.input?.prompt === 'string')
488 return /^You are the independent (CEO|DESIGN|DX|ENG) reviewer for this phase\.\n/.exec(use.input.prompt)?.[1]?.toLowerCase() as Phase | undefined;
491/** Journal path: the current call is journaled; its record must match the payload exactly, minus documented strips. */
492export const evaluateAutoplanPublication = (input: PublicationHookInput, root: string, events: Event[], claudeVersion?: string) =>
493 visible(journalDecision(input, root, events, claudeVersion));
494/** Payload path; see payloadDecision. */
495export const evaluatePayloadPublication = (input: PublicationHookInput, root: string, events: Event[]) =>
496 visible(payloadDecision(input, root, events));
497const unguarded = (input: PublicationHookInput) => !candidate({ name: input.tool_name, input: input.tool_input }, input.cwd) || !!input.agent_id;
499function journalDecision(input: PublicationHookInput, root: string, events: Event[], claudeVersion?: string): Decision {
500 return decideSafely(() => {
501 if (unguarded(input)) return { allow: true };
502 consistentEvents(input, events);
503 const current = events.filter((e): e is Use => isUse(e) && e.toolUseId === input.tool_use_id);
504 if (current.length !== 1 || current[0]!.name !== input.tool_name) fail('current_mismatch');
505 const journaled = nativeToolInput(current[0]!.input, input.cwd, input.tool_name) as Record<string, unknown>;
506 const payload = nativeToolInput(input.tool_input, input.cwd, input.tool_name) as Record<string, unknown>;
507 if (!isDeepStrictEqual(journaled, payload)) {
508 // A pure strip by a newer Claude Code's schema parse is its doing, not the model's.
509 const stripped = Object.keys(journaled).filter(key => !(key in payload));
510 if (stripped.length && newerThanChecked(claudeVersion) &&
511 isDeepStrictEqual(Object.fromEntries(Object.keys(payload).map(key => [key, journaled[key]])), payload))
512 fail('unseen_version', { keys: stripped.sort() });
513 fail('current_mismatch');
514 }
515 return phaseDecision(input, root, events.filter(e => e.order < current[0]!.order), current[0]!, true);
516 });
519/**
520 * Payload path: a ready journal lacks the current call (Claude Code had not
521 * flushed it). The payload, placed after the last journaled record, stands in
522 * for it; the previous phase's report must still be journaled and flushed.
523 */
524function payloadDecision(input: PublicationHookInput, root: string, events: Event[]): Decision {
525 return decideSafely(() => {
526 if (unguarded(input)) return { allow: true };
527 consistentEvents(input, events);
528 if (events.some(e => (e.kind === 'use' || e.kind === 'result') && e.toolUseId === input.tool_use_id)) fail('current_mismatch');
529 const last = events.at(-1)!;
530 const current: Use = { kind: 'use', sessionId: input.session_id, timestamp: last.timestamp, order: last.order + 1,
531 toolUseId: input.tool_use_id, name: input.tool_name, input: input.tool_input };
532 // The payload has no messageId; the last journaled assistant message may be this call's.
533 const lastMessage = events.findLast(e => e.messageId !== undefined && e.kind !== 'result')?.messageId;
534 return phaseDecision(input, root, events, lastMessage ? { ...current, messageId: lastMessage } : current, false);
535 });
538/** ENG-2: a tool still waiting for its result outside the last journaled message means the journal lags more than one message. */
539export function journalLagged(events: Event[]): boolean {
540 const lastMessage = events.findLast(e => e.messageId !== undefined && e.kind !== 'result')?.messageId, tools = byTool(events);
541 return events.some(e => isUse(e) && (e.messageId === undefined || e.messageId !== lastMessage) && !tools.results(e.toolUseId).length);
544function consistentEvents(input: PublicationHookInput, events: Event[]): void {
545 if (!events.length || events.some((e, i) => e.sessionId !== input.session_id || !Number.isSafeInteger(e.order) ||
546 (i > 0 && e.order <= events[i - 1]!.order))) fail('event_order');
547 const identities = new Set<string>();
548 for (const event of events) if (event.kind === 'use' || event.kind === 'result') {
549 const identity = `${event.kind}:${event.toolUseId}`;
550 if (identities.has(identity)) fail('tool_identity');
551 identities.add(identity);
552 }
555function decideSafely(work: () => Decision): Decision {
556 try { return work(); } catch (error) {
557 const code = error instanceof BoundaryError ? error.code : 'evidence';
558 const detail = error instanceof BoundaryError ? error.detail : {};
559 if (REASONS[reasonCode(code)].disposition === 'unverified') return { allow: true, unverified: code, detail };
560 return { allow: false, reason: reasonText(code, detail), code, detail };
561 }
564/** Shared by both paths. `current` is the journaled record or the payload stand-in; `before` precedes it. */
565function phaseDecision(input: PublicationHookInput, root: string, before: Event[], current: Use, journaled: boolean): Decision {
566 const requested = { name: input.tool_name, input: input.tool_input };
567 // Pinned Claude retains skill hooks after end_turn. Only an authenticated
568 // later human request can release the old invocation; tool results and
569 // compaction never do. A native slash or an actual init re-arms the guard.
570 const disarmed = disarmedAt(before, root);
571 if (disarmed.at(-1)) return { allow: true };
572 const human = before.filter((e): e is Turn => e.kind === 'user_turn').at(-1);
573 if (human?.autoplan && !before.some(e => e.kind === 'use' && e.name === 'Bash' && e.order > human.order &&
574 initArguments(e.input?.command, root))) fail('init_own');
575 const init = invocation(before, root);
576 const entered = before.filter((e, i) => e.order > init.start && !disarmed[i]);
577 const target = consumption(requested, input.cwd, root, init)!.phase;
578 let phase: Phase | undefined, entryOrder = init.start, checkpoint: string | undefined;
579 const seenCheckpoints = new Set<string>(), preparedCheckpoints = new Map<Phase, string>(), tools = byTool(entered);
580 for (const use of entered) {
581 if (use.kind === 'result') {
582 let created: ReturnType<typeof checkpointResult>;
583 try { created = checkpointResult(use, tools, init); } catch { continue; }
584 if (!created || seenCheckpoints.has(created.path)) continue;
585 seenCheckpoints.add(created.path);
586 if (phase && number[created.phase] < number[phase]) {
587 // A fresh checkpoint reopens an affected phase after a later phase.
588 // Historical Reads and reflected create results do not reopen it.
589 phase = created.phase; entryOrder = use.order; checkpoint = created.path;
590 } else if (phase === created.phase) {
591 // CEO's later voice snapshot does not replace its Step-0 checkpoint.
592 checkpoint ??= created.path;
593 } else if (!preparedCheckpoints.has(created.phase)) preparedCheckpoints.set(created.phase, created.path);
594 continue;
595 }
596 if (!isUse(use) || !['Read', 'Agent'].includes(use.name ?? '')) continue;
597 const results = tools.results(use.toolUseId);
598 if (results.length !== 1 || results[0]!.isError !== false || results[0]!.order <= use.order) continue;
599 let next: Consumer | undefined;
600 try { next = consumption(use, input.cwd, root, init, true); } catch { continue; }
601 if (!next || (next.kind === 'Read' && !autoplanReadRange(use, results[0]!, next.content!, before))) continue;
602 if (!phase || number[next.phase] > number[phase]) {
603 // An unguarded earlier delivery cannot erase its predecessor's missing
604 // publication. Recovery still uses that predecessor's existing close.
605 if (phase) try { requirePublication(phase, entryOrder, entered.filter(e => e.order < use.order), init, false, input.cwd, checkpoint); }
606 catch { continue; }
607 phase = next.phase; entryOrder = use.order;
608 checkpoint = preparedCheckpoints.get(phase); preparedCheckpoints.delete(phase);
609 }
610 }
611 // One batch rule on both paths: guarded calls in the current call's message
612 // that target the same phase are one batch; another phase is never batched.
613 const siblings = new Set(entered.filter(e => isUse(e) && candidate(e, input.cwd) && sameMessage(e, current)));
614 if ([...siblings].some(e => targetPhase(e as Use, input.cwd) !== target)) fail('cross_phase_batch');
615 if (entered.some(e => e.kind === 'use' && candidate(e, input.cwd) && !siblings.has(e) &&
616 !tools.results(e.toolUseId).length)) fail('entry_pending');
617 if (!phase) {
618 if (target !== 'ceo') fail('phase_order');
619 return { allow: true };
620 }
621 if (number[target] <= number[phase]) return { allow: true };
622 requirePublication(phase, entryOrder, entered, init, true, input.cwd, checkpoint, { current, journaled });
623 return { allow: true };
626/**
627 * A worktree session gets the repository root as CLAUDE_PROJECT_DIR while its
628 * journal is rooted in the linked worktree. Only git's own two-way link makes
629 * a directory that worktree: <root>/.git/worktrees/<name>/gitdir names
630 * <worktree>/.git, and that file names the same admin entry back. A project
631 * directory below the root (#3296) uses the first .git found walking up; a
632 * .git file there means a linked worktree or submodule, which has no registry.
633 */
634export function linkedWorktrees(projectDir: string): string[] {
635 let entries: fs.Dirent[], admin: string | undefined;
636 try {
637 for (let dir = path.resolve(projectDir); ; dir = path.dirname(dir)) {
638 const dotGit = fs.statSync(path.join(dir, '.git'), { throwIfNoEntry: false });
639 if (dotGit) { if (dotGit.isDirectory()) admin = path.join(dir, '.git', 'worktrees'); break; }
640 if (path.dirname(dir) === dir) break;
641 }
642 if (!admin) return [];
643 entries = fs.readdirSync(admin, { withFileTypes: true });
644 } catch { return []; }
645 return entries.filter(entry => entry.isDirectory()).slice(0, 256).flatMap(entry => {
646 try {
647 const link = path.join(admin, entry.name);
648 // worktree.useRelativePaths writes each side relative to its own file's directory.
649 const forward = /^([^\r\n]+)\r?\n?$/.exec(fs.readFileSync(path.join(link, 'gitdir'), 'utf8'))?.[1];
650 if (!forward) return [];
651 const gitFile = nativePathSpelling(path.resolve(link, nativePathSpelling(forward)));
652 if (path.basename(gitFile) !== '.git' || !fs.lstatSync(gitFile).isFile()) return [];
653 const back = /^gitdir: ([^\r\n]+)\r?\n?$/.exec(fs.readFileSync(gitFile, 'utf8'))?.[1];
654 if (!back || nativePathSpelling(fs.realpathSync(path.resolve(path.dirname(gitFile), nativePathSpelling(back)))) !==
655 nativePathSpelling(fs.realpathSync(link))) return [];
656 const worktree = nativePathSpelling(fs.realpathSync(path.dirname(gitFile)));
657 return ownPath(worktree) ? [worktree] : [];
658 } catch { return []; }
659 });
662export function publicationHookOutput(decision: Decision | PublicationDecision): object {
663 return decision.allow ? {} : { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny',
664 permissionDecisionReason: `[autoplan] ${decision.reason}` } };
667type EvaluationPath = 'journal' | 'payload' | 'none';
668const mib = (bytes: number) => { const m = bytes / (1024 * 1024); return `${m >= 10 ? Math.round(m) : Number(m.toPrecision(2))} MiB`; };
670/**
671 * UC1: Claude Code left this call uncheckable. No permissionDecision, so Claude
672 * Code's own permission check still runs; the user sees the warning (stderr and
673 * systemMessage), the model is reminded to publish, and the log says unverified.
674 */
675function unverifiedOutput(code: string, detail: Detail, version: string | undefined, via: EvaluationPath, recordTypes?: string[]): object {
676 logGuardDecision({ decision: 'allow', disposition: 'unverified', code, path: via, claudeVersion: version, recordTypes });
677 const notice = reasonText(code, detail, version);
678 return { systemMessage: `[autoplan] ${notice}`, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext:
679 `[autoplan] ${notice} This call proceeds unverified. Publish each completed phase report as your own parent ` +
680 'assistant text, in a message whose only tool call is the Bash no-op `true autoplan-published <phase>`, before entering the next phase.' } };
683function decided(decision: Decision, via: EvaluationPath, version?: string): object {
684 if (decision.allow && decision.unverified) return unverifiedOutput(decision.unverified, decision.detail ?? {}, version, via);
685 if (decision.allow) { logGuardDecision({ decision: 'allow', disposition: 'allow', path: via, claudeVersion: version }); return {}; }
686 const code = decision.code, disposition = REASONS[reasonCode(code)].disposition as 'fallback' | 'corrective' | 'transient';
687 logGuardDecision({ decision: 'deny', disposition, code, path: via, claudeVersion: version,
688 unknownKeys: code === 'agent_key' ? decision.detail?.keys : undefined });
689 return publicationHookOutput({ allow: false, code, reason: reasonText(code, decision.detail, version) });
691const denied = (code: ReasonCode, version?: string, detail: Detail = {}, via: EvaluationPath = 'none') =>
692 decided({ allow: false, code, reason: '', detail }, via, version);
694/** Read the journal for every owner the hook accepts (project root, then its linked worktrees), with the hook's own classifiers. */
695export function ownedRead(journal: string, owners: string[], input: PublicationHookInput, root: string, prior: JournalPrefix | undefined,
696 measure: OwnedReadMeasure): OwnedRead {
697 return readGuardJournal(journal, owners, input.session_id, input.tool_use_id, {
698 init: command => { try { return initArguments(command, root); } catch { return undefined; } },
699 read: file => artifactName(file, input.cwd, true) && path.basename(requestedPath(input.cwd, file as string)) === 'close-packet.md' ? 'close'
700 : phaseName(file, input.cwd) || artifactName(file, input.cwd) ? 'phase' : undefined,
701 reviewer: toolInput => candidate({ name: 'Agent', input: toolInput }, input.cwd),
702 }, prior, measure);
705/**
706 * A ready journal holding the current call takes the journal path; one that
707 * lacks it takes the payload path at once (Claude Code may write the record only
708 * with its result). The 2-second window remains for a journal that does not exist
709 * yet, a journal lagging more than one message, a journal with no public event or
710 * none after its latest human turn (Claude Code 2.1.292 can flush the turn's
711 * earlier calls after this one's hook; at expiry an empty journal is journal lag
712 * and a human-turn tail takes the payload path), and the unrecognized-shape
713 * double read. Claude Code's appends never fail a read; a changed prefix is `rewritten`.
714 */
715export async function runPublicationHook(value: unknown, root: string): Promise<object> {
716 return approveOwnedRead(value, await guardOutput(value, root), root);
719async function guardOutput(value: unknown, root: string): Promise<object> {
720 let version: string | undefined;
721 try {
722 if (!object(value) || value.hook_event_name !== 'PreToolUse' || typeof value.tool_name !== 'string') return denied('hook_input');
723 if (!['Read', 'Agent'].includes(value.tool_name) || value.agent_id) return {};
724 if (!ownPath(value.cwd) || !ownPath(value.transcript_path) || typeof value.session_id !== 'string' ||
725 typeof value.tool_use_id !== 'string' || !object(value.tool_input)) return denied('hook_input');
726 const input = value as PublicationHookInput;
727 if (!candidate({ name: input.tool_name, input: input.tool_input }, input.cwd)) return {};
728 // Native hooks override this environment value with the session's project
729 // root. Bash cd changes input.cwd, not the journal's original ownership.
730 // On Windows the hook env spells it C:/...; the journal records C:\...
731 const projectCwd = nativePathSpelling(process.env.CLAUDE_PROJECT_DIR ?? input.cwd);
732 if (!ownPath(projectCwd)) return denied('hook_input');
733 const journal = nativePathSpelling(input.transcript_path), owners = [projectCwd, ...linkedWorktrees(projectCwd)];
734 const deadline = performance.now() + 2_000;
735 let read: OwnedRead, first: JournalPrefix | undefined, unrecognized = false;
736 for (;;) {
737 // Every later read must see the first read's bytes unchanged (CEO-2); a mismatch is `rewritten`.
738 const measure: OwnedReadMeasure = {};
739 read = ownedRead(journal, owners, input, root, first, measure);
740 first ??= measure.prefix;
741 version = read.claudeVersion ?? read.diagnostic?.claudeVersion;
742 const code = read.transcript.reason, expired = performance.now() >= deadline;
743 if (read.transcript.status === 'ready') {
744 if (read.events.some(e => e.kind === 'use' && e.toolUseId === input.tool_use_id))
745 return decided(journalDecision(input, root, read.events, version), 'journal', version);
746 const tail = read.events.at(-1)?.kind;
747 if (tail && !journalLagged(read.events) && (tail !== 'user_turn' || expired))
748 return decided(payloadDecision(input, root, read.events), 'payload', version);
749 if (expired) return unverifiedOutput('journal_lag', {}, version, 'payload');
750 } else if (code === 'too_large') {
751 return unverifiedOutput('too_large', { size: measure.recordBytes ? mib(measure.recordBytes) : undefined, limit: mib(ownedRecordLimit()) },
752 version, 'none');
753 } else if (code === 'rewritten') {
754 return unverifiedOutput('rewritten', {}, version, 'none');
755 } else if (code === 'oversized_invocation') {
756 return denied(code, version, { limit: mib(ownedRetainedLimit()) });
757 } else if (code?.startsWith('unrecognized_shape:')) {
758 // A second read over the same prefix: the shape is Claude Code's, not a write in progress.
759 if (unrecognized) return unverifiedOutput(code, { cause: code }, version, 'none', read.diagnostic?.rootShape);
760 unrecognized = true;
761 } else if (code) return denied(code as ReasonCode, version);
762 if (expired && !code?.startsWith('unrecognized_shape:')) break;
763 await new Promise(resolve => setTimeout(resolve, 50));
764 }
765 // A journal with bytes but no owned records yet: Claude Code has not written an ancestor.
766 if (read.diagnostic) return unverifiedOutput('journal_lag', {}, version, 'none');
767 return denied('journal_missing', version);
768 } catch {
769 return denied('installation', version);
770 }
773if (import.meta.main) {
774 let output: object;
775 try {
776 const root = fs.realpathSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'));
777 const bytes = await Bun.stdin.text();
778 output = Buffer.byteLength(bytes) > 64 * 1024 ? denied('hook_input') : await runPublicationHook(JSON.parse(bytes), root);
779 } catch { output = denied('hook_input'); }
780 const warning = (output as { systemMessage?: string }).systemMessage;
781 if (warning) process.stderr.write(`${warning}\n`);
782 process.stdout.write(JSON.stringify(output) + '\n');