intentdna 1.2.3 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/hooks/cli.js CHANGED
@@ -16,9 +16,10 @@
16
16
  */
17
17
  import { readFile } from "node:fs/promises";
18
18
  import { resolve } from "node:path";
19
+ import { randomUUID } from "node:crypto";
19
20
  import { readStdin, writeOutput, silentOutput } from "./protocol.js";
20
- import { enforcePreToolUse, enforcePostToolUse, enforceUserPromptSubmit, enforceSubagentStop, enforcePreCompact, enforceNotification, } from "./enforce.js";
21
- import { appendAudit } from "./state.js";
21
+ import { enforcePreToolUse, enforcePostToolUse, enforceUserPromptSubmit, enforceSubagentStop, enforcePreCompact, enforceNotification, enforceSessionStart, enforceStop, } from "./enforce.js";
22
+ import { appendAudit, readWorkflowState, appendTrace, rotateTraces } from "./state.js";
22
23
  // ── Constants ──────────────────────────────────────────────
23
24
  const DEFAULT_IR_PATH = ".dna/compiled/ir.json";
24
25
  const VALID_EVENTS = new Set([
@@ -52,9 +53,70 @@ async function main() {
52
53
  return;
53
54
  }
54
55
  const state = {};
55
- // Dispatch to enforcement engine
56
+ // Load workflow state for events that need handoff context
57
+ if (event === "PreToolUse") {
58
+ const wfState = await readWorkflowState(projectDir, sessionId);
59
+ if (wfState && wfState.active) {
60
+ state.workflowState = {
61
+ current_step: wfState.current_step,
62
+ workflow: wfState.workflow,
63
+ completed_artifacts: wfState.completed_artifacts,
64
+ };
65
+ }
66
+ }
67
+ // Special handling for Stop — needs async workflow state read
68
+ if (event === "Stop") {
69
+ const wfState = await readWorkflowState(projectDir, sessionId);
70
+ const stopContext = wfState ? {
71
+ active: wfState.active,
72
+ workflow: wfState.workflow,
73
+ current_step: wfState.current_step,
74
+ current_role: wfState.current_role,
75
+ started_at: wfState.started_at,
76
+ completed_artifacts: wfState.completed_artifacts,
77
+ } : null;
78
+ const stopOutput = enforceStop(ir, {
79
+ cwd: typeof rawInput.cwd === "string" ? rawInput.cwd : undefined,
80
+ session_id: sessionId,
81
+ stop_reason: typeof rawInput.stop_reason === "string" ? rawInput.stop_reason : undefined,
82
+ }, stopContext);
83
+ writeOutput(stopOutput);
84
+ // Trace for Stop
85
+ appendTrace(projectDir, {
86
+ trace_id: randomUUID(),
87
+ event: "Stop",
88
+ workflow: wfState?.workflow,
89
+ step: wfState?.current_step,
90
+ decision: stopOutput.continue === false ? "block" : "allow",
91
+ duration_ms: Date.now() - Date.now(), // minimal
92
+ timestamp: new Date().toISOString(),
93
+ }).catch(() => { });
94
+ return;
95
+ }
96
+ // Dispatch to enforcement engine with timing
97
+ const start = Date.now();
56
98
  const output = dispatch(event, ir, rawInput, state);
99
+ const durationMs = Date.now() - start;
57
100
  writeOutput(output);
101
+ // Trace logging (async, fail-open)
102
+ const decision = output.continue === false ? "block"
103
+ : output.hookSpecificOutput?.additionalContext?.startsWith("WARN") ? "warn"
104
+ : "allow";
105
+ appendTrace(projectDir, {
106
+ trace_id: randomUUID(),
107
+ event,
108
+ tool_name: typeof rawInput.tool_name === "string" ? rawInput.tool_name : undefined,
109
+ agent_type: typeof rawInput.agent_type === "string" ? rawInput.agent_type : undefined,
110
+ workflow: state.workflowState?.workflow,
111
+ step: state.workflowState?.current_step,
112
+ decision: decision,
113
+ duration_ms: durationMs,
114
+ timestamp: new Date().toISOString(),
115
+ }).catch(() => { }); // Fail-open
116
+ // Side effect: rotate traces on SessionStart
117
+ if (event === "SessionStart") {
118
+ rotateTraces(projectDir).catch(() => { });
119
+ }
58
120
  // Side effect: audit log for notifications
59
121
  if (event === "Notification" && output.hookSpecificOutput?.additionalContext?.includes("violation")) {
60
122
  const message = typeof rawInput.message === "string" ? rawInput.message : "";
@@ -103,7 +165,11 @@ function dispatch(event, ir, input, state) {
103
165
  case "Stop":
104
166
  return silentOutput(); // DNA doesn't block stops
105
167
  case "SessionStart":
106
- return silentOutput(); // Placeholder — will initialize DNA state in future
168
+ return enforceSessionStart(ir, {
169
+ cwd: typeof input.cwd === "string" ? input.cwd : undefined,
170
+ session_id: typeof input.sessionId === "string" ? input.sessionId : undefined,
171
+ trigger: typeof input.trigger === "string" ? input.trigger : undefined,
172
+ });
107
173
  default:
108
174
  return silentOutput();
109
175
  }
@@ -13,9 +13,21 @@
13
13
  *
14
14
  * Step checkpoints are handled by the CLI, not here.
15
15
  */
16
- import type { ConstraintIR, RoleDef } from "../schema/types.js";
16
+ import type { ConstraintIR, RoleDef, CompletedArtifactEntry } from "../schema/types.js";
17
17
  import type { PreToolUseInput, PostToolUseInput, UserPromptSubmitInput, SubagentStopInput, NotificationInput, HookOutput } from "./protocol.js";
18
+ /** SessionStart input fields */
19
+ export interface SessionStartInput {
20
+ cwd?: string;
21
+ session_id?: string;
22
+ trigger?: string;
23
+ }
18
24
  export interface EnforceState {
25
+ /** Workflow handoff context — passed from CLI after reading workflow state */
26
+ workflowState?: {
27
+ current_step: string;
28
+ workflow: string;
29
+ completed_artifacts?: CompletedArtifactEntry[];
30
+ };
19
31
  }
20
32
  /**
21
33
  * Enforce PreToolUse constraints.
@@ -47,6 +59,49 @@ export declare function enforcePreCompact(ir: ConstraintIR): HookOutput;
47
59
  * The CLI handles actual audit file writes.
48
60
  */
49
61
  export declare function enforceNotification(ir: ConstraintIR, input: NotificationInput): HookOutput;
62
+ /**
63
+ * Initialize DNA state and inject policy summary on session start.
64
+ * Returns silent if no DNA policies are active.
65
+ */
66
+ export declare function enforceSessionStart(ir: ConstraintIR, input: SessionStartInput): HookOutput;
67
+ /** Stop enforcement input — includes workflow awareness */
68
+ export interface StopEnforceInput {
69
+ cwd?: string;
70
+ session_id?: string;
71
+ stop_reason?: string;
72
+ }
73
+ /** Workflow state context for stop enforcement */
74
+ export interface StopWorkflowContext {
75
+ active: boolean;
76
+ workflow: string;
77
+ current_step: string;
78
+ current_role: string;
79
+ started_at: string;
80
+ completed_artifacts?: CompletedArtifactEntry[];
81
+ }
82
+ /**
83
+ * Enforce Stop hook — verify workflow checkpoint completion.
84
+ *
85
+ * Safety valves (NEVER block):
86
+ * - context_limit / context_window stops
87
+ * - Stale workflow state (>2h)
88
+ * - No active workflow
89
+ * - No checkpoints defined
90
+ *
91
+ * Block when:
92
+ * - Workflow active + unmet checkpoints for current step
93
+ */
94
+ export declare function enforceStop(ir: ConstraintIR, input: StopEnforceInput, workflowState: StopWorkflowContext | null): HookOutput;
95
+ /**
96
+ * Enforce handoff produces — verify that the current step has produced
97
+ * its declared artifacts. Called at stop/checkpoint time.
98
+ * Returns block output if a required artifact is missing, null if all produced.
99
+ */
100
+ export declare function enforceHandoffProduces(ir: ConstraintIR, wfState: {
101
+ current_step: string;
102
+ workflow: string;
103
+ completed_artifacts?: CompletedArtifactEntry[];
104
+ }): HookOutput | null;
50
105
  /** Check if a file path is allowed by a list of write globs (prefix matching). */
51
106
  export declare function checkWriteAllowed(filePath: string, allowedGlobs: string[]): boolean;
52
107
  /**
@@ -46,6 +46,12 @@ export function enforcePreToolUse(ir, input, state, roles) {
46
46
  if (result)
47
47
  return result;
48
48
  }
49
+ // Layer 6: Handoff — check consumed artifacts are available
50
+ if (state?.workflowState && ir.workflows_ir) {
51
+ const result = enforceHandoffConsumes(ir, state.workflowState);
52
+ if (result)
53
+ return result;
54
+ }
49
55
  return silentOutput();
50
56
  }
51
57
  // ── PostToolUse Enforcement ────────────────────────────────
@@ -145,7 +151,161 @@ export function enforceNotification(ir, input) {
145
151
  }
146
152
  return silentOutput();
147
153
  }
154
+ // ── SessionStart Enforcement ───────────────────────────────
155
+ /**
156
+ * Initialize DNA state and inject policy summary on session start.
157
+ * Returns silent if no DNA policies are active.
158
+ */
159
+ export function enforceSessionStart(ir, input) {
160
+ // Check if there's anything meaningful to report
161
+ const directiveCount = ir.prompt_directives.length;
162
+ const gateCount = ir.pre_execution_gates.length;
163
+ const filterCount = ir.tool_filters.length;
164
+ const roleCount = ir.roles_scope_map?.length ?? 0;
165
+ const workflowCount = ir.workflows_ir?.length ?? 0;
166
+ if (directiveCount === 0 && gateCount === 0 && filterCount === 0 && roleCount === 0) {
167
+ return silentOutput();
168
+ }
169
+ // Build policy summary
170
+ const lines = [
171
+ "[Intent DNA] Session initialized — active governance:",
172
+ ];
173
+ if (ir.source_dna_ids.length > 0) {
174
+ lines.push(` Templates: ${ir.source_dna_ids.join(", ")}`);
175
+ }
176
+ const stats = [];
177
+ if (directiveCount > 0)
178
+ stats.push(`${directiveCount} directives`);
179
+ if (gateCount > 0)
180
+ stats.push(`${gateCount} gates`);
181
+ if (filterCount > 0)
182
+ stats.push(`${filterCount} filters`);
183
+ if (roleCount > 0)
184
+ stats.push(`${roleCount} roles`);
185
+ if (workflowCount > 0)
186
+ stats.push(`${workflowCount} workflows`);
187
+ if (stats.length > 0) {
188
+ lines.push(` Enforcement: ${stats.join(", ")}`);
189
+ }
190
+ return allowOutput(lines.join("\n"));
191
+ }
192
+ /**
193
+ * Enforce Stop hook — verify workflow checkpoint completion.
194
+ *
195
+ * Safety valves (NEVER block):
196
+ * - context_limit / context_window stops
197
+ * - Stale workflow state (>2h)
198
+ * - No active workflow
199
+ * - No checkpoints defined
200
+ *
201
+ * Block when:
202
+ * - Workflow active + unmet checkpoints for current step
203
+ */
204
+ export function enforceStop(ir, input, workflowState) {
205
+ // Safety valve 1: no workflow state → silent
206
+ if (!workflowState || !workflowState.active) {
207
+ return silentOutput();
208
+ }
209
+ // Safety valve 2: context_limit stops → NEVER block
210
+ const reason = input.stop_reason ?? "";
211
+ if (reason.includes("context_limit") || reason.includes("context_window")) {
212
+ return silentOutput();
213
+ }
214
+ // Safety valve 3: user abort → don't block
215
+ if (reason === "user_abort" || reason === "sigint") {
216
+ return silentOutput();
217
+ }
218
+ // Safety valve 4: stale state (>2h)
219
+ const STALE_MS = 2 * 60 * 60 * 1000;
220
+ if (workflowState.started_at) {
221
+ const age = Date.now() - new Date(workflowState.started_at).getTime();
222
+ if (age > STALE_MS) {
223
+ return silentOutput();
224
+ }
225
+ }
226
+ // Check for unmet checkpoints in current step
227
+ const checkpoints = ir.step_checkpoints ?? [];
228
+ const currentStepCheckpoints = checkpoints.filter(cp => cp.step_id === workflowState.current_step);
229
+ // Also check workflows_ir for the active workflow's checkpoints
230
+ let workflowCheckpoints = currentStepCheckpoints;
231
+ if (ir.workflows_ir && ir.workflows_ir.length > 0) {
232
+ const activeWf = ir.workflows_ir.find(w => w.workflow_name === workflowState.workflow);
233
+ if (activeWf) {
234
+ const wfStepCps = activeWf.step_checkpoints.filter(cp => cp.step_id === workflowState.current_step);
235
+ if (wfStepCps.length > 0) {
236
+ workflowCheckpoints = wfStepCps;
237
+ }
238
+ }
239
+ }
240
+ if (workflowCheckpoints.length === 0) {
241
+ return silentOutput(); // No checkpoints for current step
242
+ }
243
+ // Build list of blocking checkpoints
244
+ const blocking = workflowCheckpoints.flatMap(cp => cp.checkpoints.filter(c => (c.action ?? "block") === "block"));
245
+ if (blocking.length === 0) {
246
+ // No checkpoint blocking — check handoff produces
247
+ if (workflowState.workflow) {
248
+ const handoffResult = enforceHandoffProduces(ir, {
249
+ current_step: workflowState.current_step,
250
+ workflow: workflowState.workflow,
251
+ completed_artifacts: workflowState.completed_artifacts,
252
+ });
253
+ if (handoffResult && !handoffResult.continue)
254
+ return handoffResult;
255
+ }
256
+ return silentOutput();
257
+ }
258
+ // Block: workflow active with unmet checkpoints
259
+ const messages = blocking.map(c => c.message);
260
+ return blockOutput(`[Intent DNA] Workflow '${workflowState.workflow}' has unmet checkpoints at step '${workflowState.current_step}':\n` +
261
+ messages.map(m => ` - ${m}`).join("\n"));
262
+ }
148
263
  // ── Internal: Layer Enforcement ────────────────────────────
264
+ /**
265
+ * Enforce handoff consumes — verify that the current step's consumed
266
+ * artifacts have been produced by a preceding step.
267
+ * Returns block output if a required artifact is missing, null if all satisfied.
268
+ */
269
+ function enforceHandoffConsumes(ir, wfState) {
270
+ const activeWf = ir.workflows_ir?.find(w => w.workflow_name === wfState.workflow);
271
+ if (!activeWf || activeWf.handoff_chain.length === 0)
272
+ return null;
273
+ const currentEntry = activeWf.handoff_chain.find(h => h.step_id === wfState.current_step);
274
+ if (!currentEntry?.consumes || currentEntry.consumes.length === 0)
275
+ return null;
276
+ const completedStepIds = new Set((wfState.completed_artifacts ?? []).map(a => a.step_id));
277
+ // For each consumed artifact, find the producing step and check it completed
278
+ for (const consumed of currentEntry.consumes) {
279
+ // Find which step produces this artifact
280
+ const producer = activeWf.handoff_chain.find(h => h.produces?.some(p => p.type === consumed.type && ((p.path && consumed.path && p.path === consumed.path) ||
281
+ (consumed.from && h.step_id === consumed.from))));
282
+ if (producer && !completedStepIds.has(producer.step_id)) {
283
+ return blockOutput(`[Intent DNA] Step '${wfState.current_step}' requires artifact from step '${producer.step_id}' (${consumed.description}). Run step '${producer.step_id}' first.`);
284
+ }
285
+ }
286
+ return null;
287
+ }
288
+ /**
289
+ * Enforce handoff produces — verify that the current step has produced
290
+ * its declared artifacts. Called at stop/checkpoint time.
291
+ * Returns block output if a required artifact is missing, null if all produced.
292
+ */
293
+ export function enforceHandoffProduces(ir, wfState) {
294
+ const activeWf = ir.workflows_ir?.find(w => w.workflow_name === wfState.workflow);
295
+ if (!activeWf || activeWf.handoff_chain.length === 0)
296
+ return null;
297
+ const currentEntry = activeWf.handoff_chain.find(h => h.step_id === wfState.current_step);
298
+ if (!currentEntry?.produces || currentEntry.produces.length === 0)
299
+ return null;
300
+ const currentArtifacts = (wfState.completed_artifacts ?? []).find(a => a.step_id === wfState.current_step);
301
+ // Check that each produced artifact with a path has been recorded
302
+ for (const produced of currentEntry.produces) {
303
+ if (produced.path && (!currentArtifacts || !currentArtifacts.artifacts.some(a => a.path === produced.path))) {
304
+ return blockOutput(`[Intent DNA] Step '${wfState.current_step}' must produce artifact '${produced.description}' (path: ${produced.path}) before proceeding.`);
305
+ }
306
+ }
307
+ return null;
308
+ }
149
309
  function enforceRoleScope(rolesScopeMap, input) {
150
310
  if (!input.agent_type)
151
311
  return null;
@@ -4,6 +4,6 @@
4
4
  * Public exports for the hook enforcement engine.
5
5
  * Used by the `dna-hook` CLI and importable for programmatic use.
6
6
  */
7
- export { type HookEvent, type HookInput, type HookOutput, type HookInputBase, type PreToolUseInput, type PostToolUseInput, type UserPromptSubmitInput, type SubagentStopInput, type PreCompactInput, type NotificationInput, type StopInput, readStdin, writeOutput, allowOutput, blockOutput, escalateOutput, silentOutput, } from "./protocol.js";
8
- export { type EnforceState, enforcePreToolUse, enforcePostToolUse, enforceUserPromptSubmit, enforceSubagentStop, enforcePreCompact, enforceNotification, checkWriteAllowed, globToPrefix, evaluateGateCondition, resolveToolTarget, } from "./enforce.js";
7
+ export { type HookEvent, type HookInput, type HookOutput, type HookInputBase, type PreToolUseInput, type PostToolUseInput, type UserPromptSubmitInput, type SubagentStopInput, type PreCompactInput, type NotificationInput, type StopInput, type SessionStartInput as SessionStartHookInput, readStdin, writeOutput, allowOutput, blockOutput, escalateOutput, silentOutput, } from "./protocol.js";
8
+ export { type EnforceState, type SessionStartInput, type StopEnforceInput, type StopWorkflowContext, enforcePreToolUse, enforcePostToolUse, enforceUserPromptSubmit, enforceSubagentStop, enforcePreCompact, enforceNotification, enforceSessionStart, enforceStop, checkWriteAllowed, globToPrefix, evaluateGateCondition, resolveToolTarget, } from "./enforce.js";
9
9
  export { type DNAWorkflowState, type AuditEntry, resolveStateDir, readWorkflowState, writeWorkflowState, clearWorkflowState, appendAudit, } from "./state.js";
@@ -7,6 +7,6 @@
7
7
  // Protocol types and I/O
8
8
  export { readStdin, writeOutput, allowOutput, blockOutput, escalateOutput, silentOutput, } from "./protocol.js";
9
9
  // Enforcement engine
10
- export { enforcePreToolUse, enforcePostToolUse, enforceUserPromptSubmit, enforceSubagentStop, enforcePreCompact, enforceNotification, checkWriteAllowed, globToPrefix, evaluateGateCondition, resolveToolTarget, } from "./enforce.js";
10
+ export { enforcePreToolUse, enforcePostToolUse, enforceUserPromptSubmit, enforceSubagentStop, enforcePreCompact, enforceNotification, enforceSessionStart, enforceStop, checkWriteAllowed, globToPrefix, evaluateGateCondition, resolveToolTarget, } from "./enforce.js";
11
11
  // State management
12
12
  export { resolveStateDir, readWorkflowState, writeWorkflowState, clearWorkflowState, appendAudit, } from "./state.js";
@@ -39,7 +39,10 @@ export interface NotificationInput extends HookInputBase {
39
39
  export interface StopInput extends HookInputBase {
40
40
  stop_reason?: string;
41
41
  }
42
- export type HookInput = PreToolUseInput | PostToolUseInput | UserPromptSubmitInput | SubagentStopInput | PreCompactInput | NotificationInput | StopInput;
42
+ export interface SessionStartInput extends HookInputBase {
43
+ trigger?: string;
44
+ }
45
+ export type HookInput = PreToolUseInput | PostToolUseInput | UserPromptSubmitInput | SubagentStopInput | PreCompactInput | NotificationInput | StopInput | SessionStartInput;
43
46
  export interface HookOutput {
44
47
  continue: boolean;
45
48
  decision?: "block" | "allow";
@@ -9,6 +9,7 @@
9
9
  * All read operations are fail-safe (return null on error, never throw).
10
10
  * All write operations use atomic temp+rename pattern.
11
11
  */
12
+ import type { CompletedArtifactEntry } from "../schema/types.js";
12
13
  /** Workflow state written by runtime and read by hooks */
13
14
  export interface DNAWorkflowState {
14
15
  active: boolean;
@@ -18,6 +19,7 @@ export interface DNAWorkflowState {
18
19
  iteration: number;
19
20
  session_id: string;
20
21
  started_at: string;
22
+ completed_artifacts?: CompletedArtifactEntry[];
21
23
  }
22
24
  /** Audit log entry (one per line in JSON Lines format) */
23
25
  export interface AuditEntry {
@@ -47,7 +49,44 @@ export declare function writeWorkflowState(projectDir: string, state: DNAWorkflo
47
49
  */
48
50
  export declare function clearWorkflowState(projectDir: string, sessionId?: string): Promise<void>;
49
51
  /**
50
- * Append an entry to the audit log.
52
+ * Append an entry to the audit log with dedup protection.
53
+ * Dedup key: event + tool_name + timestamp (second-level).
51
54
  * Log file: `.dna/audit/violations-YYYY-MM-DD.log` (JSON Lines format)
52
55
  */
53
56
  export declare function appendAudit(projectDir: string, entry: AuditEntry): Promise<void>;
57
+ /**
58
+ * Record a completed artifact for a workflow step.
59
+ * Reads current workflow state, appends the artifact, writes back atomically.
60
+ */
61
+ export declare function appendCompletedArtifact(projectDir: string, stepId: string, artifact: {
62
+ type: string;
63
+ path: string;
64
+ }, sessionId?: string): Promise<void>;
65
+ /** Trace entry for hook call observability */
66
+ export interface TraceEntry {
67
+ trace_id: string;
68
+ event: string;
69
+ tool_name?: string;
70
+ agent_type?: string;
71
+ workflow?: string;
72
+ step?: string;
73
+ decision: "allow" | "block" | "warn";
74
+ duration_ms: number;
75
+ timestamp: string;
76
+ }
77
+ /**
78
+ * Append a trace entry to the daily trace file.
79
+ * File: `.dna/state/trace/trace-YYYY-MM-DD.jsonl`
80
+ * Fail-open: never throws.
81
+ */
82
+ export declare function appendTrace(projectDir: string, entry: TraceEntry): Promise<void>;
83
+ /**
84
+ * Read trace entries from the last N days.
85
+ * Returns parsed entries sorted by timestamp.
86
+ */
87
+ export declare function readTraces(projectDir: string, days?: number): Promise<TraceEntry[]>;
88
+ /**
89
+ * Clean up trace files older than retention period.
90
+ * Removes `.dna/state/trace/trace-*.jsonl` files older than TRACE_RETENTION_DAYS.
91
+ */
92
+ export declare function rotateTraces(projectDir: string): Promise<number>;
@@ -9,7 +9,7 @@
9
9
  * All read operations are fail-safe (return null on error, never throw).
10
10
  * All write operations use atomic temp+rename pattern.
11
11
  */
12
- import { readFile, writeFile, rename, mkdir, appendFile, unlink } from "node:fs/promises";
12
+ import { readFile, writeFile, rename, mkdir, appendFile, unlink, readdir, stat } from "node:fs/promises";
13
13
  import { join, dirname } from "node:path";
14
14
  // ── Constants ──────────────────────────────────────────────
15
15
  const WORKFLOW_FILE = "workflow.json";
@@ -73,7 +73,8 @@ export async function clearWorkflowState(projectDir, sessionId) {
73
73
  }
74
74
  // ── Audit Log ──────────────────────────────────────────────
75
75
  /**
76
- * Append an entry to the audit log.
76
+ * Append an entry to the audit log with dedup protection.
77
+ * Dedup key: event + tool_name + timestamp (second-level).
77
78
  * Log file: `.dna/audit/violations-YYYY-MM-DD.log` (JSON Lines format)
78
79
  */
79
80
  export async function appendAudit(projectDir, entry) {
@@ -81,8 +82,52 @@ export async function appendAudit(projectDir, entry) {
81
82
  await mkdir(auditDir, { recursive: true });
82
83
  const date = entry.timestamp.slice(0, 10); // YYYY-MM-DD
83
84
  const logPath = join(auditDir, `violations-${date}.log`);
85
+ // Dedup: check if identical entry (same event+tool+second) already exists
86
+ const dedupKey = `${entry.event}|${entry.tool_name ?? ""}|${entry.timestamp.slice(0, 19)}`;
87
+ try {
88
+ const existing = await readFile(logPath, "utf-8");
89
+ const lines = existing.trimEnd().split("\n");
90
+ // Check last 10 lines for dedup (avoid scanning entire file)
91
+ const recentLines = lines.slice(-10);
92
+ for (const line of recentLines) {
93
+ try {
94
+ const prev = JSON.parse(line);
95
+ const prevKey = `${prev.event}|${prev.tool_name ?? ""}|${prev.timestamp.slice(0, 19)}`;
96
+ if (prevKey === dedupKey)
97
+ return; // Already logged
98
+ }
99
+ catch { /* skip malformed lines */ }
100
+ }
101
+ }
102
+ catch { /* file doesn't exist yet */ }
84
103
  await appendFile(logPath, JSON.stringify(entry) + "\n", "utf-8");
85
104
  }
105
+ // ── Completed Artifacts ───────────────────────────────────
106
+ /**
107
+ * Record a completed artifact for a workflow step.
108
+ * Reads current workflow state, appends the artifact, writes back atomically.
109
+ */
110
+ export async function appendCompletedArtifact(projectDir, stepId, artifact, sessionId) {
111
+ const state = await readWorkflowState(projectDir, sessionId, 0);
112
+ if (!state)
113
+ return;
114
+ const artifacts = state.completed_artifacts ?? [];
115
+ let stepEntry = artifacts.find(a => a.step_id === stepId);
116
+ if (!stepEntry) {
117
+ stepEntry = { step_id: stepId, artifacts: [] };
118
+ artifacts.push(stepEntry);
119
+ }
120
+ // Dedup: don't add if same path already recorded
121
+ if (stepEntry.artifacts.some(a => a.path === artifact.path))
122
+ return;
123
+ stepEntry.artifacts.push({
124
+ type: artifact.type,
125
+ path: artifact.path,
126
+ verified_at: new Date().toISOString(),
127
+ });
128
+ state.completed_artifacts = artifacts;
129
+ await writeWorkflowState(projectDir, state, sessionId);
130
+ }
86
131
  // ── Internal ───────────────────────────────────────────────
87
132
  /** Atomic write: write to temp file then rename. */
88
133
  async function atomicWrite(filePath, data) {
@@ -91,3 +136,93 @@ async function atomicWrite(filePath, data) {
91
136
  await writeFile(tmpPath, data, "utf-8");
92
137
  await rename(tmpPath, filePath);
93
138
  }
139
+ const TRACE_DIR = "trace";
140
+ const MAX_TRACE_SIZE = 10 * 1024 * 1024; // 10MB
141
+ const TRACE_RETENTION_DAYS = 7;
142
+ /**
143
+ * Append a trace entry to the daily trace file.
144
+ * File: `.dna/state/trace/trace-YYYY-MM-DD.jsonl`
145
+ * Fail-open: never throws.
146
+ */
147
+ export async function appendTrace(projectDir, entry) {
148
+ try {
149
+ const traceDir = join(projectDir, ".dna", "state", TRACE_DIR);
150
+ await mkdir(traceDir, { recursive: true });
151
+ const date = entry.timestamp.slice(0, 10);
152
+ const tracePath = join(traceDir, `trace-${date}.jsonl`);
153
+ // Check file size — rotate if over limit
154
+ try {
155
+ const stats = await stat(tracePath);
156
+ if (stats.size >= MAX_TRACE_SIZE)
157
+ return; // silently skip if file too large
158
+ }
159
+ catch { /* file doesn't exist yet */ }
160
+ await appendFile(tracePath, JSON.stringify(entry) + "\n", "utf-8");
161
+ }
162
+ catch {
163
+ // Fail-open: trace write failure never affects hook execution
164
+ }
165
+ }
166
+ /**
167
+ * Read trace entries from the last N days.
168
+ * Returns parsed entries sorted by timestamp.
169
+ */
170
+ export async function readTraces(projectDir, days = 1) {
171
+ const traceDir = join(projectDir, ".dna", "state", TRACE_DIR);
172
+ const entries = [];
173
+ const cutoff = new Date();
174
+ cutoff.setDate(cutoff.getDate() - days);
175
+ const cutoffDate = cutoff.toISOString().slice(0, 10);
176
+ try {
177
+ const files = await readdir(traceDir);
178
+ const traceFiles = files
179
+ .filter(f => f.startsWith("trace-") && f.endsWith(".jsonl"))
180
+ .filter(f => {
181
+ const fileDate = f.slice(6, 16); // "trace-YYYY-MM-DD.jsonl" → "YYYY-MM-DD"
182
+ return fileDate >= cutoffDate;
183
+ })
184
+ .sort();
185
+ for (const file of traceFiles) {
186
+ const content = await readFile(join(traceDir, file), "utf-8");
187
+ for (const line of content.trim().split("\n")) {
188
+ if (!line)
189
+ continue;
190
+ try {
191
+ entries.push(JSON.parse(line));
192
+ }
193
+ catch { /* skip malformed */ }
194
+ }
195
+ }
196
+ }
197
+ catch {
198
+ // No trace dir yet
199
+ }
200
+ return entries;
201
+ }
202
+ /**
203
+ * Clean up trace files older than retention period.
204
+ * Removes `.dna/state/trace/trace-*.jsonl` files older than TRACE_RETENTION_DAYS.
205
+ */
206
+ export async function rotateTraces(projectDir) {
207
+ const traceDir = join(projectDir, ".dna", "state", TRACE_DIR);
208
+ let removed = 0;
209
+ const cutoff = new Date();
210
+ cutoff.setDate(cutoff.getDate() - TRACE_RETENTION_DAYS);
211
+ const cutoffDate = cutoff.toISOString().slice(0, 10);
212
+ try {
213
+ const files = await readdir(traceDir);
214
+ for (const file of files) {
215
+ if (!file.startsWith("trace-") || !file.endsWith(".jsonl"))
216
+ continue;
217
+ const fileDate = file.slice(6, 16);
218
+ if (fileDate < cutoffDate) {
219
+ await unlink(join(traceDir, file)).catch(() => { });
220
+ removed++;
221
+ }
222
+ }
223
+ }
224
+ catch {
225
+ // No trace dir
226
+ }
227
+ return removed;
228
+ }
@@ -124,6 +124,27 @@ export function compileWorkflowToSkill(plan, roles, ir, variables) {
124
124
  lines.push("</Checkpoints>");
125
125
  lines.push("");
126
126
  }
127
+ // Handoff — artifact flow between steps
128
+ const stepsWithHandoff = plan.steps.filter((s) => s.handoff && (s.handoff.consumes?.length || s.handoff.produces?.length));
129
+ if (stepsWithHandoff.length > 0) {
130
+ lines.push("<Handoff>");
131
+ for (const step of stepsWithHandoff) {
132
+ const h = step.handoff;
133
+ if (h.produces && h.produces.length > 0) {
134
+ for (const p of h.produces) {
135
+ lines.push(` Step ${step.id} produces: ${p.description} (${p.type}${p.path ? `, ${p.path}` : ""})`);
136
+ }
137
+ }
138
+ if (h.consumes && h.consumes.length > 0) {
139
+ for (const c of h.consumes) {
140
+ const fromNote = c.from ? ` from ${c.from}` : "";
141
+ lines.push(` Step ${step.id} consumes: ${c.description}${fromNote} (${c.type}${c.path ? `, ${c.path}` : ""})`);
142
+ }
143
+ }
144
+ }
145
+ lines.push("</Handoff>");
146
+ lines.push("");
147
+ }
127
148
  // Variables — collect {{var_name}} references from the whole plan
128
149
  const varRefs = collectVariableRefs(plan);
129
150
  if (varRefs.size > 0) {