// PostToolUse hook: Emit ARTIFACT_CREATED / ARTIFACT_UPDATED when files under // the active intent record or active space codekb tree are written or edited. // Distinguishes CREATE vs UPDATE by checking whether the target file existed // before the Write/Edit. // // Receives JSON on stdin from Claude Code. No-op if no audit.md exists (no // active workflow in this cwd) to preserve the existing "only log when // relevant" behaviour. import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { isAbsolute, join } from "node:path"; import { appendAuditEntryUnlocked } from "../tools/aidlc-audit.ts"; import { auditFilePath, type StageEntry, type ClaudeCodeHookInput, codekbDir, docsRoot, errorMessage, hookDebug, hooksHealthDir, isClaudeCodeHookInput, activeSummaryAuthorizationForRecordPath, isoTimestamp, loadStageGraphAll, recordHookDrop, resolveProjectDirFromHook, SUMMARY_AUTHORIZATION_FIELD, withAuditLock, } from "../tools/aidlc-lib.ts"; export async function run(input: string): Promise { const projectDir = resolveProjectDirFromHook(import.meta.url); hookDebug(projectDir, "write-audit-log", "invoked", { projectDir, cwd: process.cwd() }); // Write health heartbeat const healthDir = hooksHealthDir(projectDir); mkdirSync(healthDir, { recursive: true }); writeFileSync(join(healthDir, "write-audit-log.last"), isoTimestamp(), "utf-8"); // Read JSON from stdin. If stdin is a TTY (interactive shell, test harness // running under `bash -x`-inheriting pipeline), no JSON is coming — exit // cleanly instead of blocking on the terminal read. if (process.stdin.isTTY) { hookDebug(projectDir, "write-audit-log", "exit: stdin isTTY"); return 0; } let parsed: ClaudeCodeHookInput; try { const raw: unknown = JSON.parse(input); if (!isClaudeCodeHookInput(raw)) { hookDebug(projectDir, "write-audit-log", "exit: not ClaudeCodeHookInput", { input: input.slice(0, 200) }); return 0; } parsed = raw; } catch { hookDebug(projectDir, "write-audit-log", "exit: stdin parse failed", { input: input.slice(0, 200) }); return 0; } const tool = parsed.tool_name ?? ""; const rawFile: string = parsed.tool_input?.file_path ?? ""; if (!rawFile) return 0; const file = isAbsolute(rawFile) ? rawFile : join(projectDir, rawFile); const auditFileValue = file.replace(/\\/g, "/"); const fileNorm = auditFileValue; // forward-slash form for all path matching below // Only log writes to the active intent's RECORD tree, plus the space's codekb // tree. The record re-roots per intent (aidlc/spaces//intents/ // -/…), so a bare `includes("aidlc-docs/")` gate would DROP every // artifact write on the workspace layout. docsRoot() resolves that per-intent // root when an intent is active, else the bare space record root - the write is // logged iff it lands under that root. The codekb arm covers reverse- // engineering's artifacts: they live at the SPACE level keyed by repo // (aidlc/spaces//codekb//…, a sibling of intents/, outside the // record root), and without it those writes emit no ARTIFACT_* rows at all - // which blinded the approve-time gate-revision backstop to codekb revisions // (Revision Count silently stayed 0 on a revised-then-approved RE gate). // codekbDir(pd, "_") is /aidlc/spaces//codekb/_; its parent is the // codekb root for the active space (same idiom as producesDirsForStage in // aidlc-state.ts). const recordRoot = docsRoot(projectDir).replace(/\\/g, "/").replace(/\/$/, ""); const underRecord = fileNorm === recordRoot || fileNorm.startsWith(`${recordRoot}/`); const codekbRoot = join(codekbDir(projectDir, "_"), "..") .replace(/\\/g, "/") .replace(/\/$/, ""); const underCodekb = fileNorm.startsWith(`${codekbRoot}/`); hookDebug(projectDir, "write-audit-log", "path-gate", { tool, file: fileNorm, recordRoot, underRecord, codekbRoot, underCodekb, }); if (!underRecord && !underCodekb) { hookDebug(projectDir, "write-audit-log", "exit: not under record or codekb root"); return 0; } // Don't log writes to an audit shard itself (avoid recursion). The shard is // audit/-.md under the record dir; the bare audit.md guard also // covers a migrated tree's pre-shard audit.md before it is relocated. if ( file.endsWith("/audit.md") || file.endsWith("\\audit.md") || /[/\\]audit[/\\][^/\\]+\.md$/.test(file) ) { hookDebug(projectDir, "write-audit-log", "exit: write to audit shard (recursion guard)"); return 0; } const auditFile = auditFilePath(projectDir); // Don't auto-create the audit trail — the orchestrator creates it at workflow start. if (!existsSync(auditFile)) { hookDebug(projectDir, "write-audit-log", "exit: audit file missing", { auditFile }); return 0; } // Extract the context breadcrumb: the path relative to the record root (the // per-intent record dir on the new layout, or the flat `aidlc-docs/` root), // or "codekb > > " for a codekb write. Prefer the root prefixes; // fall back to the `aidlc-docs/` anchor for a flat-legacy write that didn't // match either root. let context: string; if (underRecord && fileNorm.length > recordRoot.length) { context = fileNorm.slice(recordRoot.length + 1).replace(/\//g, " > "); } else if (underCodekb) { context = `codekb > ${fileNorm.slice(codekbRoot.length + 1).replace(/\//g, " > ")}`; } else { const aidlcIdxPosix = file.indexOf("aidlc-docs/"); const aidlcIdxWin = file.indexOf("aidlc-docs\\"); const aidlcIdx = aidlcIdxPosix >= 0 ? aidlcIdxPosix : aidlcIdxWin; context = aidlcIdx >= 0 ? file.slice(aidlcIdx + "aidlc-docs/".length).replace(/[/\\]/g, " > ") : file; } // CREATE vs UPDATE distinction: // - Edit tool → always UPDATE (Edit requires the file to pre-exist) // - Write tool → CREATE only if the file was brand new; otherwise UPDATE // PostToolUse fires after the write, so `existsSync` is always true by the // time this hook runs. We infer "was this a net-new file?" from the file's // mtime matching its filesystem creation timestamp (within a small epsilon), true on fresh // creation, false on overwrite. This matches the plan's intent that // ARTIFACT_CREATED answers "when was this artifact first created?" and // Write-overwriting-existing should emit ARTIFACT_UPDATED. let eventType: string; if (tool === "Edit") { eventType = "ARTIFACT_UPDATED"; } else { // Write or any other create-capable tool: check if file was net-new. let isNew = false; try { const { statSync } = await import("node:fs"); const st = statSync(file); // The filesystem creation timestamp (birthtimeMs) tracks mtimeMs on fresh // creation. If a file was overwritten, mtime advances past that timestamp. // Accept 10ms slack for // filesystem timestamp granularity. isNew = Math.abs(st.mtimeMs - st.birthtimeMs) < 10; } catch { // stat failure → default to CREATED (safer than UPDATED for net-new files) isNew = true; } eventType = isNew ? "ARTIFACT_CREATED" : "ARTIFACT_UPDATED"; } // A write under the record descends from the summary confirmation that is the // active authorization for its stage (and Unit) at the moment of the write. The // row carries that authorization's id, so completion can ask "does this output // descend from the current confirmation" instead of "did it land after the // receipt". A write with no active authorization for its scope carries no id. // // The lookup and the append share one audit-lock hold: the answer command // writes the registry and appends its receipt under the same lock, so a // registry can never be observed here without the receipt that minted it (nor // during a rollback that removes it). const fields: Record = { Tool: tool, File: auditFileValue, Context: context, }; let stages: StageEntry[] = []; if (underRecord && fileNorm.length > recordRoot.length) { try { stages = loadStageGraphAll(); } catch (e) { hookDebug(projectDir, "write-audit-log", "stage graph unreadable", { error: errorMessage(e) }); } } try { withAuditLock(projectDir, () => { if (underRecord && fileNorm.length > recordRoot.length) { const authorization = activeSummaryAuthorizationForRecordPath( projectDir, fileNorm.slice(recordRoot.length + 1), stages, ); if (authorization !== null) fields[SUMMARY_AUTHORIZATION_FIELD] = authorization.id; } appendAuditEntryUnlocked(eventType, fields, projectDir); }); hookDebug(projectDir, "write-audit-log", "emitted", { eventType, file: auditFileValue, context }); } catch (e) { // Hook must be a no-op on any audit emission failure to avoid breaking the // user's tool call. Record the drop so `--doctor` can surface it, then // exit cleanly. hookDebug(projectDir, "write-audit-log", "exit: emit threw", { eventType, error: errorMessage(e) }); recordHookDrop(projectDir, "write-audit-log", errorMessage(e)); return 0; } return 0; } if (import.meta.main) { process.exit(await run(await Bun.stdin.text())); }