import { createHash, randomUUID } from "node:crypto"; import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, statSync, writeSync, } from "node:fs"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { acquireAuditLock, assertNoSymlinkInChainOrThrow, auditFilePath, claimAttemptFields, cloneIdPath, errorMessage, hasUnsafeSingleLineCharacter, isoTimestamp, mergeReviewRecordsFromDelta, parseFieldArgs, holdsAuditLock, redactProjectDirPrefix, relativeRecordDir, readRegularFileNoFollowOrThrow, refuseEngineObserverWrite, releaseAuditLock, requireLiveClaimForTeamUnit, resolveProjectDir, validateBoltSlug, validateLiveUnitScope, worktreeClaimBoundaryMatches, worktreeAuditFilePath, worktreeDocsDir, worktreePath, writeBufferAtomic, } from "./aidlc-lib.ts"; // --- Canonical event types (the parity tests derive the count from this set) --- // See docs/reference/12-state-machine.md for the state transitions that emit each event. const VALID_EVENT_TYPES = new Set([ // Stage lifecycle "STAGE_STARTED", "STAGE_AWAITING_APPROVAL", "STAGE_REVISING", "STAGE_COMPLETED", "STAGE_JUMPED", "STAGE_SKIPPED", // Phase lifecycle "PHASE_STARTED", "PHASE_COMPLETED", "PHASE_VERIFIED", "PHASE_SKIPPED", // Workflow lifecycle "WORKFLOW_STARTED", "WORKFLOW_COMPLETED", "WORKFLOW_PARKED", "WORKFLOW_UNPARKED", // Session events (hook-owned) "SESSION_STARTED", "SESSION_RESUMED", "SESSION_COMPACTED", "SESSION_ENDED", // Human presence (hook-owned): one event per real human prompt turn. The // approval/interview gate requires a HUMAN_TURN appended AFTER the last gate // resolution (in ledger order) before it commits. "HUMAN_TURN", // Initialization events (fire IN ADDITION TO STAGE_COMPLETED) "WORKSPACE_SCAFFOLDED", "WORKSPACE_SCANNED", "WORKSPACE_INITIALISED", // User interaction "DECISION_RECORDED", "GATE_APPROVED", "GATE_REJECTED", "QUESTION_ANSWERED", "SUMMARY_CONFIRMATION_RECORDED", "PLAN_APPROVAL_RECORDED", // Break-glass: the human typed the override phrase and the conductor ran // `answer --override`; the receipt binds to content and attempt only. // Emitted by aidlc-log.ts beside PLAN_APPROVAL_RECORDED (Override: yes). "PLAN_APPROVAL_OVERRIDDEN", // Reviewer step (§12a) — REVIEW_REQUESTED on dispatch, REVIEW_COMPLETED when // a verdict is read. Emitted by the tool actor `aidlc-log.ts review`. A // reviewer-bearing stage cannot complete without a terminal REVIEW_COMPLETED // in its audit tail (enforced by aidlc-state.ts in approve, advance, finalize, // and complete-workflow). "REVIEW_REQUESTED", "REVIEW_COMPLETED", // Ordered pipeline-link receipt. Emitted only by aidlc-log.ts link after a // declared link returns; completion guards require the full current-attempt // chain before a pipeline stage may enter or resolve approval. "PIPELINE_LINK_COMPLETED", // Unit-of-work lifecycle on INLINE per-unit Construction stages (for_each: // unit-of-work, mode: inline) — emitted by `aidlc-state.ts unit // start|pause|resume|complete`. UNIT_COMPLETED is the completion receipt the // engine's coverage walk prefers over bare artifact existence (artifacts are // evidence checked AT the receipt, never the transition itself); UNIT_PAUSED // carries Reason + Next Action so a resumed session lands on the exact // checkpoint. The autonomous swarm path keeps its own SWARM_UNIT_* ledger. "UNIT_STARTED", "UNIT_PAUSED", "UNIT_RESUMED", "UNIT_COMPLETED", // Artifact events (hook-emitted) "ARTIFACT_CREATED", "ARTIFACT_UPDATED", "ARTIFACT_REUSED", // Subagent (hook-emitted) "SUBAGENT_COMPLETED", // Reviewer read-scope enforcement (hook-emitted): a per-unit reviewer's // tool call was refused for reaching into sibling units' construction/ paths. "REVIEWER_SCOPE_BLOCKED", // Terminal-receipt write-freeze enforcement (hook-emitted): a declared // produces-artifact write was refused because it would invalidate a fresh // READY review receipt before the gate (stage-protocol-reviewer §12a terminal // ordering). No bracket characters in this comment: t47 slices the array // literal at the first closing bracket after the const name. "REVIEW_FREEZE_BLOCKED", // Plan-approval ordering enforcement (hook-emitted): a code-generation // developer-agent dispatch was refused because no unit had an approved // code-generation plan on disk (stage Steps 2-3 must precede Step 4). "PLAN_APPROVAL_BLOCKED", // A guard's deterministic off-switch was set while a workflow existed, so a // tool call passed without enforcement. One row per streak: the hook appends // only when the newest row in its shard is not already this event. "GUARD_DISABLED", // DocumentKB (emitters wired by aidlc-knowledge.ts onboard/sync/associate). // The customer-document store is a SPACE-level object, so all three land in // the space-level audit shard even when the document is intent-scoped -- a // scope change must never split one document's history across two shards. "DOCUMENT_INDEXED", "DOCUMENT_UPDATED", "DOCUMENT_REMOVED", // Health/system "HEALTH_CHECKED", "SCOPE_DETECTED", "SCOPE_CHANGED", // User-driven plugin selection changes enabled graph/scope surfaces. "PLUGIN_SELECTION_CHANGED", "DEPTH_CHANGED", "TEST_STRATEGY_CHANGED", // Per-run review-class override changed (config-change --review). The // effective class each stage runs at is resolved at directive emission. "REVIEW_CLASS_CHANGED", // Change Control: the per-intent value moved (the change-control verb, or a // memory layer edit observed by a governed checkpoint), and a governed // checkpoint accepted an input change under `relaxed` instead of refusing. // Emitted through the library by aidlc-utility.ts and the checkpoint owners // (aidlc-state.ts, aidlc-log.ts, aidlc-testing-posture.ts). "CHANGE_CONTROL_SET", "CHANGE_ACCEPTED", // Adaptive composer: an in-flight plan re-shape (pending-stage suffix flips // via the recompose verb). Emitted by aidlc-utility.ts handleRecompose. "RECOMPOSED", // Jump events owned by STAGE_JUMPED — JUMP_COMPLETED was deleted as a // redundant alias. // Error/Recovery "ERROR_LOGGED", "RECOVERY_COMPLETED", // Construction Bolt execution "BOLT_STARTED", "BOLT_COMPLETED", "BOLT_FAILED", "AUTONOMY_MODE_SET", "UNIT_OWNERSHIP_SET", "UNIT_GATE_RHYTHM_SET", "UNIT_MERGED", // Worktree lifecycle: // WORKTREE_* emitted by aidlc-worktree.ts // STATE_* emitted by aidlc-state.ts state-fork/state-merge // AUDIT_* emitted by audit-fork/audit-merge handlers below "WORKTREE_CREATED", "WORKTREE_MERGED", "WORKTREE_DISCARDED", "STATE_FORKED", "STATE_MERGED", "AUDIT_FORKED", "AUDIT_MERGED", // Practices (stage events + runtime events) "PRACTICES_DISCOVERED", "PRACTICES_AFFIRMED", "PRACTICES_OVERRIDE", "PRACTICES_SECTION_EMPTY", // Merge Dispatch (emitter wired via aidlc-bolt.ts dispatch-event) "MERGE_DISPATCH_INVOKED", "MERGE_DISPATCH_RETURNED", "MERGE_DISPATCH_FALLBACK", // Sensors (emitters wired by sensor dispatcher for SENSOR_*; doctor for // GUARDRAIL_LOADED) "SENSOR_FIRED", "SENSOR_PASSED", "SENSOR_FAILED", "SENSOR_BUDGET_OVERRIDE", "GUARDRAIL_LOADED", // Learning Loop (MEMORY_EMPTY emitter wired by aidlc-runtime.ts compile; // RULE_LEARNED + SENSOR_PROPOSED emitters wired by aidlc-learnings.ts persist) "MEMORY_EMPTY", "RULE_LEARNED", "SENSOR_PROPOSED", // Swarm lifecycle — all emit from the swarm referee aidlc-swarm.ts (the // per-Unit pair + batch tally from `finalize`; SWARM_STARTED + SWARM_DEGRADED // from `prepare`). See CHANGELOG + audit-format.md. "SWARM_STARTED", "SWARM_UNIT_CONVERGED", "SWARM_SOURCE_MERGED", "SWARM_UNIT_FAILED", "SWARM_BATON_RETURNED", "SWARM_COMPLETED", "SWARM_DEGRADED", ]); // --- Event type to human-readable heading --- const EVENT_HEADINGS: Record = { STAGE_STARTED: "Stage Start", STAGE_AWAITING_APPROVAL: "Stage Awaiting Approval", STAGE_REVISING: "Stage Revising", STAGE_COMPLETED: "Stage Completion", STAGE_JUMPED: "Stage Jump", STAGE_SKIPPED: "Stage Skip", PHASE_STARTED: "Phase Start", PHASE_COMPLETED: "Phase Completion", PHASE_VERIFIED: "Phase Verification", PHASE_SKIPPED: "Phase Skip", WORKFLOW_STARTED: "Workflow Start", WORKFLOW_COMPLETED: "Workflow Completion", WORKFLOW_PARKED: "Workflow Parked", WORKFLOW_UNPARKED: "Workflow Unparked", SESSION_STARTED: "Session Start", SESSION_RESUMED: "Session Resume", SESSION_COMPACTED: "Session Compacted", SESSION_ENDED: "Session End", HUMAN_TURN: "Human Turn", WORKSPACE_SCAFFOLDED: "Workspace Scaffolded", WORKSPACE_SCANNED: "Workspace Scanned", WORKSPACE_INITIALISED: "Workspace Initialised", DECISION_RECORDED: "Decision Recorded", GATE_APPROVED: "Gate Approved", GATE_REJECTED: "Gate Rejected", QUESTION_ANSWERED: "Question Answered", SUMMARY_CONFIRMATION_RECORDED: "Summary Confirmation Recorded", PLAN_APPROVAL_RECORDED: "Plan Approval Recorded", PLAN_APPROVAL_OVERRIDDEN: "Plan Approval Overridden", REVIEW_REQUESTED: "Review Requested", REVIEW_COMPLETED: "Review Completed", PIPELINE_LINK_COMPLETED: "Pipeline Link Completed", UNIT_STARTED: "Unit Started", UNIT_PAUSED: "Unit Paused", UNIT_RESUMED: "Unit Resumed", UNIT_COMPLETED: "Unit Completed", ARTIFACT_CREATED: "Artifact Created", ARTIFACT_UPDATED: "Artifact Updated", ARTIFACT_REUSED: "Artifact Reused", SUBAGENT_COMPLETED: "Subagent Completed", REVIEWER_SCOPE_BLOCKED: "Reviewer Scope Blocked", REVIEW_FREEZE_BLOCKED: "Review Freeze Blocked", PLAN_APPROVAL_BLOCKED: "Plan Approval Blocked", GUARD_DISABLED: "Guard Disabled", DOCUMENT_INDEXED: "Document Indexed", DOCUMENT_UPDATED: "Document Updated", DOCUMENT_REMOVED: "Document Removed", HEALTH_CHECKED: "Health Check", SCOPE_DETECTED: "Scope Detection", SCOPE_CHANGED: "Scope Change", PLUGIN_SELECTION_CHANGED: "Plugin Selection Change", DEPTH_CHANGED: "Depth Change", TEST_STRATEGY_CHANGED: "Test Strategy Change", REVIEW_CLASS_CHANGED: "Review Class Change", CHANGE_CONTROL_SET: "Change Control Set", CHANGE_ACCEPTED: "Change Accepted", RECOMPOSED: "Plan Recomposed", ERROR_LOGGED: "Error Logged", RECOVERY_COMPLETED: "Recovery Completed", BOLT_STARTED: "Bolt Started", BOLT_COMPLETED: "Bolt Completed", BOLT_FAILED: "Bolt Failed", AUTONOMY_MODE_SET: "Autonomy Mode Set", UNIT_OWNERSHIP_SET: "Unit Ownership Set", UNIT_GATE_RHYTHM_SET: "Unit Gate Rhythm Set", UNIT_MERGED: "Unit Merged", WORKTREE_CREATED: "Worktree Created", WORKTREE_MERGED: "Worktree Merged", WORKTREE_DISCARDED: "Worktree Discarded", STATE_FORKED: "State Forked", STATE_MERGED: "State Merged", AUDIT_FORKED: "Audit Forked", AUDIT_MERGED: "Audit Merged", PRACTICES_DISCOVERED: "Practices Discovered", PRACTICES_AFFIRMED: "Practices Affirmed", PRACTICES_OVERRIDE: "Practices Override", PRACTICES_SECTION_EMPTY: "Practices Section Empty", MERGE_DISPATCH_INVOKED: "Merge Dispatch Invoked", MERGE_DISPATCH_RETURNED: "Merge Dispatch Returned", MERGE_DISPATCH_FALLBACK: "Merge Dispatch Fallback", SENSOR_FIRED: "Sensor Fired", SENSOR_PASSED: "Sensor Passed", SENSOR_FAILED: "Sensor Failed", SENSOR_BUDGET_OVERRIDE: "Sensor Budget Override", GUARDRAIL_LOADED: "Guardrail Loaded", MEMORY_EMPTY: "Memory Empty", RULE_LEARNED: "Rule Learned", SENSOR_PROPOSED: "Sensor Proposed", SWARM_STARTED: "Swarm Started", SWARM_UNIT_CONVERGED: "Swarm Unit Converged", SWARM_SOURCE_MERGED: "Swarm Source Merged", SWARM_UNIT_FAILED: "Swarm Unit Failed", SWARM_BATON_RETURNED: "Swarm Baton Returned", SWARM_COMPLETED: "Swarm Completed", SWARM_DEGRADED: "Swarm Degraded", }; // --- Helpers --- function jsonSuccess(data: Record): void { process.stdout.write(`${JSON.stringify(data)}\n`); } function jsonError(message: string): never { process.stderr.write(`${JSON.stringify({ error: message })}\n`); process.exit(1); } const CLI_RESERVED_EVENT_TYPES = new Set([ "HUMAN_TURN", "SUMMARY_CONFIRMATION_RECORDED", "PLAN_APPROVAL_RECORDED", "PLAN_APPROVAL_OVERRIDDEN", "ARTIFACT_CREATED", "ARTIFACT_UPDATED", "ARTIFACT_REUSED", "REVIEW_REQUESTED", "REVIEW_COMPLETED", "PIPELINE_LINK_COMPLETED", ]); function refuseReservedCliEvent(eventType: string): void { if (CLI_RESERVED_EVENT_TYPES.has(eventType)) { jsonError( `${eventType} is reserved for its owning hook/tool and cannot be appended through the public audit CLI.`, ); } } function refuseReservedCliBatch(entriesJson: string): void { try { const entries = JSON.parse(entriesJson) as unknown; if (!Array.isArray(entries)) return; for (const entry of entries) { if ( typeof entry === "object" && entry !== null && "eventType" in entry && typeof entry.eventType === "string" ) { refuseReservedCliEvent(entry.eventType); } } } catch { // The normal append-batch parser owns malformed-JSON diagnostics. } } // --- Subcommand: append --- export interface AuditEntryInput { eventType: string; fields: Record; } // Authority-bearing events: rows the engine's guards read as authorization // evidence — completed-stage receipts (validity routing), human presence // (humanActedSinceGate), gate resolutions, interview // answers (one-answer-per-human-turn), reviewer receipts // (verifyReviewerPrecondition), swarm attempt/convergence (the finalize and // artifact-guard boundaries), and the autonomy grant. Each has exactly one owning emitter that // reaches appendAuditEntry through the library import (hooks, aidlc-state, // aidlc-log, aidlc-swarm, aidlc-bolt). The public CLI refuses them so a // conductor cannot mint authority it does not have; everything else stays // CLI-appendable as the diagnostic escape hatch. Test fixtures that simulate // the owning emitters set AIDLC_ALLOW_DIRECT_AUDIT_EVENTS=1 (the same escape // idiom as AIDLC_ALLOW_DIRECT_STATE_TRANSITIONS in aidlc-state.ts). export const CLI_PROTECTED_EVENT_TYPES = new Set([ "STAGE_COMPLETED", "HUMAN_TURN", "GATE_APPROVED", "GATE_REJECTED", "QUESTION_ANSWERED", "PLAN_APPROVAL_RECORDED", "PLAN_APPROVAL_OVERRIDDEN", "REVIEW_REQUESTED", "REVIEW_COMPLETED", "PIPELINE_LINK_COMPLETED", "ARTIFACT_REUSED", "SWARM_STARTED", "SWARM_UNIT_CONVERGED", "SWARM_SOURCE_MERGED", "AUTONOMY_MODE_SET", "UNIT_OWNERSHIP_SET", "UNIT_GATE_RHYTHM_SET", // Unit lifecycle receipts: routing trusts UNIT_COMPLETED as the completion // signal (unitSettled) and UNIT_PAUSED as the hard-stop checkpoint, and the // owning verb verifies artifacts before committing — a CLI-forged receipt // would skip that verification. Owned by `aidlc-state.ts unit`. "UNIT_STARTED", "UNIT_PAUSED", "UNIT_RESUMED", "UNIT_COMPLETED", "UNIT_MERGED", // DocumentKB provenance: the knowledge tool emits these through the library // inside its catalog transaction. A CLI-forged DOCUMENT_INDEXED whose // Digest+Source match a real row would make the tool's idempotent // audit-repair pass treat provenance as already recorded and SUPPRESS the // genuine row, so the CLI must not mint them. "DOCUMENT_INDEXED", "DOCUMENT_UPDATED", "DOCUMENT_REMOVED", // Change Control provenance: a governed checkpoint owns the acceptance row // and the verb owns the setting row. A CLI-forged CHANGE_ACCEPTED would make // a change look already reported and suppress the genuine row. "CHANGE_CONTROL_SET", "CHANGE_ACCEPTED", ]); // Events a WORKTREE DELTA may never carry into the main intent shard. This is // deliberately an explicit enumeration, not prefix families: a Bolt/swarm // worktree legitimately emits STAGE_*, SENSOR_*, REVIEW_REQUESTED/COMPLETED // (the per-unit reviewer receipts the SKILL instructs recording with // --project-dir ) and ARTIFACT_* rows as its work product, and the // referee's defence against a lying conductor is artifact re-verification at // finalize, not delta filtering. A prefix blacklist over those families // refused exactly the delta the swarm contract requires and made // `bolt complete --merge` deterministically unrecoverable (the delta bytes // never change), which broke t49/t134. What IS blocked: // - human authority: the presence/gate events humanActedSinceGate and the // gate flow trust; a merged forgery would satisfy a gate no human saw. // - unit lifecycle receipts: routing trusts UNIT_COMPLETED (unitSettled) // and the owning verb verifies artifacts before committing. // - referee bookkeeping: fork/merge/swarm/bolt/worktree lifecycle rows are // emitted main-side by the referee; a delta copy would double-count. // - DOCUMENT_* (prefix, future-proof): DocumentKB rows live in the // space-level shard by design; one in an intent delta is a forgery. const MERGE_PROTECTED_EVENT_TYPES = new Set([ // Human authority (GATE_RESOLUTION_EVENTS + presence + autonomy). "HUMAN_TURN", "GATE_APPROVED", "GATE_REJECTED", "QUESTION_ANSWERED", "SUMMARY_CONFIRMATION_RECORDED", "PLAN_APPROVAL_RECORDED", "PLAN_APPROVAL_OVERRIDDEN", "AUTONOMY_MODE_SET", "UNIT_OWNERSHIP_SET", "UNIT_GATE_RHYTHM_SET", // Routing-trusted unit lifecycle receipts. "UNIT_STARTED", "UNIT_PAUSED", "UNIT_RESUMED", "UNIT_COMPLETED", // Referee/conductor bookkeeping, emitted against main only. "AUDIT_FORKED", "AUDIT_MERGED", "STATE_FORKED", "STATE_MERGED", "SWARM_STARTED", "SWARM_COMPLETED", "SWARM_DEGRADED", "SWARM_BATON_RETURNED", "SWARM_UNIT_CONVERGED", "SWARM_SOURCE_MERGED", "SWARM_UNIT_FAILED", "BOLT_STARTED", "BOLT_COMPLETED", "BOLT_FAILED", "WORKTREE_CREATED", "WORKTREE_DISCARDED", "WORKTREE_MERGED", ]); function mergeEventIsProtected(eventType: string): boolean { if (MERGE_PROTECTED_EVENT_TYPES.has(eventType)) return true; return eventType.startsWith("DOCUMENT_"); } function directAuditEventsAllowed(): boolean { return process.env.AIDLC_ALLOW_DIRECT_AUDIT_EVENTS === "1"; } function refuseProtectedEvent(eventType: string): never { jsonError( `Direct emission of ${eventType} is blocked: it is an authority-bearing receipt owned by its ` + "emitting tool or hook (gate resolutions and approvals come from aidlc-orchestrate.ts report, " + "interview answers and reviews from aidlc-log.ts, human presence from the prompt-submit hook). " + "The audit CLI appends diagnostic events only." ); } // Field keys that can spoof event queries. A caller-supplied `Event` field // lands as a SECOND `**Event**:` line, and the multiline regex in // findAllEvents matches ANY line of a block — so a smuggled `--field // Event=HUMAN_TURN` on a harmless event type would register as a forged event // in every query. `Timestamp` is deliberately NOT reserved: the public `append` // CLI accepts it, and it cannot spoof — the emitter's own `**Timestamp**:` line // is written first and every parser takes the first match. renderAuditBlock // drops it instead, so it can never render a second line. const RESERVED_FIELD_KEYS = new Set(["Event"]); // Keys renderAuditBlock writes itself, and therefore never re-renders from // `fields`. `Event` is already refused by RESERVED_FIELD_KEYS before render // (belt-and-braces); `Timestamp` is accepted there on purpose, so this set is // the only thing keeping a caller-supplied value from emitting a SECOND // `**Timestamp**:` line — which would break any whole-file reader that zips // `**Timestamp**` occurrences against `**Event**` occurrences. const EMITTER_OWNED_FIELD_KEYS = new Set(["Timestamp", "Event"]); const AUDIT_FIELD_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9 ._()/-]*$/; function validateAuditEntry(entry: AuditEntryInput): void { if (!VALID_EVENT_TYPES.has(entry.eventType)) { throw new Error( `Invalid event type: ${entry.eventType}. Must be one of: ${[...VALID_EVENT_TYPES].join(", ")}` ); } for (const key of Object.keys(entry.fields)) { if (RESERVED_FIELD_KEYS.has(key)) { throw new Error( `Reserved field key: ${key}. The emitter writes **${key}**: itself; a caller-supplied ` + "value would forge a second matching line and spoof multiline event queries." ); } if (!AUDIT_FIELD_KEY_PATTERN.test(key)) { throw new Error( `Invalid audit field key: ${JSON.stringify(key)}. Field keys must match ` + `${AUDIT_FIELD_KEY_PATTERN} so they remain one Markdown label on one physical line.` ); } } } function renderAuditBlock( entry: AuditEntryInput, timestamp: string, projectDir: string, ): string { const heading = EVENT_HEADINGS[entry.eventType] || entry.eventType; let block = `\n## ${heading}\n`; block += `**Timestamp**: ${timestamp}\n`; block += `**Event**: ${entry.eventType}\n`; for (const [key, value] of Object.entries(entry.fields)) { // The emitter already wrote these above; re-rendering one would put a // second identically-marked line in the block (issue #715). if (EMITTER_OWNED_FIELD_KEYS.has(key)) continue; // Escape every JavaScript line terminator in values so a malicious or // malformed input cannot forge a second audit field or event line. const safeValue = redactProjectDirPrefix( String(value), projectDir, ).replace(/\r\n?|\n|\u2028|\u2029/g, "\\n"); block += `**${key}**: ${safeValue}\n`; } return `${block}\n---\n`; } // One best-effort metrics tap shared by every structured audit append path. // Lazy loading keeps metrics fully opt-in and lets audit-only fixtures omit the // module. Each call catches independently so one lost metric cannot block an // audit write or suppress later events in a batch. function tapAuditMetric( eventType: string, fields: Record, projectDir: string, ): void { if (!process.env.AIDLC_METRICS_ENDPOINT) return; try { const metrics = require("./aidlc-metrics.ts") as { emitMetricForAuditEvent: ( eventType: string, fields: Record, projectDir: string, ) => void; }; metrics.emitMetricForAuditEvent(eventType, fields, projectDir); } catch { // Metrics module missing or emit failed - never propagate. } } // Core append logic — throws on error instead of exiting. Safe for library callers. // CLI caller (main) wraps this in try/catch and translates to jsonError. export function appendAuditEntry( eventType: string, fields: Record, projectDir: string, intent?: string, space?: string ): { appended: true; event: string; timestamp: string } { validateAuditEntry({ eventType, fields }); // Lock + audit shard both pin to the same (intent, space) record so a fork/ // merge pair targets ONE intent end-to-end; omitted -> default-resolution. if (!acquireAuditLock(projectDir, 50, 100, intent, space)) { throw new Error("Failed to acquire audit lock after retries"); } try { return appendAuditEntryUnlocked(eventType, fields, projectDir, intent, space); } finally { releaseAuditLock(projectDir, intent, space); } } // Lock-already-held variant for callers that need to hold the audit lock // across multiple operations (e.g., aidlc-state.ts fork/merge, which read // state, decide on a write, emit audit, and write state — all inside one // critical section). The caller MUST have acquired the audit lock via // acquireAuditLock(projectDir) and MUST release it (via releaseAuditLock or // equivalent) regardless of how this function returns. Validates the event // type the same way as the locked variant; everything else is identical. export function appendAuditEntryUnlocked( eventType: string, fields: Record, projectDir: string, intent?: string, space?: string ): { appended: true; event: string; timestamp: string } { const entry = { eventType, fields }; validateAuditEntry(entry); const ts = isoTimestamp(); appendAuditBlockAtPath( projectDir, auditFilePath(projectDir, intent, space), renderAuditBlock(entry, ts, projectDir), ); tapAuditMetric(eventType, fields, projectDir); return { appended: true, event: eventType, timestamp: ts }; } // Append to an EXPLICIT shard path, bypassing (intent, space) resolution. // // Every other append derives its shard from `auditFilePath`, and that resolution // has a sharp edge: `intent === undefined` does NOT mean "no intent" -- it means // "resolve one from the cursor" (auditFilePath -> recordDir -> activeIntent, // which falls back to the active-intent pointer, then to a lone intent). The // space-level shard is only reached when a space has NO intents at all, so a // caller cannot ASK for it. // // DocumentKB needs to: a document outlives any intent, and its scope can change // later, so filing its provenance under whichever intent happened to be active // would split one document's history across shards. It composes the space shard // itself and passes it here. // // Deliberately does NOT lock -- the caller holds the lock across a wider // transaction, and the locking variant would deadlock on itself. Validation, // rendering, and the metric tap are identical to every other append, so a row // written this way is indistinguishable from one written the usual way. function writeAll(fd: number, content: string): void { const bytes = Buffer.from(content, "utf-8"); let offset = 0; while (offset < bytes.length) { const written = writeSync(fd, bytes, offset, bytes.length - offset); if (written <= 0) throw new Error("Audit append made no write progress"); offset += written; } } interface AuditFileIdentity { dev: number; ino: number } interface AuditAppendExpectation extends AuditFileIdentity { prefixLength: number; prefixHash: string; } function appendAuditBlockAtPath( projectDir: string, shardPath: string, block: string, expectedIdentity?: AuditAppendExpectation, ): void { // Every audit append funnels through here, so one barrier covers // appendAuditEntry, appendAuditEntryUnlocked, appendAuditEntryAtPathUnlocked // and appendAuditEntries: the ledger is authority evidence, and an observer // that appended to it would be minting the very receipts it came to read. refuseEngineObserverWrite("appendAuditBlockAtPath"); const dir = dirname(shardPath); const projectAbs = resolve(projectDir); const projectReal = realpathSync(projectAbs); const rel = relative(projectAbs, resolve(shardPath)); if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { throw new Error(`Refusing audit shard outside project: ${shardPath}`); } assertNoSymlinkInChainOrThrow(projectReal, rel); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); assertNoSymlinkInChainOrThrow(projectReal, rel); const noFollow = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0; let fd: number | undefined; try { fd = openSync( shardPath, fsConstants.O_RDWR | fsConstants.O_APPEND | fsConstants.O_CREAT | noFollow | fsConstants.O_NONBLOCK, 0o666, ); const opened = fstatSync(fd); if (!opened.isFile()) throw new Error(`Refusing non-regular audit shard: ${shardPath}`); // No nlink refusal on the ORDINARY append path: rsync --link-dest and // cp -al backup snapshots leave a live shard at nlink 2, and refusing it // here bricked every later gate/hook append framework-wide. A hardlink // aliases the same inode inside an already containment- and // symlink-chain-checked path, so it grants no redirect. The explicit // fork/merge path stays strict: readAuditSnapshot refuses a // multiply-linked main shard, and verifyExpectedPrefix below re-checks // during a merge append. if (expectedIdentity && (opened.dev !== expectedIdentity.dev || opened.ino !== expectedIdentity.ino)) { throw new Error(`Audit shard changed after validation: ${shardPath}`); } const verifyExpectedPrefix = (): void => { if (!expectedIdentity) return; const current = fstatSync(fd as number); if (current.nlink !== 1) throw new Error(`Audit shard became multiply linked: ${shardPath}`); const prefix = Buffer.alloc(expectedIdentity.prefixLength); let offset = 0; while (offset < prefix.length) { const count = readSync(fd as number, prefix, offset, prefix.length - offset, offset); if (count === 0) break; offset += count; } const hash = createHash("sha256").update(prefix.subarray(0, offset)).digest("hex"); if (offset !== expectedIdentity.prefixLength || hash !== expectedIdentity.prefixHash) { throw new Error(`Audit shard prefix changed after validation: ${shardPath}`); } }; // O_NOFOLLOW is not available on every platform and protects only the leaf. // Re-resolve after opening, require containment, and prove the pathname still // names the descriptor's inode before writing through the pinned descriptor. const verifyPathStillNamesDescriptor = (): void => { assertNoSymlinkInChainOrThrow(projectReal, rel); if (lstatSync(shardPath).isSymbolicLink()) { throw new Error(`Refusing symlinked audit shard: ${shardPath}`); } const currentReal = realpathSync(shardPath); if (currentReal !== projectReal && !currentReal.startsWith(`${projectReal}${sep}`)) { throw new Error(`Refusing audit shard outside project: ${shardPath}`); } const current = statSync(currentReal); if (current.dev !== opened.dev || current.ino !== opened.ino) { throw new Error(`Audit shard changed while opening: ${shardPath}`); } }; verifyPathStillNamesDescriptor(); verifyExpectedPrefix(); if (opened.size === 0) writeAll(fd, "# AI-DLC Audit Log\n"); writeAll(fd, block); // If an attacker renamed the leaf/parent during the descriptor write, fail // the enclosing audit-first transaction instead of reporting a ledger row // that is no longer discoverable at the canonical path. verifyPathStillNamesDescriptor(); verifyExpectedPrefix(); } finally { if (fd !== undefined) closeSync(fd); } } function readAuditSnapshot(projectDir: string, shardPath: string): { bytes: Buffer; identity: AuditFileIdentity; } { const projectReal = realpathSync(projectDir); const rel = relative(resolve(projectDir), resolve(shardPath)); if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { throw new Error(`Refusing audit shard outside project: ${shardPath}`); } assertNoSymlinkInChainOrThrow(projectReal, rel); const noFollow = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0; const fd = openSync(shardPath, fsConstants.O_RDONLY | noFollow | fsConstants.O_NONBLOCK); try { const opened = fstatSync(fd); if (!opened.isFile() || opened.nlink !== 1) { throw new Error(`Refusing non-regular or multiply-linked audit shard: ${shardPath}`); } const verify = (): void => { assertNoSymlinkInChainOrThrow(projectReal, rel); if (lstatSync(shardPath).isSymbolicLink()) { throw new Error(`Refusing symlinked audit shard: ${shardPath}`); } const real = realpathSync(shardPath); if (real !== projectReal && !real.startsWith(`${projectReal}${sep}`)) { throw new Error(`Refusing audit shard outside project: ${shardPath}`); } const current = statSync(real); if (current.dev !== opened.dev || current.ino !== opened.ino) { throw new Error(`Audit shard changed while reading: ${shardPath}`); } }; verify(); const bytes = readFileSync(fd); verify(); const after = fstatSync(fd); if (after.nlink !== 1 || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs || bytes.length !== opened.size) { throw new Error(`Audit shard changed while reading: ${shardPath}`); } return { bytes, identity: { dev: opened.dev, ino: opened.ino } }; } finally { closeSync(fd); } } export function appendAuditEntryAtPathUnlocked( eventType: string, fields: Record, projectDir: string, shardPath: string, ): { appended: true; event: string; timestamp: string } { const entry = { eventType, fields }; validateAuditEntry(entry); const ts = isoTimestamp(); appendAuditBlockAtPath( projectDir, shardPath, renderAuditBlock(entry, ts, projectDir), ); tapAuditMetric(eventType, fields, projectDir); return { appended: true, event: eventType, timestamp: ts }; } // Validate a related event set before touching disk, then append every block // under one lock with one write. This is the audit-only transaction primitive // for lifecycle pairs such as a synthetic single-stage STARTED/COMPLETED pair: // a malformed later entry cannot leave an earlier entry committed, and no // concurrent emitter can interleave between the blocks. export function appendAuditEntries( entries: AuditEntryInput[], projectDir: string, intent?: string, space?: string, ): { appended: true; events: string[]; timestamps: string[] } { if (entries.length === 0) { throw new Error("appendAuditEntries requires at least one entry"); } for (const entry of entries) validateAuditEntry(entry); const append = (): { appended: true; events: string[]; timestamps: string[] } => { const timestamps = entries.map(() => isoTimestamp()); const payload = entries .map((entry, index) => renderAuditBlock(entry, timestamps[index], projectDir) ) .join(""); appendAuditBlockAtPath(projectDir, auditFilePath(projectDir, intent, space), payload); for (const entry of entries) { tapAuditMetric(entry.eventType, entry.fields, projectDir); } return { appended: true, events: entries.map((entry) => entry.eventType), timestamps, }; }; // A caller may hold the same lock across this batch and its related state // write. In that transaction the validated one-write batch is already // serialized; attempting the non-reentrant acquisition would deadlock. if (holdsAuditLock(projectDir, intent, space)) return append(); if (!acquireAuditLock(projectDir, 50, 100, intent, space)) { throw new Error("Failed to acquire audit lock after retries"); } try { return append(); } finally { releaseAuditLock(projectDir, intent, space); } } // Legacy CLI-style wrapper. Kept for backward compatibility with aidlc-state/aidlc-jump/ // aidlc-log/aidlc-bolt — they import this and catch exceptions. The // main() caller below uses this same function but its catch block translates errors // via jsonError (which exits). export function handleAppend( eventType: string, fields: Record, projectDir: string ): void { if (CLI_PROTECTED_EVENT_TYPES.has(eventType) && !directAuditEventsAllowed()) { refuseProtectedEvent(eventType); } const result = appendAuditEntry(eventType, fields, projectDir); jsonSuccess(result); } function handleAppendBatch(rawEntries: string, projectDir: string): void { let parsed: unknown; try { parsed = JSON.parse(rawEntries); } catch { throw new Error("append-batch entries must be valid JSON"); } if (!Array.isArray(parsed) || parsed.length === 0) { throw new Error("append-batch entries must be a non-empty JSON array"); } const entries: AuditEntryInput[] = parsed.map((raw, index) => { if ( typeof raw !== "object" || raw === null || Array.isArray(raw) || typeof (raw as { eventType?: unknown }).eventType !== "string" || typeof (raw as { fields?: unknown }).fields !== "object" || (raw as { fields?: unknown }).fields === null || Array.isArray((raw as { fields?: unknown }).fields) ) { throw new Error( `append-batch entry ${index} must contain string eventType and object fields` ); } const fields = (raw as { fields: Record }).fields; if (Object.values(fields).some((value) => typeof value !== "string")) { throw new Error(`append-batch entry ${index} field values must be strings`); } return { eventType: (raw as { eventType: string }).eventType, fields: fields as Record, }; }); // Same ownership floor as `append`: a batch must not smuggle an // authority-bearing receipt among diagnostic rows. The engine's own batch // callers must not smuggle a protected receipt among diagnostic rows. The // synthetic single-stage owner uses appendAuditEntries directly instead of // crossing this public CLI boundary. for (const entry of entries) { if (CLI_PROTECTED_EVENT_TYPES.has(entry.eventType) && !directAuditEventsAllowed()) { refuseProtectedEvent(entry.eventType); } } jsonSuccess(appendAuditEntries(entries, projectDir)); } // --- Subcommand: append-raw --- function handleAppendRaw( heading: string, body: string, projectDir: string ): void { if (hasUnsafeSingleLineCharacter(heading)) { jsonError("append-raw heading must be printable text on one physical line"); } // A raw body is written verbatim, and every event query (findAllEvents, // auditBlockField) matches `**Event**:` lines anywhere in a block — so a raw // body carrying an `**Event**: ` line IS that event to every // reader, timestamp and all. Refuse taxonomy names outright (canonical events // go through `append`, which validates ownership); non-taxonomy Event lines // (custom diagnostics) stay allowed — no query resolves them to authority. const expandedBody = redactProjectDirPrefix( body.replace(/\\n/g, "\n"), projectDir, ); const safeHeading = redactProjectDirPrefix(heading, projectDir); for (const raw of expandedBody.split(/\r\n?|\n|\u2028|\u2029/)) { const line = raw.startsWith("- ") ? raw.slice(2) : raw; if (!line.startsWith("**Event**:")) continue; const value = line.slice("**Event**:".length).trim(); if (VALID_EVENT_TYPES.has(value)) { jsonError( `append-raw refuses a body carrying **Event**: ${value} — that line would register as a ` + "canonical audit event to every reader. Emit taxonomy events through their owning tool " + "(or `append` for diagnostic types); append-raw is for free-form notes only." ); } } const ts = isoTimestamp(); if (!acquireAuditLock(projectDir)) { jsonError("Failed to acquire audit lock after retries"); } try { // Interpret literal \n sequences in the body as actual newlines let block = `\n## ${safeHeading}\n`; block += `**Timestamp**: ${ts}\n`; block += `${expandedBody}\n`; block += `\n---\n`; appendAuditBlockAtPath(projectDir, auditFilePath(projectDir), block); } finally { releaseAuditLock(projectDir); } jsonSuccess({ appended: true, heading, timestamp: ts }); } // --- Subcommand: audit-fork --- // // audit-fork --slug [--project-dir ] // // Forks the main audit log into a Bolt's worktree on Bolt start. Byte-copies // main audit so the worktree is self-contained at fork instant. Records the // pre-emit byte-offset (Fork Boundary) and SHA-256 (Source Audit Hash) on // AUDIT_FORKED so audit-merge can recover both at gate-approval time. // // Audit-of-intent semantics: a fresh or dead-partial redo emits AUDIT_FORKED to // main BEFORE the mkdir + copy. A complete current fork returns as a no-op. If // the disk operation fails after emit, additionally emit ERROR_LOGGED with // [slug=] [fork-emitted:] so doctor can reconcile drift at // observation time. Mirrors aidlc-worktree.ts pattern. // // Why this exists as a tool subcommand: same load-bearing rationale as // aidlc-state.ts practices-promote — stage prose that names a write target // gets the LLM (under `claude -p`) to hallucinate a permission policy and // halt the workflow. Routing through a subcommand removes the LLM from the // path entirely. // The intent/space SELECTOR for a Bolt audit fork/merge pair: --intent // / --space pin BOTH ends to one intent's audit shard + worktree mirror // (vision §5). Omitted -> default-resolution (the active cursor), which is what // the orchestrator threads today. Returns undefined when a flag is absent so the // helpers default-resolve. function parseSelectorFlags(args: string[]): { intent?: string; space?: string } { let intent: string | undefined; let space: string | undefined; for (let i = 0; i < args.length; i++) { if (args[i] === "--intent" && i + 1 < args.length) { intent = args[i + 1]; i++; } else if (args[i] === "--space" && i + 1 < args.length) { space = args[i + 1]; i++; } } return { intent, space }; } function parseSlugFlag(args: string[], subcommand: string): string { let slug: string | undefined; for (let i = 0; i < args.length; i++) { if (args[i] === "--slug" && i + 1 < args.length) { slug = args[i + 1]; i++; } } if (!slug) { jsonError(`Usage: aidlc-audit ${subcommand} --slug [--project-dir ]`); } const err = validateBoltSlug(slug); if (err) { jsonError(err); } return slug; } function validateMergeDelta(delta: string): void { if (delta !== "" && !delta.endsWith("\n---\n")) { throw new Error("worktree audit delta ends with an incomplete block"); } for (const block of delta.split(/\n---\n/).filter((part) => part.trim() !== "")) { const eventMatches = [...block.matchAll(/^(?:-\s*)?\*\*Event\*\*:\s*(.+)$/gm)]; const timestampMatches = [...block.matchAll(/^(?:-\s*)?\*\*Timestamp\*\*:\s*(.+)$/gm)]; if (eventMatches.length === 0) { const timestamps = block.match(/^(?:-\s*)?\*\*Timestamp\*\*:/gm) ?? []; if (timestamps.length !== 1) throw new Error("worktree audit delta has malformed note block"); continue; // complete append-raw diagnostic note } if (eventMatches.length !== 1) throw new Error("worktree audit delta has duplicate Event fields"); if (timestampMatches.length !== 1) { throw new Error("worktree audit delta must contain exactly one Timestamp field"); } const eventType = eventMatches[0][1].trim(); if (!VALID_EVENT_TYPES.has(eventType)) { throw new Error(`worktree audit delta contains unknown event ${eventType}`); } if (mergeEventIsProtected(eventType)) { throw new Error(`worktree audit delta contains protected authority event ${eventType}`); } const fields: Record = {}; for (const match of block.matchAll(/^(?:-\s*)?\*\*([^*]+)\*\*:\s*(.*)$/gm)) { const key = match[1].trim(); if (key !== "Event" && key !== "Timestamp") fields[key] = match[2]; } validateAuditEntry({ eventType, fields }); } } function exactAuditField(block: string, name: string): string | null { const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const matches = [...block.matchAll( new RegExp(`^(?:-\\s*)?\\*\\*${escaped}\\*\\*:\\s*(.*)$`, "gm"), )]; return matches.length === 1 ? matches[0][1].trim() : null; } interface CompleteAuditBlock { block: string; start: number; end: number; } interface AuditForkRecord extends CompleteAuditBlock { boundary: number; sourceHash: string; timestamp: string; } function completeAuditBlocks(content: string): CompleteAuditBlock[] { const separator = "\n---\n"; const blocks: CompleteAuditBlock[] = []; let start = 0; while (start < content.length) { const separatorStart = content.indexOf(separator, start); if (separatorStart < 0) break; const end = separatorStart + separator.length; blocks.push({ block: content.slice(start, separatorStart), start, end }); start = end; } return blocks; } function parseAuditForkBlock(block: CompleteAuditBlock, slug: string): AuditForkRecord | null { if ( exactAuditField(block.block, "Event") !== "AUDIT_FORKED" || exactAuditField(block.block, "Bolt slug") !== slug ) { return null; } const boundaryField = exactAuditField(block.block, "Fork Boundary"); const sourceHash = exactAuditField(block.block, "Source Audit Hash"); const timestamp = exactAuditField(block.block, "Timestamp"); if ( !boundaryField || !/^\d+$/.test(boundaryField) || !sourceHash || !/^[0-9a-f]{64}$/.test(sourceHash) || !timestamp ) { return null; } const boundary = Number(boundaryField); if (!Number.isSafeInteger(boundary) || boundary < 0) return null; return { ...block, boundary, sourceHash, timestamp }; } function latestAuditFork(content: string, slug: string): AuditForkRecord | null { const blocks = completeAuditBlocks(content); for (let i = blocks.length - 1; i >= 0; i--) { const fork = parseAuditForkBlock(blocks[i], slug); if (fork) return fork; } return null; } function forksCorrelate(left: AuditForkRecord, right: AuditForkRecord): boolean { return ( left.boundary === right.boundary && left.sourceHash === right.sourceHash && left.timestamp === right.timestamp ); } function matchingAuditMerge( content: string, slug: string, fork: AuditForkRecord, ): CompleteAuditBlock | null { for (const block of completeAuditBlocks(content)) { if (block.start < fork.end) continue; if ( exactAuditField(block.block, "Event") !== "AUDIT_MERGED" || exactAuditField(block.block, "Bolt slug") !== slug ) { continue; } const forkTimestamp = exactAuditField(block.block, "Fork Timestamp"); if (forkTimestamp !== null) { if (forkTimestamp === fork.timestamp) return block; continue; } // Backward compatibility for receipts written before Fork Timestamp was // added: the boundary + source hash pair uniquely identifies the fork. if ( exactAuditField(block.block, "Fork Boundary") === String(fork.boundary) && exactAuditField(block.block, "Source Audit Hash") === fork.sourceHash ) { return block; } } return null; } function containsDeltaAtBlockBoundary(content: string, delta: string, after: number): boolean { if (delta === "") return false; let position = content.indexOf(delta, after); while (position >= 0) { if (position === after || content.slice(Math.max(0, position - 5), position) === "\n---\n") { return true; } position = content.indexOf(delta, position + 1); } return false; } function handleAuditFork(args: string[], projectDir: string): void { const slug = parseSlugFlag(args, "audit-fork"); // Pin the main-side audit shard AND the worktree mirror to ONE intent so // audit-fork/merge operate on the same record (the SAME selector the state // fork used). recordPrefix is the worktree mirror's relative record dir // (null -> flat-legacy mirror, today's behaviour). const { intent, space } = parseSelectorFlags(args); const wtPath = worktreePath(projectDir, slug); const priorForkVerification = existsSync(wtPath) ? worktreeClaimBoundaryMatches(projectDir, wtPath, slug) : null; let scopeStamp: ReturnType; if (priorForkVerification) { validateLiveUnitScope(projectDir, slug); scopeStamp = priorForkVerification; } else { scopeStamp = requireLiveClaimForTeamUnit(projectDir, slug, { intent, space, walkingSkeletonMain: args.includes("--walking-skeleton-main"), }); } const recordPrefix = relativeRecordDir(projectDir, intent, space); const mainAuditPath = auditFilePath(projectDir, intent, space); // Pre-emit guards (fail clean before any audit side-effect). if (!existsSync(mainAuditPath)) { jsonError(`main audit not found at ${mainAuditPath}; start a workflow first (describe what to build, e.g. /aidlc "build the auth service")`); } if (!existsSync(wtPath)) { jsonError( `worktree directory not found at ${wtPath}; run aidlc-worktree create first` ); } if (!acquireAuditLock(projectDir, 50, 100, intent, space)) { jsonError("Failed to acquire audit lock after retries"); } let boundary = 0; let sourceHash = ""; let auditTs = ""; let alreadyCurrent = false; let wtAuditPath = ""; try { const projectReal = realpathSync(projectDir); const wtRel = relative(resolve(projectDir), resolve(wtPath)); if (wtRel === "" || wtRel === ".." || wtRel.startsWith(`..${sep}`) || isAbsolute(wtRel)) { throw new Error(`worktree path is outside project: ${wtPath}`); } assertNoSymlinkInChainOrThrow(projectReal, wtRel); const wtReal = realpathSync(wtPath); const verifyWorktreeIdentity = (): void => { assertNoSymlinkInChainOrThrow(projectReal, wtRel); if (realpathSync(wtPath) !== wtReal) { throw new Error(`worktree path changed during audit-fork: ${wtPath}`); } }; if (scopeStamp) { const wtClone = cloneIdPath(wtPath); verifyWorktreeIdentity(); assertNoSymlinkInChainOrThrow(wtReal, relative(wtPath, wtClone)); if (!existsSync(wtClone)) { mkdirSync(dirname(wtClone), { recursive: true }); verifyWorktreeIdentity(); assertNoSymlinkInChainOrThrow(wtReal, relative(wtPath, wtClone)); const noFollow = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0; const bytes = Buffer.from( `${randomUUID().replace(/-/g, "").slice(0, 12)}\n`, ); let cloneFd: number | undefined; try { cloneFd = openSync( wtClone, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, 0o600, ); const identity = fstatSync(cloneFd); if (!identity.isFile()) { throw new Error("worktree clone id target is not a regular file"); } writeSync(cloneFd, bytes, 0, bytes.length); } finally { if (cloneFd !== undefined) closeSync(cloneFd); } verifyWorktreeIdentity(); assertNoSymlinkInChainOrThrow(wtReal, relative(wtPath, wtClone)); const cloneReal = realpathSync(wtClone); const cloneRel = relative(wtReal, cloneReal); if ( cloneRel === "" || cloneRel === ".." || cloneRel.startsWith(`..${sep}`) || isAbsolute(cloneRel) || !lstatSync(wtClone).isFile() ) { throw new Error("worktree clone id escaped the verified worktree"); } } } // Fork and merge both select the worktree's clone ID exactly when the // state fork copied a claim stamp into that worktree. wtAuditPath = worktreeAuditFilePath( wtPath, recordPrefix, scopeStamp ? wtPath : projectDir, ); const before = readAuditSnapshot(projectDir, mainAuditPath); if (existsSync(wtAuditPath)) { const existing = readAuditSnapshot(projectDir, wtAuditPath); const existingContent = existing.bytes.toString("utf-8"); const existingFork = latestAuditFork(existingContent, slug); if (existingFork) { if (existingContent.slice(existingFork.end) !== "") { throw new Error( `worktree audit already exists at ${wtAuditPath} with unmerged work after ` + `AUDIT_FORKED; merge the delta with audit-merge, or discard the worktree`, ); } const mainContent = before.bytes.toString("utf-8"); const mainFork = latestAuditFork(mainContent, slug); if (!mainFork || !forksCorrelate(existingFork, mainFork)) { throw new Error( `worktree audit already exists at ${wtAuditPath}, but its AUDIT_FORKED row ` + `does not match the authoritative main row; discard the worktree before re-forking`, ); } const expected = scopeStamp ? before.bytes.subarray(mainFork.start, mainFork.end) : before.bytes.subarray(0, mainFork.end); if (!existing.bytes.equals(expected)) { throw new Error( `worktree audit already exists at ${wtAuditPath}, but its fork prefix differs ` + `from main; discard the worktree before re-forking`, ); } boundary = existingFork.boundary; sourceHash = existingFork.sourceHash; auditTs = existingFork.timestamp; alreadyCurrent = true; } } if (!alreadyCurrent) { boundary = before.bytes.length; sourceHash = createHash("sha256").update(before.bytes).digest("hex"); const forkEntry = { eventType: "AUDIT_FORKED", fields: { "Bolt slug": slug, "Source Audit Hash": sourceHash, "Fork Boundary": String(boundary), ...claimAttemptFields(projectDir, slug), }, }; validateAuditEntry(forkEntry); auditTs = isoTimestamp(); appendAuditBlockAtPath( projectDir, mainAuditPath, renderAuditBlock(forkEntry, auditTs, projectDir), { ...before.identity, prefixLength: boundary, prefixHash: sourceHash, }, ); tapAuditMetric("AUDIT_FORKED", forkEntry.fields, projectDir); // Worktree-local tools must append to the fork shard that audit-merge // consumes. Swarm worktrees share the parent clone token; claimed Unit // worktrees keep the fresh token minted above. const wtCloneIdPath = cloneIdPath(wtPath); const cloneBytes = scopeStamp ? readRegularFileNoFollowOrThrow(wtCloneIdPath, "worktree clone id") : readRegularFileNoFollowOrThrow(cloneIdPath(projectDir), "clone id"); verifyWorktreeIdentity(); assertNoSymlinkInChainOrThrow(wtReal, relative(wtPath, wtCloneIdPath)); mkdirSync(dirname(wtCloneIdPath), { recursive: true }); verifyWorktreeIdentity(); assertNoSymlinkInChainOrThrow(wtReal, relative(wtPath, wtCloneIdPath)); writeBufferAtomic(wtCloneIdPath, cloneBytes); verifyWorktreeIdentity(); const mainAfterForkSnapshot = readAuditSnapshot(projectDir, mainAuditPath); if (mainAfterForkSnapshot.identity.dev !== before.identity.dev || mainAfterForkSnapshot.identity.ino !== before.identity.ino) { throw new Error("main audit changed identity during audit-fork"); } const mainAfterFork = mainAfterForkSnapshot.bytes; const worktreeForkBytes = scopeStamp ? (() => { const mainFork = latestAuditFork(mainAfterFork.toString("utf-8"), slug); if (!mainFork) throw new Error("main audit is missing the emitted AUDIT_FORKED row"); return mainAfterFork.subarray(mainFork.start, mainFork.end); })() : mainAfterFork; verifyWorktreeIdentity(); assertNoSymlinkInChainOrThrow(wtReal, relative(wtPath, wtAuditPath)); mkdirSync(dirname(wtAuditPath), { recursive: true }); verifyWorktreeIdentity(); assertNoSymlinkInChainOrThrow(wtReal, relative(wtPath, wtAuditPath)); writeBufferAtomic(wtAuditPath, worktreeForkBytes); verifyWorktreeIdentity(); } } catch (e) { const message = e instanceof Error ? errorMessage(e) : String(e); try { if (auditTs !== "") { appendAuditEntryUnlocked( "ERROR_LOGGED", { Tool: "aidlc-audit", Command: "audit-fork", Error: `[slug=${slug}] [fork-emitted:${auditTs}] ${message}`, }, projectDir, intent, space, ); } } finally { releaseAuditLock(projectDir, intent, space); } jsonError(message); } releaseAuditLock(projectDir, intent, space); jsonSuccess({ emitted: "AUDIT_FORKED", slug, source_audit_hash: sourceHash, fork_boundary: boundary, worktree_audit: wtAuditPath, audit_timestamp: auditTs, ...(alreadyCurrent ? { already_current: true, message: "audit fork already exists and is current; no changes made", } : {}), }); } // --- Subcommand: audit-merge --- // // audit-merge --slug [--project-dir ] // // Merges a Bolt's worktree audit deltas back into the main audit on gate // approval. Recovers Fork Boundary + Source Audit Hash from the worktree's // AUDIT_FORKED entry, sanity-checks the prefix-hash against main audit's // current first-`boundary` bytes (refuses on mismatch — catches mid-Bolt // tampering or main-audit truncation), then appends the post-fork delta and // emits AUDIT_MERGED. // // Delta detection is parse-driven: locate the AUDIT_FORKED block in the // worktree audit, take everything after that block's "\n---\n" separator. // The Fork Boundary field is used solely as the prefix-hash anchor, NOT for // delta math (the worktree audit's copy of AUDIT_FORKED extends beyond // `boundary` by the entry's own size; trusting `boundary` for delta-start // would duplicate AUDIT_FORKED on merge-back). // // Lock budget: extended from acquireAuditLock's 5s default to 20s // (200 retries × 100ms) to absorb N=4-8 Bolt-merge contention in workshop // scenarios. // // One lock guards prefix validation, retry detection, and the combined // delta+AUDIT_MERGED append. The retry pre-check runs immediately before that // append; post-write verification order stays unchanged. function handleAuditMerge(args: string[], projectDir: string): void { const slug = parseSlugFlag(args, "audit-merge"); // Same selector the state/audit fork used -> the SAME intent record on both // ends (vision §5). recordPrefix pins the worktree audit mirror. const { intent, space } = parseSelectorFlags(args); const recordPrefix = relativeRecordDir(projectDir, intent, space); const mainAuditPath = auditFilePath(projectDir, intent, space); const wtPath = worktreePath(projectDir, slug); const scopeStamp = requireLiveClaimForTeamUnit(projectDir, slug, { intent, space, walkingSkeletonMain: true, }); const wtAuditPath = worktreeAuditFilePath( wtPath, recordPrefix, scopeStamp ? wtPath : projectDir, ); if (!existsSync(wtAuditPath)) { jsonError(`worktree audit not found at ${wtAuditPath}; nothing to merge`); } if (!existsSync(mainAuditPath)) { jsonError(`main audit not found at ${mainAuditPath}; start a workflow first (describe what to build, e.g. /aidlc "build the auth service")`); } const wtSnapshot = readAuditSnapshot(projectDir, wtAuditPath); const wtContent = wtSnapshot.bytes.toString("utf-8"); const fork = latestAuditFork(wtContent, slug); if (!fork) { jsonError(`worktree audit missing AUDIT_FORKED entry for slug ${slug}`); } const boundary = fork.boundary; const sourceHash = fork.sourceHash; const forkTs = fork.timestamp; // forkTs anchors the audit-of-intent correlation tag for any post-emit // failure on this merge — doctor joins this back to the matching // AUDIT_FORKED row in main audit by exact-string timestamp match. const delta = wtContent.slice(fork.end); try { validateMergeDelta(delta); } catch (error) { jsonError(`refusing malformed or unauthorized worktree audit delta: ${errorMessage(error)}`); } // Acquire outer lock with extended budget for parallel-Bolt contention. // Defaults: 200 retries × 100ms = 20s, sized for N=4-8 contention. The // AIDLC_AUDIT_LOCK_RETRIES env var lets tests dial this down so the // lock-timeout failure path is testable without 20-second waits. const lockRetries = parseInt( process.env.AIDLC_AUDIT_LOCK_RETRIES ?? "200", 10, ); const lockRetryMs = parseInt( process.env.AIDLC_AUDIT_LOCK_RETRY_MS ?? "100", 10, ); if (!acquireAuditLock(projectDir, lockRetries, lockRetryMs, intent, space)) { jsonError( `Failed to acquire audit lock after ${lockRetries} × ${lockRetryMs}ms = ${(lockRetries * lockRetryMs / 1000).toFixed(1)}s retries; another merge in flight?` ); } // Atomic critical section: retry pre-check + delta + AUDIT_MERGED run under a // single lock acquisition. The catch path uses the unlocked append variant // because we still hold that lock. // // Failure-mode worth flagging for doctor: appendAuditBlockAtPath can throw // during its verification after the combined bytes landed. The catch path // emits ERROR_LOGGED with [slug=] [fork-emitted:] correlation // tags; the next retry's pre-check then observes the receipt or exact delta // and does not append it again. let entriesMerged = 0; let result: { timestamp: string }; let alreadyMerged = false; try { // Validate the main prefix only after acquiring the same lock that protects // the append. The prior pre-lock read allowed another writer to change the // ledger between validation and merge. const mainSnapshot = readAuditSnapshot(projectDir, mainAuditPath); const mainBuf = mainSnapshot.bytes; const wtCurrent = readAuditSnapshot(projectDir, wtAuditPath); if (wtCurrent.identity.dev !== wtSnapshot.identity.dev || wtCurrent.identity.ino !== wtSnapshot.identity.ino || !wtCurrent.bytes.equals(wtSnapshot.bytes)) { throw new Error("worktree audit changed while merge was preparing; retry the merge"); } // The worktree copy is writable and cannot authoritatively choose how much // of main to validate. Recover the matching fork row from main and require // every correlation field to agree before trusting the boundary. const mainContent = mainBuf.toString("utf-8"); const mainFork = latestAuditFork(mainContent, slug); if (!mainFork) throw new Error(`main audit is missing AUDIT_FORKED for slug ${slug}`); if (!forksCorrelate(fork, mainFork)) { throw new Error("worktree AUDIT_FORKED metadata does not match the authoritative main row"); } if (!Number.isSafeInteger(boundary) || boundary < 0 || boundary > mainBuf.length) { throw new Error(`invalid Fork Boundary ${boundary} for ${mainBuf.length}-byte main audit`); } const prefixLen = boundary; const prefixHash = createHash("sha256") .update(mainBuf.subarray(0, prefixLen)) .digest("hex"); if (prefixHash !== sourceHash) { if (mainBuf.length < boundary) { throw new Error( `main audit prefix-hash does not match recorded Source Audit Hash ` + `(expected at least ${boundary} bytes, got ${mainBuf.length}); ` + `refusing to merge (main-audit truncation suspected)`, ); } throw new Error( `main audit prefix-hash at byte ${boundary} does not match recorded Source Audit Hash; ` + `refusing to merge (mid-Bolt tampering suspected)`, ); } const trimmed = delta.trim(); if (trimmed !== "") entriesMerged = delta.split(/\n---\n/).filter((b) => b.trim()).length; const existingMerge = matchingAuditMerge(mainContent, slug, mainFork); const deltaAlreadyPresent = containsDeltaAtBlockBoundary( mainContent, delta, mainFork.end, ); if (existingMerge || deltaAlreadyPresent) { // The earlier merge carried the delta's review records with its rows; a // retry after that has nothing left to carry and must stay idempotent // even once the worktree is gone. alreadyMerged = true; result = { timestamp: existingMerge ? exactAuditField(existingMerge.block, "Timestamp") ?? forkTs : forkTs, }; } else { // The review records the delta's REVIEW_COMPLETED rows name travel with // the rows, under the same lock and before any row lands: main must never // hold a completion pointing at a record it does not have. Paired to // their requests, digest-verified from the worktree, exclusive on main. if (recordPrefix !== null) { mergeReviewRecordsFromDelta( delta, worktreeDocsDir(wtPath, recordPrefix), join(projectDir, ...recordPrefix.split("/")), ); } const mergedEntry = { eventType: "AUDIT_MERGED", fields: { "Bolt slug": slug, "Entries Merged": String(entriesMerged), "Source Audit Hash": sourceHash, "Fork Boundary": String(boundary), "Fork Timestamp": forkTs, }, }; validateAuditEntry(mergedEntry); const mergedTimestamp = isoTimestamp(); // Delta and receipt share one descriptor-pinned append, so no unsafe raw // append can bypass the normal shard protections or interleave between them. appendAuditBlockAtPath( projectDir, mainAuditPath, delta + renderAuditBlock(mergedEntry, mergedTimestamp, projectDir), { ...mainSnapshot.identity, prefixLength: boundary, prefixHash: sourceHash, }, ); tapAuditMetric("AUDIT_MERGED", mergedEntry.fields, projectDir); result = { timestamp: mergedTimestamp }; } } catch (e) { const message = e instanceof Error ? errorMessage(e) : String(e); // We still hold the outer lock in the catch path. Use the unlocked // variant so we don't release-and-reacquire (which would race against // any concurrent merger waiting for our lock). Release in finally below. try { appendAuditEntryUnlocked( "ERROR_LOGGED", { Tool: "aidlc-audit", Command: "audit-merge", Error: `[slug=${slug}] [fork-emitted:${forkTs}] ${message}`, }, projectDir, intent, space, ); } finally { releaseAuditLock(projectDir, intent, space); } jsonError(message); } releaseAuditLock(projectDir, intent, space); jsonSuccess({ emitted: "AUDIT_MERGED", slug, entries_merged: entriesMerged, source_audit_hash: sourceHash, fork_boundary: boundary, audit_timestamp: result.timestamp, ...(alreadyMerged ? { already_merged: true, message: "audit merge already applied; no changes made", } : {}), }); } // --- CLI entry point --- export function main(argv: string[]): void { const rawArgs = argv; // Extract --project-dir before general parsing let projectDirArg: string | undefined; const filteredArgs: string[] = []; for (let i = 0; i < rawArgs.length; i++) { if (rawArgs[i] === "--project-dir" && i + 1 < rawArgs.length) { projectDirArg = rawArgs[i + 1]; i++; // skip the value } else { filteredArgs.push(rawArgs[i]); } } const projectDir = resolveProjectDir(projectDirArg); const subcommand = filteredArgs[0]; if (!subcommand) { jsonError("Usage: aidlc-audit [args...]"); } switch (subcommand) { case "append": { const eventType = filteredArgs[1]; if (!eventType) { jsonError("Usage: aidlc-audit append [--field key=value ...]"); } refuseReservedCliEvent(eventType); const fields = parseFieldArgs(rawArgs); handleAppend(eventType, fields, projectDir); break; } case "append-batch": { const entries = filteredArgs[1]; if (!entries) { jsonError("Usage: aidlc-audit append-batch "); } refuseReservedCliBatch(entries); handleAppendBatch(entries, projectDir); break; } case "append-raw": { const heading = filteredArgs[1]; const body = filteredArgs[2]; if (!heading || !body) { jsonError( "Usage: aidlc-audit append-raw " ); } handleAppendRaw(heading, body, projectDir); break; } case "audit-fork": handleAuditFork(filteredArgs.slice(1), projectDir); break; case "audit-merge": handleAuditMerge(filteredArgs.slice(1), projectDir); break; default: jsonError(`Unknown subcommand: ${subcommand}. Expected: append, append-batch, append-raw, audit-fork, audit-merge`); } } if (import.meta.main) { main(process.argv.slice(2)); }