intentdna 1.5.14 → 1.5.16

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.
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * Step checkpoints are handled by the CLI, not here.
15
15
  */
16
- import { allowOutput, blockOutput, escalateOutput, silentOutput } from "./protocol.js";
16
+ import { allowOutput, blockOutput, escalateOutput } from "./protocol.js";
17
17
  import { relative, normalize } from "node:path";
18
18
  // ── PreToolUse Enforcement ─────────────────────────────────
19
19
  /**
@@ -27,43 +27,42 @@ export function enforcePreToolUse(ir, input, state, roles) {
27
27
  if (state?.workflowState && ir.workflows_ir) {
28
28
  const stepRuleResult = enforceStepRules(ir, input, state);
29
29
  if (stepRuleResult !== null)
30
- return stepRuleResult;
30
+ return { output: stepRuleResult, trace: { matched_rule: "step_rule", step_id: state.workflowState.current_step } };
31
31
  }
32
32
  // Layer 2: Role scope
33
33
  if (input.agent_type && ir.roles_scope_map && ir.roles_scope_map.length > 0) {
34
- // G4: Check if scope is relaxed by iteration-based rule
35
34
  const relaxed = isStepScopeRelaxed(ir, state);
36
35
  if (!relaxed) {
37
36
  const result = enforceRoleScope(ir.roles_scope_map, input, getStepAdditionalPaths(ir, state));
38
37
  if (result)
39
- return result;
38
+ return { output: result, trace: { matched_rule: "scope", role: input.agent_type } };
40
39
  }
41
40
  }
42
41
  // Layer 3: Tool filters
43
42
  if (ir.tool_filters.length > 0) {
44
43
  const result = enforceToolFilters(ir.tool_filters, input);
45
44
  if (result)
46
- return result;
45
+ return { output: result, trace: { matched_rule: "filter" } };
47
46
  }
48
47
  // Layer 4: Pre-execution gates
49
48
  if (ir.pre_execution_gates.length > 0) {
50
49
  const result = enforceGates(ir.pre_execution_gates, input);
51
50
  if (result)
52
- return result;
51
+ return { output: result, trace: { matched_rule: "gate" } };
53
52
  }
54
53
  // Layer 5: Output schema
55
54
  if (roles && input.tool_name === "Write") {
56
55
  const result = enforceOutputSchema(roles, input);
57
56
  if (result)
58
- return result;
57
+ return { output: result, trace: { matched_rule: "output_schema" } };
59
58
  }
60
59
  // Layer 6: Handoff — check consumed artifacts are available
61
60
  if (state?.workflowState && ir.workflows_ir) {
62
61
  const result = enforceHandoffConsumes(ir, state.workflowState, state.existingArtifactPaths);
63
62
  if (result)
64
- return result;
63
+ return { output: result, trace: { matched_rule: "handoff", step_id: state.workflowState.current_step } };
65
64
  }
66
- return silentOutput();
65
+ return null;
67
66
  }
68
67
  // ── PostToolUse Enforcement ────────────────────────────────
69
68
  /**
@@ -92,8 +91,13 @@ export function enforcePostToolUse(ir, input) {
92
91
  }
93
92
  }
94
93
  if (lines.length === 0)
95
- return silentOutput();
96
- return allowOutput(lines.join("\n"));
94
+ return null;
95
+ // Audit-only path: validator entries emit audit lines, plus bash scope WARN lines.
96
+ // If any bash scope violation was found use "scope" (real path match); otherwise
97
+ // this is validator-only output — use "validator" for accurate trace semantics.
98
+ const hasScopeViolation = lines.some(l => l.startsWith("WARN"));
99
+ const matchedRule = hasScopeViolation ? "scope" : "validator";
100
+ return { output: allowOutput(lines.join("\n")), trace: { matched_rule: matchedRule } };
97
101
  }
98
102
  // ── UserPromptSubmit Enforcement ───────────────────────────
99
103
  /**
@@ -103,7 +107,7 @@ export function enforcePostToolUse(ir, input) {
103
107
  export function enforceUserPromptSubmit(ir, _input) {
104
108
  const highPriority = ir.prompt_directives.filter(d => d.priority === "high");
105
109
  if (highPriority.length === 0)
106
- return silentOutput();
110
+ return null;
107
111
  const lines = [
108
112
  "<system-reminder>[Intent DNA] Active high-priority policies:",
109
113
  ];
@@ -111,7 +115,7 @@ export function enforceUserPromptSubmit(ir, _input) {
111
115
  lines.push(` - [${d.source_gene}] ${d.text}`);
112
116
  }
113
117
  lines.push("</system-reminder>");
114
- return allowOutput(lines.join("\n"));
118
+ return { output: allowOutput(lines.join("\n")) };
115
119
  }
116
120
  // ── SubagentStop Enforcement ───────────────────────────────
117
121
  /**
@@ -120,9 +124,9 @@ export function enforceUserPromptSubmit(ir, _input) {
120
124
  export function enforceSubagentStop(ir, input) {
121
125
  const rolesScopeMap = ir.roles_scope_map;
122
126
  if (!rolesScopeMap || rolesScopeMap.length === 0)
123
- return silentOutput();
127
+ return null;
124
128
  if (!input.agent_type)
125
- return silentOutput();
129
+ return null;
126
130
  for (const entry of rolesScopeMap) {
127
131
  const agentTypeName = `dna-${toKebabCase(entry.role_name)}`;
128
132
  if (input.agent_type === agentTypeName) {
@@ -130,10 +134,15 @@ export function enforceSubagentStop(ir, input) {
130
134
  const scopeDesc = writeGlobs.length > 0
131
135
  ? `write:[${writeGlobs.join(",")}]`
132
136
  : "write:[]";
133
- return allowOutput(`[Intent DNA] SubagentStop audit: role=${entry.role_name} agent=${input.agent_name ?? "unknown"} scope=${scopeDesc}`);
137
+ return {
138
+ output: allowOutput(`[Intent DNA] SubagentStop audit: role=${entry.role_name} agent=${input.agent_name ?? "unknown"} scope=${scopeDesc}`),
139
+ trace: { matched_rule: "scope", role: entry.role_name },
140
+ };
134
141
  }
135
142
  }
136
- return allowOutput(`[Intent DNA] SubagentStop: unknown role for agent_type=${input.agent_type}`);
143
+ return {
144
+ output: allowOutput(`[Intent DNA] SubagentStop: unknown role for agent_type=${input.agent_type}`),
145
+ };
137
146
  }
138
147
  // ── PreCompact Enforcement ─────────────────────────────────
139
148
  /**
@@ -145,7 +154,7 @@ export function enforcePreCompact(ir, workflowContext) {
145
154
  const hasRoles = ir.roles_scope_map && ir.roles_scope_map.length > 0;
146
155
  const hasWorkflow = !!workflowContext;
147
156
  if (!hasDirectives && !hasRoles && !hasWorkflow)
148
- return silentOutput();
157
+ return null;
149
158
  const critical = ir.prompt_directives.filter(d => d.priority === "high");
150
159
  const standard = ir.prompt_directives.filter(d => d.priority === "medium");
151
160
  const lines = [
@@ -176,7 +185,7 @@ export function enforcePreCompact(ir, workflowContext) {
176
185
  if (hasWorkflow) {
177
186
  lines.push(` Active workflow: ${workflowContext.workflow} (step: ${workflowContext.current_step}, role: ${workflowContext.current_role})`);
178
187
  }
179
- return allowOutput(lines.join("\n"));
188
+ return { output: allowOutput(lines.join("\n")) };
180
189
  }
181
190
  // ── Notification Enforcement ───────────────────────────────
182
191
  /**
@@ -187,12 +196,14 @@ export function enforceNotification(ir, input) {
187
196
  const blockGates = ir.pre_execution_gates.filter(g => g.action === "block" || g.action === "escalate");
188
197
  const blockFilters = ir.tool_filters.filter(f => f.action === "remove");
189
198
  if (blockGates.length === 0 && blockFilters.length === 0)
190
- return silentOutput();
199
+ return null;
191
200
  const message = input.message ?? "";
192
201
  if (message.includes("Intent DNA")) {
193
- return allowOutput(`[Intent DNA] Notification: violation detected — ${input.title ?? "untitled"}: ${message}`);
202
+ return {
203
+ output: allowOutput(`[Intent DNA] Notification: violation detected — ${input.title ?? "untitled"}: ${message}`),
204
+ };
194
205
  }
195
- return silentOutput();
206
+ return null;
196
207
  }
197
208
  // ── SessionStart Enforcement ───────────────────────────────
198
209
  /**
@@ -200,16 +211,14 @@ export function enforceNotification(ir, input) {
200
211
  * Returns silent if no DNA policies are active.
201
212
  */
