Files
gstack/autoplan/bin/phase-publication-hook.ts
T
garrytan 42e53413da feat: require a zero-error product typecheck and a test type-debt ratchet
Adds tsconfig.json (strict) over product code, fixes its remaining 90
diagnostics (type-only, interface corrections, and explicit narrowing),
and adds a typecheck job to the required free-tests aggregate running
bun run typecheck, the test-code ratchet (identity -> count baseline, fails
on new, repeated, or unlocked fixed diagnostics), and the lib/cso format
check. Reuses fixes from #2447 where they still applied.
2026-09-29 14:34:02 +00:00

492 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bun
/** A native parent publication barrier at Autoplan's exact Read boundaries. */
import * as fs from 'node:fs';
import * as path from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { isDeepStrictEqual } from 'node:util';
import { extractImplementationPlan, checkPhaseImplementation, acceptedBlocks } from '../../bin/gstack-autoplan-snapshot';
import { autoplanPhaseCompletions } from '../../lib/autoplan-phase-publication';
import { readOwnedClaudePublicTranscript, type ClaudeParentPublicEvent } from '../../lib/claude-public-transcript';
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';
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');
const ownPath = (value: unknown): value is string => typeof value === 'string' && path.isAbsolute(value) && path.normalize(value) === value;
class BoundaryError extends Error {}
function fail(reason: string): never { throw new BoundaryError(reason); }
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;
}
export type PublicationDecision = { allow: true } | { allow: false; reason: string };
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) || fs.realpathSync(file) !== file) fail('Artifact path is unavailable or aliased.');
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
try {
const before = fs.fstatSync(fd, { bigint: true });
if (!before.isFile() || before.size > 32n * 1024n * 1024n ||
(immutable && process.platform !== 'win32' && (before.mode & 0o222n) !== 0n)) fail('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('Artifact changed during read.');
const text = bytes.toString('utf8');
if (!Buffer.from(text).equals(bytes)) fail('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 = path.resolve(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 = path.resolve(cwd, file as string);
const canonical = path.join(root, 'autoplan', 'sections', path.basename(requested));
if (fs.realpathSync(requested) !== canonical || fs.realpathSync(canonical) !== canonical)
fail('Autoplan phase entry belongs to a different or unavailable installation. Restore this invocation’s hook installation before retrying.');
return phase;
}
interface Consumer { phase: Phase; content?: string; kind: 'Read' | 'Agent' }
function artifactName(file: unknown, cwd: string, includeClose = false): Phase | undefined {
if (typeof file !== 'string') return;
const requested = path.resolve(cwd, file), base = path.basename(requested);
const 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('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('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('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('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('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(path.resolve(cwd, use.input!.file_path as string))) };
const phase = artifactName(use.input?.file_path, cwd, includeClose);
if (!phase) return;
const file = path.resolve(cwd, use.input!.file_path as string), base = path.basename(file);
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 file = JSON.parse(/^Read file: ("[^\n]+")$/m.exec(prompt)?.[1] ?? 'null');
if (!ownPath(file) || path.basename(file) !== 'native-prompt.md') fail('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('Native phase dispatch differs from its exact immutable snapshot.');
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));
}
function textResult(event: Event): string | undefined {
if (event.kind !== 'result' || event.isError !== false) return;
if (typeof event.content === 'string') return event.content;
if (Array.isArray(event.content) && event.content.length === 1 && event.content[0]?.type === 'text' &&
typeof event.content[0].text === 'string') return event.content[0].text;
}
/** Authenticate the existing direct-create result; this does not prove its shell command's origin. */
function checkpointResult(result: Event, entered: Event[], init: Invocation): { phase: Phase; path: string } | undefined {
if (result.kind !== 'result') return;
const use = entered.find((e): e is Use => isUse(e) && e.toolUseId === result.toolUseId);
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; retain all other canonical-path checks.
.map(x => process.platform === 'win32' ? x.replaceAll('/', '\\') : x);
if (!args.every(ownPath) || fs.realpathSync(args[0]!) !== path.join(root, 'bin', 'gstack-autoplan-snapshot.ts')) return;
return args.slice(1);
}
function invocation(events: Event[], root: string): Invocation {
let bound: Invocation | undefined;
let chosen: Record<string, any> | undefined;
for (const use of events) {
if (use.kind !== 'use' || use.name !== 'Bash') continue;
const args = initArguments(use.input?.command, root);
if (!args) continue;
const results = events.filter(x => x.kind === 'result' && x.toolUseId === use.toolUseId && x.order > use.order);
if (results.length !== 1) fail('Autoplan initialization acknowledgment is unavailable or ambiguous.');
const text = textResult(results[0]!);
if (text === undefined) fail('Autoplan initialization did not succeed. Complete the existing init step first.');
const result = JSON.parse(text);
if (!object(result) || result.sourcePlan !== fs.realpathSync(args[0]!) || result.activePlan !== args[1] ||
result.restorePath !== args[2] || typeof result.reused !== 'boolean' || !positive(result.originalBytes) ||
!/^[a-f0-9]{64}$/.test(result.originalSha256)) fail('Autoplan initialization does not match the successful native request.');
if (result.reused && bound && bound.activePlan === result.activePlan && bound.restorePath === result.restorePath) continue;
chosen = result;
bound = { activePlan: result.activePlan, restorePath: result.restorePath,
originalSha256: result.originalSha256, start: results[0]!.order };
}
if (!chosen || !bound) fail('Autoplan invocation evidence is unavailable. Complete the existing snapshot init step before phase entry.');
const restore = read(bound.restorePath, true), active = read(bound.activePlan);
const reference = JSON.stringify(bound.restorePath).replace(/--/g, '\\u002d\\u002d');
if (hash(restore) !== bound.originalSha256 || Buffer.byteLength(restore) !== chosen.originalBytes ||
!active.startsWith(`<!-- /autoplan restore point: ${reference} -->\n`) || bound.activePlan === bound.restorePath)
fail('Autoplan initialization artifacts do not match this parent invocation.');
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) !== 'Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.' ||
!isDeepStrictEqual(result.file, { filePath: use.input?.file_path })) break;
// Pinned native dedup requires the same offset/limit and a non-truncated prior
// Read. Seeded-context notices without that native delivery supply no range.
const prior = history.filter((e): e is Use => e.kind === 'use' && e.name === 'Read' &&
e.sessionId === use.sessionId && e.order < use.order && e.input?.file_path === use.input?.file_path).at(-1);
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 (f.filePath !== use.input?.file_path || typeof f.content !== 'string' || !positive(f.startLine) || !positive(f.numLines) ||
f.totalLines !== lines.length || f.startLine + f.numLines - 1 > lines.length || (use.input?.offset ?? 1) !== f.startLine ||
(use.input?.limit !== undefined && (!positive(use.input.limit) || f.numLines > use.input.limit)) ||
f.content !== lines.slice(f.startLine - 1, f.startLine - 1 + f.numLines).join('\n')) return;
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 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 packet no longer matches the current phase input. Finish the existing close procedure with a fresh packet.');
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 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 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 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 methodology belongs to a different invocation.');
if (current) checkPhaseImplementation(phase, init.activePlan, checkpoint,
prior.sourceSha256 === binding.sourceSha256 ? 'unchanged' : 'changed');
return content;
}
/** A skill hook survives end_turn; unrelated human intervals are never phase evidence. */
function disarmed(events: Event[], root: string): boolean {
const human = events.filter((e): e is Turn => e.kind === 'user_turn').at(-1);
return !!human && !human.autoplan && events.some(e => e.kind === 'end_turn' && e.order < human.order) &&
!events.some(e => e.kind === 'use' && e.name === 'Bash' && e.order > human.order && initArguments(e.input?.command, root));
}
/** Only exact reversible successful Edits can establish a report-only change. */
function verifyCloseEdits(events: Event[], closeOrder: number, init: Invocation): void {
const edits = events.filter((e): e is Use => e.kind === 'use' && e.order > closeOrder &&
['Write', 'Edit'].includes(e.name ?? '') && e.input?.file_path === init.activePlan);
if (!edits.length) return;
const current = read(init.activePlan);
let prior = current;
for (const use of edits.toReversed()) {
const results = events.filter((e): e is Tool => e.kind === 'result' && e.toolUseId === use.toolUseId);
if (results.length !== 1) fail('An active-plan mutation is pending after the close Read. Wait for its result, then verify the current close input.');
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('Post-close mutation history cannot be reconstructed exactly. Repeat the existing close procedure.');
const at = prior.indexOf(input.new_string);
if (at < 0 || prior.indexOf(input.new_string, at + input.new_string.length) !== -1)
fail('Post-close Edit history is ambiguous or incomplete. Repeat the existing close procedure.');
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('Post-close Edit history does not match its unique native old_string. Repeat the existing close procedure.');
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('Review-record position is ambiguous. Repeat the existing close procedure.');
return [...acceptedBlocks(plan.slice(at + implementation.length))].map(([phase, block]) => [phase, block.raw]);
};
if (extractImplementationPlan(prior) !== extractImplementationPlan(current) ||
!isDeepStrictEqual(requirements(prior), requirements(current)))
fail('Implementation or accepted requirements changed after the close Read. Repeat the existing close procedure.');
}
function requirePublication(phase: Phase, entryOrder: number, entered: Event[], init: Invocation, current: boolean, checkpoint?: string): void {
const closeReads = entered.filter((e): e is Use => e.kind === 'use' && e.name === 'Read' && e.order >= entryOrder &&
ownPath(e.input?.file_path) && path.basename(e.input.file_path) === 'close-packet.md' &&
path.dirname(path.dirname(e.input.file_path)) === path.dirname(init.restorePath) &&
path.basename(path.dirname(e.input.file_path)).startsWith(`autoplan-${phase}-`));
if (!closeReads.length) fail(`Finish the existing Phase ${number[phase]} close procedure and Read its complete current close packet before entering the next phase.`);
const latestPath = closeReads.at(-1)!.input!.file_path as string;
const content = closePacket(latestPath, phase, init, current), covered = new Set<number>();
if (checkpoint && JSON.parse(/^Binding: (.+)$/m.exec(content)![1]!).checkpointPath !== checkpoint)
fail(`The Phase ${number[phase]} close packet belongs to an earlier checkpoint. Complete the current phase's close procedure with its fixed checkpoint.`);
let closeOrder = -1;
for (const use of closeReads.filter(e => e.input?.file_path === latestPath)) {
const results = entered.filter(e => e.kind === 'result' && e.toolUseId === use.toolUseId);
if (results.length !== 1) continue;
const range = autoplanReadRange(use, results[0]!, content, entered);
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(`Read every line of the current Phase ${number[phase]} close packet successfully before entering the next phase.`);
const pending = entered.some(e => e.kind === 'use' && e.order > closeOrder && ['Write', 'Edit'].includes(e.name ?? '') &&
e.input?.file_path === init.activePlan && !entered.some(r => r.kind === 'result' && r.toolUseId === e.toolUseId));
if (pending) fail('An active-plan mutation is pending after the close Read. Wait for its result, then verify the current close input.');
if (current) verifyCloseEdits(entered, closeOrder, init);
const messages = entered.filter((e): e is Event & { kind: 'message' } => e.kind === 'message' && e.order > closeOrder);
const hits = autoplanPhaseCompletions({ status: 'ready', calls: [], assistantMessages: messages }, 0);
if (!hits.some(hit => hit.phase === number[phase])) fail(`Publish the filled Phase ${number[phase]} report as your own parent assistant text now, then retry the same phase-entry tool. The close packet or a saved report does not publish it.`);
}
/** Ordered public events only. This does not judge review content or create a report. */
export function evaluateAutoplanPublication(input: PublicationHookInput, root: string, events: Event[]): PublicationDecision {
return evaluatePublication(input, root, events, false);
}
function evaluatePublication(input: PublicationHookInput, root: string, events: Event[], pendingRead: boolean): PublicationDecision {
try {
const requested = { name: input.tool_name, input: input.tool_input };
if (!candidate(requested, input.cwd) || input.agent_id) return { allow: true };
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('Native parent event order is unavailable. Retry this phase-entry tool after the journal is available.');
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('Native tool identity is ambiguous. Restore the current parent evidence before retrying.');
identities.add(identity);
}
const current = events.filter(e => e.kind === 'use' && e.toolUseId === input.tool_use_id);
if (pendingRead ? input.tool_name !== 'Read' || events.some(e =>
(e.kind === 'use' || e.kind === 'result') && e.toolUseId === input.tool_use_id) :
current.length !== 1 || current[0]!.kind !== 'use' || current[0]!.name !== input.tool_name ||
!isDeepStrictEqual(current[0]!.input, input.tool_input)) fail('Current native phase-entry identity is unavailable. Retry this phase-entry tool after the journal is available.');
const before = pendingRead ? events : events.filter(e => e.order < current[0]!.order);
// Pinned Claude retains skill hooks after end_turn. Only an authenticated
// later human request can release the old invocation; tool results and
// compaction never do. A native slash or an actual init re-arms the guard.
const human = before.filter((e): e is Turn => e.kind === 'user_turn').at(-1);
if (disarmed(before, root)) {
if (pendingRead) fail('Current native phase-entry identity is unavailable after this invocation ended.');
return { allow: true };
}
if (human?.autoplan && !before.some(e => e.kind === 'use' && e.name === 'Bash' && e.order > human.order &&
initArguments(e.input?.command, root))) fail('This Autoplan invocation needs its own successful init before phase entry.');
const init = invocation(before, root);
const entered = before.filter(e => e.order > init.start && !disarmed(before.filter(prior => prior.order < e.order), root));
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>();
for (const use of entered) {
if (use.kind === 'result') {
let created: ReturnType<typeof checkpointResult>;
try { created = checkpointResult(use, entered, 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 = entered.filter((e): e is Tool => e.kind === 'result' && e.toolUseId === 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, checkpoint); }
catch { continue; }
phase = next.phase; entryOrder = use.order;
checkpoint = preparedCheckpoints.get(phase); preparedCheckpoints.delete(phase);
}
}
const pendingEntry = entered.some(e => e.kind === 'use' && candidate(e, input.cwd) &&
!entered.some(r => r.kind === 'result' && r.toolUseId === e.toolUseId));
if (pendingEntry) fail('A prior phase-entry tool is still pending. Retry after its native result before requesting another phase.');
// A streamed tool may reach PreToolUse before its journal record. The
// native input can revisit a phase already proven by prior owned ACKs;
// it cannot establish a phase, a publication, or a synthetic current use.
if (pendingRead && (!phase || number[target] > number[phase]))
fail('Current native phase-entry identity is required before entering a new phase.');
if (!phase) {
if (target !== 'ceo') fail('Read the current Phase 1 CEO entry successfully before entering a later phase.');
return { allow: true };
}
if (number[target] <= number[phase]) return { allow: true };
requirePublication(phase, entryOrder, entered, init, true, checkpoint);
return { allow: true };
} catch (error) {
return { allow: false, reason: error instanceof BoundaryError
? error.message : 'Autoplan phase evidence is unavailable or changed. Restore the current invocation evidence and retry this phase-entry tool.' };
}
}
export function publicationHookOutput(decision: PublicationDecision): object {
return decision.allow ? {} : { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny',
permissionDecisionReason: `[autoplan] ${decision.reason}` } };
}
/** Claude's pending tool record can flush after hook entry; wait only for that identity. */
export async function runPublicationHook(value: unknown, root: string): Promise<object> {
try {
if (!object(value) || value.hook_event_name !== 'PreToolUse' || typeof value.tool_name !== 'string') fail('Invalid native 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)) fail('Native parent hook identity is unavailable.');
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.
const projectCwd = process.env.CLAUDE_PROJECT_DIR ?? input.cwd;
if (!ownPath(projectCwd)) fail('Native parent project directory is unavailable.');
const deadline = performance.now() + 2_000;
do {
const snapshot = readOwnedClaudePublicTranscript(input.transcript_path, projectCwd, input.session_id);
if (snapshot.transcript.status === 'ready') {
if (snapshot.events.some(e => e.kind === 'use' && e.toolUseId === input.tool_use_id))
return publicationHookOutput(evaluateAutoplanPublication(input, root, snapshot.events));
if (input.tool_name === 'Read' && evaluatePublication(input, root, snapshot.events, true).allow)
return {};
}
await new Promise(resolve => setTimeout(resolve, 50));
} while (performance.now() < deadline);
fail('Native parent evidence has not reached the journal yet. Retry this phase-entry tool; no missing-publication conclusion has been made.');
} catch (error) {
return publicationHookOutput({ allow: false, reason: error instanceof BoundaryError
? error.message : 'Hook installation or native evidence is unavailable. Restore this Autoplan installation before retrying.' });
}
}
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();
if (Buffer.byteLength(bytes) > 64 * 1024) fail('Native hook input exceeds its bound.');
output = await runPublicationHook(JSON.parse(bytes), root);
} catch { output = publicationHookOutput({ allow: false, reason: 'Publication hook could not load its native input. Restore the hook and retry.' }); }
process.stdout.write(JSON.stringify(output) + '\n');
}