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.
- package/.claude-plugin/marketplace.json +20 -7
- package/.claude-plugin/plugin.json +16 -2
- package/dist/hooks/cli.d.ts +30 -0
- package/dist/hooks/cli.js +168 -59
- package/dist/hooks/enforce.d.ts +13 -12
- package/dist/hooks/enforce.js +61 -46
- package/dist/hooks/protocol.d.ts +11 -0
- package/dist/hooks/schema.d.ts +22 -0
- package/dist/hooks/schema.js +86 -0
- package/dist/hooks/state-manager.d.ts +48 -0
- package/dist/hooks/state-manager.js +135 -0
- package/dist/hooks/state.d.ts +8 -0
- package/dist/hooks/state.js +8 -0
- package/dist/mcp/tools-enforce.js +6 -2
- package/dist/templates/flutter-rewrite.dna.yaml +18 -17
- package/package.json +4 -2
- package/spec/hooks-infra-harness-hardening.md +72 -26
- package/LICENSE +0 -69
- /package/{.claude-plugin/hooks → hooks}/hooks.json +0 -0
package/dist/hooks/enforce.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Step checkpoints are handled by the CLI, not here.
|
|
15
15
|
*/
|
|
16
|
-
import { allowOutput, blockOutput, escalateOutput
|
|
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
|
|
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
|
|
96
|
-
|
|
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
|
|
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
|
|
127
|
+
return null;
|
|
124
128
|
if (!input.agent_type)
|
|
125
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
199
|
+
return null;
|
|
191
200
|
const message = input.message ?? "";
|
|
192
201
|
if (message.includes("Intent DNA")) {
|
|
193
|
-
return
|
|
202
|
+
return {
|
|
203
|
+
output: allowOutput(`[Intent DNA] Notification: violation detected — ${input.title ?? "untitled"}: ${message}`),
|
|
204
|
+
};
|
|
194
205
|
}
|
|
195
|
-
return
|
|
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
|
|
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
|
|
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
|
|
262
|
+
return null;
|
|
256
263
|
}
|
|
257
|
-
// Safety valve 3: user abort → don't block
|
|
258
264
|
if (reason === "user_abort" || reason === "sigint") {
|
|
259
|
-
return
|
|
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
|
|
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
|
|
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
|
|
297
|
+
if (handoffResult)
|
|
297
298
|
return handoffResult;
|
|
298
299
|
}
|
|
299
|
-
return
|
|
300
|
+
return null;
|
|
300
301
|
}
|
|
301
|
-
// Block: workflow active with unmet checkpoints
|
|
302
302
|
const messages = blocking.map(c => c.message);
|
|
303
|
-
return
|
|
304
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/hooks/protocol.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/hooks/state.d.ts
CHANGED
|
@@ -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 */
|