202
213
  export function enforceSessionStart(ir, input) {
203
- // Check if there's anything meaningful to report
204
214
  const directiveCount = ir.prompt_directives.length;
205
215
  const gateCount = ir.pre_execution_gates.length;
206
216
  const filterCount = ir.tool_filters.length;
207
217
  const roleCount = ir.roles_scope_map?.length ?? 0;
208
218
  const workflowCount = ir.workflows_ir?.length ?? 0;
209
219
  if (directiveCount === 0 && gateCount === 0 && filterCount === 0 && roleCount === 0) {
210
- return silentOutput();
220
+ return null;
211
221
  }
212
- // Build policy summary
213
222
  const lines = [
214
223
  "[Intent DNA] Session initialized — active governance:",
215
224
  ];
@@ -230,7 +239,7 @@ export function enforceSessionStart(ir, input) {
230
239
  if (stats.length > 0) {
231
240
  lines.push(` Enforcement: ${stats.join(", ")}`);
232
241
  }
233
- return allowOutput(lines.join("\n"), "SessionStart");
242
+ return { output: allowOutput(lines.join("\n"), "SessionStart") };
234
243
  }
235
244
  /**
236
245
  * Enforce Stop hook — verify workflow checkpoint completion.
@@ -245,31 +254,25 @@ export function enforceSessionStart(ir, input) {
245
254
  * - Workflow active + unmet checkpoints for current step
246
255
  */
247
256
  export function enforceStop(ir, input, workflowState) {
248
- // Safety valve 1: no workflow state → silent
249
257
  if (!workflowState || !workflowState.active) {
250
- return silentOutput();
258
+ return null;
251
259
  }
252
- // Safety valve 2: context_limit stops → NEVER block
253
260
  const reason = input.stop_reason ?? "";
254
261
  if (reason.includes("context_limit") || reason.includes("context_window")) {
255
- return silentOutput();
262
+ return null;
256
263
  }
257
- // Safety valve 3: user abort → don't block
258
264
  if (reason === "user_abort" || reason === "sigint") {
259
- return silentOutput();
265
+ return null;
260
266
  }
261
- // Safety valve 4: stale state (>2h)
262
267
  const STALE_MS = 2 * 60 * 60 * 1000;
263
268
  if (workflowState.started_at) {
264
269
  const age = Date.now() - new Date(workflowState.started_at).getTime();
265
270
  if (age > STALE_MS) {
266
- return silentOutput();
271
+ return null;
267
272
  }
268
273
  }
269
- // Check for unmet checkpoints in current step
270
274
  const checkpoints = ir.step_checkpoints ?? [];
271
275
  const currentStepCheckpoints = checkpoints.filter(cp => cp.step_id === workflowState.current_step);
272
- // Also check workflows_ir for the active workflow's checkpoints
273
276
  let workflowCheckpoints = currentStepCheckpoints;
274
277
  if (ir.workflows_ir && ir.workflows_ir.length > 0) {
275
278
  const activeWf = ir.workflows_ir.find(w => w.workflow_name === workflowState.workflow);
@@ -281,27 +284,27 @@ export function enforceStop(ir, input, workflowState) {
281
284
  }
282
285
  }
283
286
  if (workflowCheckpoints.length === 0) {
284
- return silentOutput(); // No checkpoints for current step
287
+ return null;
285
288
  }
286
- // Build list of blocking checkpoints
287
289
  const blocking = workflowCheckpoints.flatMap(cp => cp.checkpoints.filter(c => (c.action ?? "block") === "block"));
288
290
  if (blocking.length === 0) {
289
- // No checkpoint blocking — check handoff produces
290
291
  if (workflowState.workflow) {
291
292
  const handoffResult = enforceHandoffProduces(ir, {
292
293
  current_step: workflowState.current_step,
293
294
  workflow: workflowState.workflow,
294
295
  completed_artifacts: workflowState.completed_artifacts,
295
296
  });
296
- if (handoffResult && !handoffResult.continue)
297
+ if (handoffResult)
297
298
  return handoffResult;
298
299
  }
299
- return silentOutput();
300
+ return null;
300
301
  }
301
- // Block: workflow active with unmet checkpoints
302
302
  const messages = blocking.map(c => c.message);
303
- return blockOutput(`[Intent DNA] Workflow '${workflowState.workflow}' has unmet checkpoints at step '${workflowState.current_step}':\n` +
304
- messages.map(m => ` - ${m}`).join("\n"));
303
+ return {
304
+ output: blockOutput(`[Intent DNA] Workflow '${workflowState.workflow}' has unmet checkpoints at step '${workflowState.current_step}':\n` +
305
+ messages.map(m => ` - ${m}`).join("\n")),
306
+ trace: { matched_rule: "handoff", step_id: workflowState.current_step },
307
+ };
305
308
  }
306
309
  // ── G4: State-driven Step Enforcement ─────────────────────
307
310
  /** Write tools that step read_only rule should block */
@@ -393,10 +396,12 @@ export function enforceHandoffProduces(ir, wfState) {
393
396
  if (!currentEntry?.produces || currentEntry.produces.length === 0)
394
397
  return null;
395
398
  const currentArtifacts = (wfState.completed_artifacts ?? []).find(a => a.step_id === wfState.current_step);
396
- // Check that each produced artifact with a path has been recorded
397
399
  for (const produced of currentEntry.produces) {
398
400
  if (produced.path && (!currentArtifacts || !currentArtifacts.artifacts.some(a => a.path === produced.path))) {
399
- return blockOutput(`[Intent DNA] Step '${wfState.current_step}' must produce artifact '${produced.description}' (path: ${produced.path}) before proceeding.`);
401
+ return {
402
+ output: blockOutput(`[Intent DNA] Step '${wfState.current_step}' must produce artifact '${produced.description}' (path: ${produced.path}) before proceeding.`),
403
+ trace: { matched_rule: "handoff", step_id: wfState.current_step },
404
+ };
400
405
  }
401
406
  }
402
407
  return null;
@@ -712,3 +717,13 @@ export function checkContextReadiness(sessionReads, required) {
712
717
  const missing = required.filter(f => !readSet.has(f));
713
718
  return { ready: missing.length === 0, missing };
714
719
  }
720
+ // ── Workflow Boundary Gate ────────────────────────────────────
721
+ export function checkWorkflowBoundary(workflowCompleted, targetSkill) {
722
+ if (workflowCompleted) {
723
+ return {
724
+ output: blockOutput(`[Intent DNA] Workflow completed. Cannot start skill '${targetSkill}'.`),
725
+ trace: { matched_rule: "workflow_boundary" },
726
+ };
727
+ }
728
+ return { output: allowOutput() };
729
+ }
@@ -54,6 +54,17 @@ export interface HookOutput {
54
54
  permissionDecision?: "ask" | "allow" | "deny";
55
55
  };
56
56
  }
57
+ export type MatchedRule = "scope" | "filter" | "gate" | "schema" | "validator" | "handoff" | "step_rule" | "output_schema" | "context_gate" | "reflection_gate" | "workflow_boundary";
58
+ export interface TraceMetadata {
59
+ matched_rule: MatchedRule;
60
+ gene_id?: string;
61
+ step_id?: string;
62
+ role?: string;
63
+ }
64
+ export interface EnforceResult {
65
+ output: HookOutput;
66
+ trace?: TraceMetadata;
67
+ }
57
68
  /** Create an allow output, optionally injecting text into the conversation. */
58
69
  export declare function allowOutput(additionalContext?: string, hookEventName?: string): HookOutput;
59
70
  /** Create a block output with reason. */
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Hook input validation — CC protocol boundary.
3
+ * Zero external dependencies. Hand-written validators.
4
+ * Fail-open: invalid input → log warning + return { valid: false, ... }
5
+ */
6
+ export interface ValidationResult {
7
+ valid: boolean;
8
+ errors: string[];
9
+ normalized?: Record<string, unknown>;
10
+ }
11
+ /**
12
+ * Validate hook input against expected schema for each event type.
13
+ * Returns normalized input (camelCase) on success.
14
+ */
15
+ export declare function validateHookInput(event: string, raw: unknown): ValidationResult;
16
+ /**
17
+ * Normalize session_id field from various formats:
18
+ * - session_id (snake_case from CC)
19
+ * - sessionId (camelCase internal)
20
+ * - CLAUDE_SESSION_ID (env var fallback)
21
+ */
22
+ export declare function normalizeSessionId(input: Record<string, unknown>): string | undefined;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Hook input validation — CC protocol boundary.
3
+ * Zero external dependencies. Hand-written validators.
4
+ * Fail-open: invalid input → log warning + return { valid: false, ... }
5
+ */
6
+ /**
7
+ * Validate hook input against expected schema for each event type.
8
+ * Returns normalized input (camelCase) on success.
9
+ */
10
+ export function validateHookInput(event, raw) {
11
+ if (typeof raw !== "object" || raw === null) {
12
+ return { valid: false, errors: ["Input must be an object"] };
13
+ }
14
+ const input = raw;
15
+ const errors = [];
16
+ const normalized = { ...input };
17
+ // Normalize session_id to sessionId
18
+ const sessionId = normalizeSessionId(input);
19
+ if (sessionId !== undefined) {
20
+ normalized.sessionId = sessionId;
21
+ delete normalized.session_id;
22
+ }
23
+ switch (event) {
24
+ case "PreToolUse":
25
+ if (typeof input.tool_name !== "string") {
26
+ errors.push("PreToolUse: tool_name must be a string");
27
+ }
28
+ if (typeof input.tool_input !== "object" || input.tool_input === null) {
29
+ errors.push("PreToolUse: tool_input must be an object");
30
+ }
31
+ break;
32
+ case "PostToolUse":
33
+ if (typeof input.tool_name !== "string") {
34
+ errors.push("PostToolUse: tool_name must be a string");
35
+ }
36
+ if (typeof input.tool_input !== "object" || input.tool_input === null) {
37
+ errors.push("PostToolUse: tool_input must be an object");
38
+ }
39
+ // tool_output is optional
40
+ break;
41
+ case "SessionStart":
42
+ // session_id is optional but should be normalized if present
43
+ break;
44
+ case "SubagentStop":
45
+ // agent_type or agent_name should be present but not strictly required
46
+ break;
47
+ case "Notification":
48
+ if (typeof input.title !== "string" && input.title !== undefined) {
49
+ errors.push("Notification: title must be a string if present");
50
+ }
51
+ if (typeof input.message !== "string" && input.message !== undefined) {
52
+ errors.push("Notification: message must be a string if present");
53
+ }
54
+ break;
55
+ case "UserPromptSubmit":
56
+ case "PreCompact":
57
+ case "Stop":
58
+ // Minimal validation — these events have no required fields beyond base
59
+ break;
60
+ default:
61
+ errors.push(`Unknown event type: ${event}`);
62
+ }
63
+ return {
64
+ valid: errors.length === 0,
65
+ errors,
66
+ normalized: errors.length === 0 ? normalized : undefined,
67
+ };
68
+ }
69
+ /**
70
+ * Normalize session_id field from various formats:
71
+ * - session_id (snake_case from CC)
72
+ * - sessionId (camelCase internal)
73
+ * - CLAUDE_SESSION_ID (env var fallback)
74
+ */
75
+ export function normalizeSessionId(input) {
76
+ if (typeof input.sessionId === "string") {
77
+ return input.sessionId;
78
+ }
79
+ if (typeof input.session_id === "string") {
80
+ return input.session_id;
81
+ }
82
+ if (typeof input.CLAUDE_SESSION_ID === "string") {
83
+ return input.CLAUDE_SESSION_ID;
84
+ }
85
+ return undefined;
86
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Intent DNA — DNAStateManager
3
+ *
4
+ * Class-based facade over state.ts module functions. Bundles projectDir +
5
+ * sessionId into a single object so call sites don't repeat those args.
6
+ * Adds experience chain persistence (markdown + structured JSON) and
7
+ * lifecycle operations (init / cleanup / isStale) that state.ts lacks.
8
+ *
9
+ * Existing state.ts functions remain the canonical I/O primitives — this
10
+ * class delegates to them. Prefer this class in new code; state.ts exports
11
+ * are @deprecated for direct use.
12
+ */
13
+ import type { DNAWorkflowState, AuditEntry, SurgeonAttemptState, SessionReadsState } from "./state.js";
14
+ /** Single entry in the experience chain — one per failed surgeon round. */
15
+ export interface ExperienceEntry {
16
+ round: number;
17
+ analysis: string;
18
+ attempt: string;
19
+ result: string;
20
+ lesson: string;
21
+ timestamp: string;
22
+ }
23
+ export declare class DNAStateManager {
24
+ private readonly projectDir;
25
+ private readonly sessionId?;
26
+ constructor(projectDir: string, sessionId?: string | undefined);
27
+ readWorkflowState(stalenessMs?: number): Promise<DNAWorkflowState | null>;
28
+ writeWorkflowState(state: DNAWorkflowState): Promise<void>;
29
+ clearWorkflowState(): Promise<void>;
30
+ appendAudit(entry: AuditEntry): Promise<void>;
31
+ readSurgeonAttempts(): Promise<SurgeonAttemptState>;
32
+ writeSurgeonAttempts(state: SurgeonAttemptState): Promise<void>;
33
+ readSessionReads(): Promise<SessionReadsState>;
34
+ appendSessionRead(filePath: string): Promise<void>;
35
+ readExperienceChain(): Promise<ExperienceEntry[]>;
36
+ writeExperienceChain(chain: ExperienceEntry[]): Promise<void>;
37
+ appendExperience(entry: ExperienceEntry): Promise<void>;
38
+ /** Ensure session directory structure exists. */
39
+ init(): Promise<void>;
40
+ /**
41
+ * Remove this manager's state files.
42
+ * Callers that need trace rotation should invoke rotateTraces() from state.ts separately.
43
+ */
44
+ cleanup(): Promise<void>;
45
+ /** True when workflow.json started_at exceeds the staleness threshold. */
46
+ isStale(stalenessMs?: number): Promise<boolean>;
47
+ private path;
48
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Intent DNA — DNAStateManager
3
+ *
4
+ * Class-based facade over state.ts module functions. Bundles projectDir +
5
+ * sessionId into a single object so call sites don't repeat those args.
6
+ * Adds experience chain persistence (markdown + structured JSON) and
7
+ * lifecycle operations (init / cleanup / isStale) that state.ts lacks.
8
+ *
9
+ * Existing state.ts functions remain the canonical I/O primitives — this
10
+ * class delegates to them. Prefer this class in new code; state.ts exports
11
+ * are @deprecated for direct use.
12
+ */
13
+ import { readFile, mkdir, unlink, stat } from "node:fs/promises";
14
+ import { join } from "node:path";
15
+ import { resolveStateDir, readWorkflowState as rwState, writeWorkflowState as wwState, clearWorkflowState as cwState, appendAudit as aAudit, readSurgeonAttempts as rSurg, writeSurgeonAttempts as wSurg, readSessionReads as rReads, appendSessionRead as aRead, atomicWrite, } from "./state.js";
16
+ const EXPERIENCE_JSON = "workflow/experience.json";
17
+ const EXPERIENCE_MD = "workflow/experience.md";
18
+ const DEFAULT_STALENESS_MS = 2 * 60 * 60 * 1000; // 2h — matches state.ts
19
+ // ── Manager ────────────────────────────────────────────────
20
+ export class DNAStateManager {
21
+ projectDir;
22
+ sessionId;
23
+ constructor(projectDir, sessionId) {
24
+ this.projectDir = projectDir;
25
+ this.sessionId = sessionId;
26
+ }
27
+ // ── Workflow state ──
28
+ readWorkflowState(stalenessMs = DEFAULT_STALENESS_MS) {
29
+ return rwState(this.projectDir, this.sessionId, stalenessMs);
30
+ }
31
+ writeWorkflowState(state) {
32
+ return wwState(this.projectDir, state, this.sessionId);
33
+ }
34
+ clearWorkflowState() {
35
+ return cwState(this.projectDir, this.sessionId);
36
+ }
37
+ // ── Audit log ──
38
+ appendAudit(entry) {
39
+ return aAudit(this.projectDir, entry);
40
+ }
41
+ // ── Surgeon attempts ──
42
+ readSurgeonAttempts() {
43
+ return rSurg(this.projectDir, this.sessionId);
44
+ }
45
+ writeSurgeonAttempts(state) {
46
+ return wSurg(this.projectDir, state, this.sessionId);
47
+ }
48
+ // ── Session reads ──
49
+ readSessionReads() {
50
+ return rReads(this.projectDir, this.sessionId);
51
+ }
52
+ appendSessionRead(filePath) {
53
+ return aRead(this.projectDir, filePath, this.sessionId);
54
+ }
55
+ // ── Experience chain (new) ──
56
+ async readExperienceChain() {
57
+ const path = this.path(EXPERIENCE_JSON);
58
+ try {
59
+ const raw = await readFile(path, "utf-8");
60
+ const parsed = JSON.parse(raw);
61
+ return Array.isArray(parsed) ? parsed : [];
62
+ }
63
+ catch {
64
+ return [];
65
+ }
66
+ }
67
+ async writeExperienceChain(chain) {
68
+ await atomicWrite(this.path(EXPERIENCE_JSON), JSON.stringify(chain, null, 2));
69
+ await atomicWrite(this.path(EXPERIENCE_MD), renderExperienceMarkdown(chain));
70
+ }
71
+ async appendExperience(entry) {
72
+ const chain = await this.readExperienceChain();
73
+ chain.push(entry);
74
+ await this.writeExperienceChain(chain);
75
+ }
76
+ // ── Lifecycle ──
77
+ /** Ensure session directory structure exists. */
78
+ async init() {
79
+ await mkdir(resolveStateDir(this.projectDir, this.sessionId), { recursive: true });
80
+ }
81
+ /**
82
+ * Remove this manager's state files.
83
+ * Callers that need trace rotation should invoke rotateTraces() from state.ts separately.
84
+ */
85
+ async cleanup() {
86
+ const dir = resolveStateDir(this.projectDir, this.sessionId);
87
+ for (const rel of ["workflow.json", EXPERIENCE_JSON, EXPERIENCE_MD, "workflow/surgeon-attempts.json", "workflow/session-reads.json"]) {
88
+ await unlink(join(dir, rel)).catch(() => { });
89
+ }
90
+ }
91
+ /** True when workflow.json started_at exceeds the staleness threshold. */
92
+ async isStale(stalenessMs = DEFAULT_STALENESS_MS) {
93
+ const wfPath = join(resolveStateDir(this.projectDir, this.sessionId), "workflow.json");
94
+ try {
95
+ const raw = await readFile(wfPath, "utf-8");
96
+ const state = JSON.parse(raw);
97
+ if (!state.started_at)
98
+ return false;
99
+ return Date.now() - new Date(state.started_at).getTime() > stalenessMs;
100
+ }
101
+ catch {
102
+ // No workflow.json — fall back to directory mtime for unparseable state
103
+ try {
104
+ const s = await stat(wfPath);
105
+ return Date.now() - s.mtimeMs > stalenessMs;
106
+ }
107
+ catch {
108
+ return false;
109
+ }
110
+ }
111
+ }
112
+ path(rel) {
113
+ return join(resolveStateDir(this.projectDir, this.sessionId), rel);
114
+ }
115
+ }
116
+ // ── Markdown rendering ────────────────────────────────────
117
+ function renderExperienceMarkdown(chain) {
118
+ if (chain.length === 0) {
119
+ return "# Experience chain\n\n_Empty — no attempts yet._\n";
120
+ }
121
+ const lines = ["# Experience chain", ""];
122
+ for (const e of chain) {
123
+ lines.push(`## Round ${e.round} — ${e.timestamp}`);
124
+ lines.push("");
125
+ lines.push(`**Analysis:** ${e.analysis}`);
126
+ lines.push("");
127
+ lines.push(`**Attempt:** ${e.attempt}`);
128
+ lines.push("");
129
+ lines.push(`**Result:** ${e.result}`);
130
+ lines.push("");
131
+ lines.push(`**Lesson:** ${e.lesson}`);
132
+ lines.push("");
133
+ }
134
+ return lines.join("\n");
135
+ }
@@ -56,20 +56,24 @@ export interface SurgeonAttemptState {
56
56
  export declare function resolveStateDir(projectDir: string, sessionId?: string): string;
57
57
  /**
58
58
  * Read current workflow state. Returns null if not found or stale.
59
+ * @deprecated Prefer `new DNAStateManager(projectDir, sessionId).readWorkflowState()`.
59
60
  */
60
61
  export declare function readWorkflowState(projectDir: string, sessionId?: string, stalenessMs?: number): Promise<DNAWorkflowState | null>;
61
62
  /**
62
63
  * Write workflow state atomically.
64
+ * @deprecated Prefer `DNAStateManager.writeWorkflowState()`.
63
65
  */
64
66
  export declare function writeWorkflowState(projectDir: string, state: DNAWorkflowState, sessionId?: string): Promise<void>;
65
67
  /**
66
68
  * Clear workflow state (workflow complete).
69
+ * @deprecated Prefer `DNAStateManager.clearWorkflowState()`.
67
70
  */
68
71
  export declare function clearWorkflowState(projectDir: string, sessionId?: string): Promise<void>;
69
72
  /**
70
73
  * Append an entry to the audit log with dedup protection.
71
74
  * Dedup key: event + tool_name + timestamp (second-level).
72
75
  * Log file: `.dna/audit/violations-YYYY-MM-DD.log` (JSON Lines format)
76
+ * @deprecated Prefer `DNAStateManager.appendAudit()`.
73
77
  */
74
78
  export declare function appendAudit(projectDir: string, entry: AuditEntry): Promise<void>;
75
79
  /**
@@ -84,10 +88,12 @@ export declare function appendCompletedArtifact(projectDir: string, stepId: stri
84
88
  export declare function atomicWrite(filePath: string, data: string): Promise<void>;
85
89
  /**
86
90
  * Read surgeon attempt state. Returns default state if not found.
91
+ * @deprecated Prefer `DNAStateManager.readSurgeonAttempts()`.
87
92
  */
88
93
  export declare function readSurgeonAttempts(projectDir: string, sessionId?: string): Promise<SurgeonAttemptState>;
89
94
  /**
90
95
  * Write surgeon attempt state atomically.
96
+ * @deprecated Prefer `DNAStateManager.writeSurgeonAttempts()`.
91
97
  */
92
98
  export declare function writeSurgeonAttempts(projectDir: string, state: SurgeonAttemptState, sessionId?: string): Promise<void>;
93
99
  /** Tracks which files a session has Read — for context gate enforcement */
@@ -97,6 +103,7 @@ export interface SessionReadsState {
97
103
  }
98
104
  /**
99
105
  * Read session reads state. Returns empty reads if not found.
106
+ * @deprecated Prefer `DNAStateManager.readSessionReads()`.
100
107
  */
101
108
  export declare function readSessionReads(projectDir: string, sessionId?: string): Promise<SessionReadsState>;
102
109
  /**
@@ -105,6 +112,7 @@ export declare function readSessionReads(projectDir: string, sessionId?: string)
105
112
  export declare function writeSessionReads(projectDir: string, state: SessionReadsState, sessionId?: string): Promise<void>;
106
113
  /**
107
114
  * Append a file path to session reads (dedup).
115
+ * @deprecated Prefer `DNAStateManager.appendSessionRead()`.
108
116
  */
109
117
  export declare function appendSessionRead(projectDir: string, filePath: string, sessionId?: string): Promise<void>;
110
118
  /** Trace entry for hook call observability */