#!/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; type Turn = Extract; 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(), results = new Map(); for (const e of events) if (e.kind === 'use' || e.kind === 'result') { const map = (e.kind === 'use' ? uses : results) as Map, 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; const number: Record = { ceo: 1, design: 2, dx: 2.5, eng: 3, tasks: 4 }; const object = (x: unknown): x is Record => 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 = { 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 = { ...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; 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 }, 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 }, 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 !== `` || 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 | 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(``.length)) || !active.startsWith(``) || 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(), notified = new Set(), reviewers = new Set(); 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(); 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 }, 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; const payload = nativeToolInput(input.tool_input, input.cwd, input.tool_name) as Record; 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(); 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(), preparedCheckpoints = new Map(), tools = byTool(entered); for (const use of entered) { if (use.kind === 'result') { let created: ReturnType; 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: /.git/worktrees//gitdir names * /.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 `, 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 { return approveOwnedRead(value, await guardOutput(value, root), root); } async function guardOutput(value: unknown, root: string): Promise { 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'); }