intentdna 1.5.13 → 1.5.15

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.
@@ -11,4 +11,8 @@ export interface InitOptions {
11
11
  register?: string;
12
12
  upgrade?: string | boolean;
13
13
  }
14
+ /**
15
+ * Calculate SHA256 hash of template content (truncated to 16 hex chars).
16
+ */
17
+ export declare function calculateTemplateHash(content: string): string;
14
18
  export declare function runInit(opts: InitOptions): Promise<number>;
@@ -6,8 +6,9 @@
6
6
  import { writeFile, readFile, readdir, mkdir, copyFile, access } from "node:fs/promises";
7
7
  import { resolve, dirname, basename } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
+ import { createHash } from "node:crypto";
9
10
  import { parseYAML } from "../../schema/yaml-parser.js";
10
- import { getPackageVersion, sanitizeTemplateName } from "../util/version.js";
11
+ import { sanitizeTemplateName } from "../util/version.js";
11
12
  import { validateDNA } from "../../schema/validate.js";
12
13
  import { askString, askChoice, askNumber, askYesNo, closePrompt } from "../util/prompt.js";
13
14
  const TEMPLATES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "templates");
@@ -65,17 +66,24 @@ async function listTemplates() {
65
66
  return results;
66
67
  }
67
68
  /**
68
- * Inject _source_template and _template_version fields after the namespace/type line in YAML content.
69
+ * Calculate SHA256 hash of template content (truncated to 16 hex chars).
69
70
  */
70
- function injectSourceTemplate(content, templateName, version) {
71
+ export function calculateTemplateHash(content) {
72
+ return createHash("sha256").update(content, "utf-8").digest("hex").slice(0, 16);
73
+ }
74
+ /**
75
+ * Inject _source_template, _template_version, and _template_content_hash fields after the namespace/type line in YAML content.
76
+ */
77
+ function injectSourceTemplate(content, templateName, templateVersion, templateHash) {
71
78
  const marker = /^(namespace:\s*.+)$/m;
72
79
  const typeMarker = /^(type:\s*.+)$/m;
73
80
  const match = content.match(marker) || content.match(typeMarker);
74
- const versionLine = version ? `\n_template_version: "${version}"` : "";
81
+ const versionLine = templateVersion ? `\n_template_version: "${templateVersion}"` : "";
82
+ const hashLine = templateHash ? `\n_template_content_hash: "${templateHash}"` : "";
75
83
  if (match) {
76
- return content.replace(match[0], `${match[0]}\n_source_template: ${templateName}${versionLine}`);
84
+ return content.replace(match[0], `${match[0]}\n_source_template: ${templateName}${versionLine}${hashLine}`);
77
85
  }
78
- return `_source_template: ${templateName}${versionLine}\n${content}`;
86
+ return `_source_template: ${templateName}${versionLine}${hashLine}\n${content}`;
79
87
  }
80
88
  /**
81
89
  * Check namespace collision against existing configs in .dna/configs/.
@@ -164,9 +172,11 @@ async function upgradeConfig(configPath, templateName) {
164
172
  const srcDir = found.source === "project" ? PROJECT_TEMPLATES_DIR : TEMPLATES_DIR;
165
173
  const srcPath = resolve(srcDir, `${found.name}.dna.yaml`);
166
174
  let newContent = await readFile(srcPath, "utf-8");
167
- // 4. Inject _source_template and _template_version
168
- const pkgVersion = await getPackageVersion();
169
- newContent = injectSourceTemplate(newContent, found.name, pkgVersion ?? undefined);
175
+ // 4. Inject _source_template, _template_version, and _template_content_hash
176
+ const templateData = parseYAML(newContent);
177
+ const templateVersion = typeof templateData.version === "string" ? templateData.version : undefined;
178
+ const templateHash = calculateTemplateHash(newContent);
179
+ newContent = injectSourceTemplate(newContent, found.name, templateVersion, templateHash);
170
180
  // 5. Replace VariableDef entries with user values where they exist
171
181
  for (const [key, value] of Object.entries(userVars)) {
172
182
  // Match multi-line VariableDef: "key:\n description: ...\n default: ..."
@@ -290,8 +300,11 @@ export async function runInit(opts) {
290
300
  const srcPath = resolve(srcDir, `${found.name}.dna.yaml`);
291
301
  const content = await readFile(srcPath, "utf-8");
292
302
  // Inject _source_template and _template_version metadata for upgrade tracking
293
- const pkgVersion = await getPackageVersion();
294
- const contentWithSource = injectSourceTemplate(content, found.name, pkgVersion ?? undefined);
303
+ // Inject _source_template, _template_version, and _template_content_hash for upgrade tracking
304
+ const templateData = parseYAML(content);
305
+ const templateVersion = typeof templateData.version === "string" ? templateData.version : undefined;
306
+ const templateHash = calculateTemplateHash(content);
307
+ const contentWithSource = injectSourceTemplate(content, found.name, templateVersion, templateHash);
295
308
  // Check namespace collision against existing configs
296
309
  const srcData = parseYAML(content);
297
310
  const srcNs = typeof srcData.namespace === "string" ? srcData.namespace : undefined;
@@ -40,6 +40,12 @@ export interface SyncOptions {
40
40
  * → ALSO write settings.json to register dna-hook events
41
41
  */
42
42
  export declare function detectMode(): Promise<"plugin" | "bin">;
43
+ /**
44
+ * Remove stale DNA-managed files from a directory.
45
+ * Only removes files matching activeNamespaces (dna-{ns}-*).
46
+ * Returns list of removed paths.
47
+ */
48
+ export declare function cleanStaleDNAFiles(dir: string, type: "agents" | "skills", activeNamespaces: string[]): Promise<string[]>;
43
49
  /**
44
50
  * Auto-detect DNA configs: multi-config (.dna/configs/*.yaml) or legacy single-config.
45
51
  * Returns array of absolute paths.
@@ -70,11 +76,10 @@ export interface TemplateUpgradeInfo {
70
76
  availableVersion: string;
71
77
  }
72
78
  /**
73
- * Check template versions against current package version.
74
- * Returns upgrade info for outdated configs and prints human-readable suggestions.
79
+ * Check template versions against current template content hashes.
80
+ * Returns upgrade info for changed configs and prints human-readable suggestions.
75
81
  *
76
- * U1: Semver-aware comparison only suggests upgrades when package is strictly newer.
77
- * Handles missing _template_version (old configs created before versioning).
82
+ * Uses content hash comparison instead of semver detects any template content change.
78
83
  */
79
84
  export declare function checkTemplateVersions(configPaths: string[]): Promise<TemplateUpgradeInfo[]>;
80
85
  export declare function runSync(opts: SyncOptions): Promise<number>;
@@ -17,7 +17,6 @@ import { fileURLToPath } from "node:url";
17
17
  import { createInterface } from "node:readline";
18
18
  import { loadDNA, compileFromFiles } from "../../compiler/index.js";
19
19
  import { parseYAML } from "../../schema/yaml-parser.js";
20
- import { getPackageVersion } from "../util/version.js";
21
20
  import { compileToMarkdown, injectIntoFile, removeFromFile } from "../../runtime/markdown.js";
22
21
  import { compileAllRolesToAgentMD, writeAgentMDFiles, removeAgentMDFiles } from "../../runtime/agent-md.js";
23
22
  import { removeWorkflowScripts } from "../../runtime/workflow-runner.js";
@@ -72,6 +71,32 @@ const LEGACY_BASH_HOOKS = [
72
71
  "dna-pre-compact.sh",
73
72
  "dna-notification.sh",
74
73
  ];
74
+ const TEMPLATES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "templates");
75
+ function calculateTemplateHash(content) {
76
+ return createHash("sha256").update(content, "utf-8").digest("hex").slice(0, 16);
77
+ }
78
+ async function findTemplateFile(templateName) {
79
+ // Try direct filename match first
80
+ const directPath = resolve(TEMPLATES_DIR, `${templateName}.dna.yaml`);
81
+ if (await fileExists(directPath))
82
+ return directPath;
83
+ // Fallback: search by id or name field
84
+ try {
85
+ const files = await readdir(TEMPLATES_DIR);
86
+ for (const file of files) {
87
+ if (!file.endsWith(".dna.yaml") && !file.endsWith(".dna.yml"))
88
+ continue;
89
+ const fullPath = resolve(TEMPLATES_DIR, file);
90
+ const content = await readFile(fullPath, "utf-8");
91
+ const data = parseYAML(content);
92
+ if (data.id === templateName || data.name === templateName) {
93
+ return fullPath;
94
+ }
95
+ }
96
+ }
97
+ catch { /* fail-open */ }
98
+ return null;
99
+ }
75
100
  /**
76
101
  * Remove legacy bash hook scripts written by previous Intent DNA versions.
77
102
  * Only removes files that contain the sentinel comment.
@@ -93,15 +118,22 @@ async function removeLegacyBashHooks(hooksDir) {
93
118
  }
94
119
  /**
95
120
  * Remove stale DNA-managed files from a directory.
96
- * Scans for files/dirs containing the sentinel marker and deletes them.
121
+ * Only removes files matching activeNamespaces (dna-{ns}-*).
97
122
  * Returns list of removed paths.
98
123
  */
99
- async function cleanStaleDNAFiles(dir, type) {
124
+ export async function cleanStaleDNAFiles(dir, type, activeNamespaces) {
100
125
  const removed = [];
101
126
  try {
102
127
  const entries = await readdir(dir, { withFileTypes: true });
103
128
  for (const entry of entries) {
104
129
  const fullPath = resolve(dir, entry.name);
130
+ // Extract namespace from filename: dna-{namespace}-*
131
+ const nsMatch = entry.name.match(/^dna-([a-z0-9_-]+)-/);
132
+ const fileNamespace = nsMatch ? nsMatch[1] : null;
133
+ // Skip if namespace doesn't match active namespaces
134
+ if (!fileNamespace || !activeNamespaces.includes(fileNamespace)) {
135
+ continue;
136
+ }
105
137
  if (type === "agents" && entry.isFile() && entry.name.endsWith(".md")) {
106
138
  const content = await readFile(fullPath, "utf-8").catch(() => "");
107
139
  if (content.includes(DNA_SENTINEL)) {
@@ -276,42 +308,44 @@ export function compareSemver(a, b) {
276
308
  return 0;
277
309
  }
278
310
  /**
279
- * Check template versions against current package version.
280
- * Returns upgrade info for outdated configs and prints human-readable suggestions.
311
+ * Check template versions against current template content hashes.
312
+ * Returns upgrade info for changed configs and prints human-readable suggestions.
281
313
  *
282
- * U1: Semver-aware comparison only suggests upgrades when package is strictly newer.
283
- * Handles missing _template_version (old configs created before versioning).
314
+ * Uses content hash comparison instead of semver detects any template content change.
284
315
  */
285
316
  export async function checkTemplateVersions(configPaths) {
286
- const pkgVersion = await getPackageVersion();
287
- if (!pkgVersion)
288
- return [];
289
317
  const upgrades = [];
290
318
  for (const configPath of configPaths) {
291
319
  try {
292
320
  const content = await readFile(configPath, "utf-8");
293
321
  const data = parseYAML(content);
294
- const tmplVersion = typeof data._template_version === "string" ? data._template_version : undefined;
295
322
  const tmplName = typeof data._source_template === "string" ? data._source_template : undefined;
296
- if (tmplName && !tmplVersion) {
297
- // Old config without version tracking suggest upgrade
323
+ const tmplHash = typeof data._template_content_hash === "string" ? data._template_content_hash : undefined;
324
+ const tmplVersion = typeof data._template_version === "string" ? data._template_version : undefined;
325
+ if (!tmplName)
326
+ continue;
327
+ const templatePath = await findTemplateFile(tmplName);
328
+ if (!templatePath)
329
+ continue;
330
+ const currentTemplateContent = await readFile(templatePath, "utf-8");
331
+ const currentHash = calculateTemplateHash(currentTemplateContent);
332
+ if (!tmplHash) {
298
333
  upgrades.push({
299
334
  configPath,
300
335
  templateName: tmplName,
301
- currentVersion: "unknown",
302
- availableVersion: pkgVersion,
336
+ currentVersion: tmplVersion ?? "unknown",
337
+ availableVersion: "content-changed",
303
338
  });
304
- process.stderr.write(`Upgrade available: "${tmplName}" has no version tag. Run: dna init --upgrade ${tmplName}\n`);
339
+ process.stderr.write(`Upgrade available: "${tmplName}" has no content hash. Run: dna init --upgrade ${tmplName}\n`);
305
340
  }
306
- else if (tmplVersion && tmplName && compareSemver(pkgVersion, tmplVersion) > 0) {
307
- // Package is strictly newer than template — suggest upgrade
341
+ else if (currentHash !== tmplHash) {
308
342
  upgrades.push({
309
343
  configPath,
310
344
  templateName: tmplName,
311
- currentVersion: tmplVersion,
312
- availableVersion: pkgVersion,
345
+ currentVersion: tmplVersion ?? "unknown",
346
+ availableVersion: "content-changed",
313
347
  });
314
- process.stderr.write(`Upgrade available: "${tmplName}" ${tmplVersion} → ${pkgVersion}. Run: dna init --upgrade ${tmplName}\n`);
348
+ process.stderr.write(`Template content changed: "${tmplName}". Run: dna init --upgrade ${tmplName}\n`);
315
349
  }
316
350
  }
317
351
  catch {
@@ -544,10 +578,14 @@ export async function runSync(opts) {
544
578
  else if (opts.plugin && opts.settingsPath) {
545
579
  process.stderr.write(`Plugin mode: hooks managed by plugin framework (skipping settings.json)\n`);
546
580
  }
581
+ // Extract active namespaces from loaded DNAs for namespace-scoped cleanup
582
+ const activeNamespaces = loadedDNAs
583
+ .map(d => d.namespace)
584
+ .filter((ns) => typeof ns === "string");
547
585
  // Step 5: Generate agent MD files
548
586
  if (opts.agentsDir) {
549
587
  // Clean stale DNA-managed agents before regenerating
550
- const staleAgents = await cleanStaleDNAFiles(opts.agentsDir, "agents");
588
+ const staleAgents = await cleanStaleDNAFiles(opts.agentsDir, "agents", activeNamespaces);
551
589
  if (staleAgents.length > 0) {
552
590
  process.stderr.write(`Cleaned ${staleAgents.length} stale agent file(s)\n`);
553
591
  }
@@ -608,7 +646,7 @@ export async function runSync(opts) {
608
646
  // Step 7: Generate skill files from workflows
609
647
  if (opts.skillsDir) {
610
648
  // Clean stale DNA-managed skills before regenerating
611
- const staleSkills = await cleanStaleDNAFiles(opts.skillsDir, "skills");
649
+ const staleSkills = await cleanStaleDNAFiles(opts.skillsDir, "skills", activeNamespaces);
612
650
  if (staleSkills.length > 0) {
613
651
  process.stderr.write(`Cleaned ${staleSkills.length} stale skill dir(s)\n`);
614
652
  }
@@ -14,6 +14,9 @@
14
14
  *
15
15
  * Fail-open: all errors → { continue: true, suppressOutput: true }
16
16
  */
17
+ import type { ConstraintIR } from "../schema/types.js";
18
+ import { blockOutput } from "./protocol.js";
19
+ import { readWorkflowState } from "./state.js";
17
20
  export interface SessionSummary {
18
21
  total: number;
19
22
  blocks: number;
@@ -34,3 +37,30 @@ export declare function computeSummary(traces: Array<{
34
37
  reason?: string;
35
38
  }>): SessionSummary;
36
39
  export declare function formatSummary(s: SessionSummary): string | null;
40
+ /**
41
+ * Evaluate PreToolUse gates in priority order:
42
+ * 1. Workflow Boundary — block Skill() after workflow completed
43
+ * 2. Context Gate — block Edit/Write/Bash when required context files unread
44
+ *
45
+ * Returns the first triggered block, or null if all gates pass.
46
+ * Fail-open on any exception.
47
+ */
48
+ export declare function handlePreToolGates(ir: ConstraintIR, rawInput: Record<string, unknown>, wfState: Awaited<ReturnType<typeof readWorkflowState>>, projectDir: string, sessionId?: string): Promise<{
49
+ output: ReturnType<typeof blockOutput>;
50
+ matched_rule: "workflow_boundary" | "context_gate";
51
+ } | null>;
52
+ /**
53
+ * Build next-step guidance for Stop hook.
54
+ * Returns a string to append to the Stop output's reason, or null if
55
+ * no explicit guidance applies (normal flow).
56
+ *
57
+ * Guidance cases:
58
+ * 1. Surgeon stalled — fail_count has reached the step's max_attempts
59
+ * for a step that defines handoff_to. Emit Agent() dispatch to the
60
+ * handoff role with experience chain context.
61
+ * 2. Workflow complete — wfState exists but active=false. Emit STOP
62
+ * directive so the assistant doesn't auto-start another workflow.
63
+ *
64
+ * Fail-open: any exception returns null.
65
+ */
66
+ export declare function buildWorkflowGuidance(projectDir: string, wfState: Awaited<ReturnType<typeof readWorkflowState>>, sessionId?: string): Promise<string | null>;
package/dist/hooks/cli.js CHANGED
@@ -17,8 +17,9 @@
17
17
  import { readFile, stat } from "node:fs/promises";
18
18
  import { join, resolve } from "node:path";
19
19
  import { randomUUID } from "node:crypto";
20
- import { readStdin, writeOutput, silentOutput, allowOutput } from "./protocol.js";
21
- import { enforcePreToolUse, enforcePostToolUse, enforceUserPromptSubmit, enforceSubagentStop, enforcePreCompact, enforceNotification, enforceSessionStart, enforceStop, extractBashWritePaths, checkReflectionLimit, checkContextReadiness, } from "./enforce.js";
20
+ import { readStdin, writeOutput, silentOutput, allowOutput, blockOutput } from "./protocol.js";
21
+ import { validateHookInput } from "./schema.js";
22
+ import { enforcePreToolUse, enforcePostToolUse, enforceUserPromptSubmit, enforceSubagentStop, enforcePreCompact, enforceNotification, enforceSessionStart, enforceStop, extractBashWritePaths, checkReflectionLimit, checkContextReadiness, checkWorkflowBoundary, } from "./enforce.js";
22
23
  import { appendAudit, readWorkflowState, appendTrace, rotateTraces, readTraces, cleanStaleState, readSurgeonAttempts, writeSurgeonAttempts, appendSessionRead, readSessionReads } from "./state.js";
23
24
  // ── Constants ──────────────────────────────────────────────
24
25
  const DEFAULT_IR_PATH = ".dna/compiled/ir.json";
@@ -80,12 +81,19 @@ async function main() {
80
81
  irPath = args[irFlagIdx + 1];
81
82
  }
82
83
  // Read stdin
83
- const rawInput = await readStdin(5000);
84
+ const rawStdin = await readStdin(5000);
85
+ // Validate + normalize input against CC hook protocol schema.
86
+ // Fail-open: invalid input → write stderr warning + silent exit.
87
+ const validation = validateHookInput(event, rawStdin);
88
+ if (!validation.valid) {
89
+ process.stderr.write(`\n Intent DNA: invalid ${event} input — ${validation.errors.join("; ")}\n`);
90
+ writeOutput(silentOutput());
91
+ return;
92
+ }
93
+ const rawInput = validation.normalized;
84
94
  // Resolve project directory from input or cwd
85
95
  const projectDir = typeof rawInput.cwd === "string" ? rawInput.cwd : process.cwd();
86
- const sessionId = typeof rawInput.session_id === "string" ? rawInput.session_id
87
- : typeof rawInput.sessionId === "string" ? rawInput.sessionId
88
- : undefined;
96
+ const sessionId = typeof rawInput.sessionId === "string" ? rawInput.sessionId : undefined;
89
97
  // Load compiled IR
90
98
  const resolvedIRPath = resolve(projectDir, irPath);
91
99
  const ir = await loadIR(resolvedIRPath);
@@ -94,24 +102,46 @@ async function main() {
94
102
  return;
95
103
  }
96
104
  const state = {};
105
+ let wfStateRaw = null;
97
106
  // Load workflow state for events that need it (handoff context + PreCompact preservation)
98
107
  if (event === "PreToolUse" || event === "PreCompact") {
99
- const wfState = await readWorkflowState(projectDir, sessionId);
100
- if (wfState && wfState.active) {
108
+ wfStateRaw = await readWorkflowState(projectDir, sessionId);
109
+ if (wfStateRaw && wfStateRaw.active) {
101
110
  state.workflowState = {
102
- current_step: wfState.current_step,
103
- workflow: wfState.workflow,
104
- current_role: wfState.current_role,
105
- completed_artifacts: wfState.completed_artifacts,
106
- iteration: wfState.iteration, // G4: pass iteration for state-driven rules
111
+ current_step: wfStateRaw.current_step,
112
+ workflow: wfStateRaw.workflow,
113
+ current_role: wfStateRaw.current_role,
114
+ completed_artifacts: wfStateRaw.completed_artifacts,
115
+ iteration: wfStateRaw.iteration, // G4: pass iteration for state-driven rules
107
116
  };
108
117
  // Re-run fallback: scan consumed artifact paths on disk so
109
118
  // enforceHandoffConsumes can skip blocks for files that already exist.
110
119
  if (event === "PreToolUse") {
111
- state.existingArtifactPaths = await scanConsumedArtifactPaths(projectDir, ir, wfState.workflow, wfState.current_step);
120
+ state.existingArtifactPaths = await scanConsumedArtifactPaths(projectDir, ir, wfStateRaw.workflow, wfStateRaw.current_step);
112
121
  }
113
122
  }
114
123
  }
124
+ // Phase 2B: PreToolUse gates (workflow boundary + context gate).
125
+ // Run before dispatch so the block shortcircuits the rest of the pipeline.
126
+ if (event === "PreToolUse") {
127
+ const gateResult = await handlePreToolGates(ir, rawInput, wfStateRaw, projectDir, sessionId);
128
+ if (gateResult) {
129
+ writeOutput(gateResult.output);
130
+ appendTrace(projectDir, {
131
+ trace_id: randomUUID(),
132
+ event,
133
+ tool_name: typeof rawInput.tool_name === "string" ? rawInput.tool_name : undefined,
134
+ agent_type: typeof rawInput.agent_type === "string" ? rawInput.agent_type : undefined,
135
+ workflow: wfStateRaw?.workflow,
136
+ step: wfStateRaw?.current_step,
137
+ decision: "block",
138
+ reason: gateResult.output.reason,
139
+ duration_ms: 0,
140
+ timestamp: new Date().toISOString(),
141
+ }, sessionId).catch(() => { });
142
+ return;
143
+ }
144
+ }
115
145
  // Special handling for Stop — needs async workflow state read + session summary
116
146
  if (event === "Stop") {
117
147
  const wfState = await readWorkflowState(projectDir, sessionId);
@@ -123,11 +153,12 @@ async function main() {
123
153
  started_at: wfState.started_at,
124
154
  completed_artifacts: wfState.completed_artifacts,
125
155
  } : null;
126
- let stopOutput = enforceStop(ir, {
156
+ let stopResult = enforceStop(ir, {
127
157
  cwd: typeof rawInput.cwd === "string" ? rawInput.cwd : undefined,
128
- session_id: sessionId,
158
+ sessionId: sessionId,
129
159
  stop_reason: typeof rawInput.stop_reason === "string" ? rawInput.stop_reason : undefined,
130
160
  }, stopContext);
161
+ let stopOutput = stopResult?.output ?? silentOutput();
131
162
  // Session summary: aggregate block/warn stats from trace
132
163
  try {
133
164
  const traces = await readTraces(projectDir, 1, sessionId);
@@ -135,16 +166,29 @@ async function main() {
135
166
  const summaryText = formatSummary(summary);
136
167
  if (summaryText) {
137
168
  if (stopOutput.suppressOutput) {
138
- // Was silent → upgrade to allow with summary
139
169
  stopOutput = allowOutput(summaryText, "Stop");
140
170
  }
141
171
  else if (stopOutput.reason) {
142
- // Was a block → append summary to reason
143
172
  stopOutput.reason += "\n\n" + summaryText;
144
173
  }
145
174
  }
146
175
  }
147
176
  catch { /* fail-open: summary failure never blocks */ }
177
+ // Phase 3: Smart orchestration guidance.
178
+ // Appended to Stop output so the next assistant turn sees explicit
179
+ // next-step instructions (Agent() call + workflow boundary).
180
+ try {
181
+ const guidance = await buildWorkflowGuidance(projectDir, wfState, sessionId);
182
+ if (guidance) {
183
+ if (stopOutput.suppressOutput) {
184
+ stopOutput = allowOutput(guidance, "Stop");
185
+ }
186
+ else if (stopOutput.reason) {
187
+ stopOutput.reason += "\n\n" + guidance;
188
+ }
189
+ }
190
+ }
191
+ catch { /* fail-open */ }
148
192
  writeOutput(stopOutput);
149
193
  // Trace for Stop
150
194
  appendTrace(projectDir, {
@@ -160,11 +204,12 @@ async function main() {
160
204
  }
161
205
  // Dispatch to enforcement engine with timing
162
206
  const start = Date.now();
163
- let output = dispatch(event, ir, rawInput, state);
207
+ let result = dispatch(event, ir, rawInput, state);
208
+ let output = result?.output ?? silentOutput();
164
209
  const durationMs = Date.now() - start;
165
- // Surgeon reflection gate (PostToolUse only, async)
210
+ // PostToolUse side effects: session read tracking + surgeon reflection gate.
211
+ // Context gate moved to PreToolUse (block mode) in Phase 2B.
166
212
  if (event === "PostToolUse") {
167
- // 1. Track session reads
168
213
  try {
169
214
  const toolName = String(rawInput.tool_name ?? "");
170
215
  if (toolName === "Read") {
@@ -174,22 +219,6 @@ async function main() {
174
219
  await appendSessionRead(projectDir, readPath, sessionId);
175
220
  }
176
221
  }
177
- // 2. Context gate (Phase 1: warn mode)
178
- const writeTools = new Set(["Edit", "Write", "Bash"]);
179
- if (writeTools.has(toolName)) {
180
- const wfStateForCtx = await readWorkflowState(projectDir, sessionId);
181
- if (wfStateForCtx?.active && ir.source_dna_ids.length > 0) {
182
- const contextGateOutput = await handleContextGate(ir, wfStateForCtx, projectDir, sessionId);
183
- if (contextGateOutput) {
184
- if (output.suppressOutput) {
185
- output = contextGateOutput;
186
- }
187
- else if (output.reason) {
188
- output = { ...output, reason: output.reason + "\n" + (contextGateOutput.reason ?? "") };
189
- }
190
- }
191
- }
192
- }
193
222
  }
194
223
  catch { /* fail-open */ }
195
224
  try {
@@ -306,16 +335,16 @@ function dispatch(event, ir, input, state) {
306
335
  message: typeof input.message === "string" ? input.message : undefined,
307
336
  });
308
337
  case "Stop":
309
- return silentOutput(); // DNA doesn't block stops
338
+ return null;
310
339
  case "SessionStart":
311
340
  return enforceSessionStart(ir, {
312
341
  cwd: typeof input.cwd === "string" ? input.cwd : undefined,
313
- session_id: typeof input.session_id === "string" ? input.session_id
342
+ sessionId: typeof input.session_id === "string" ? input.session_id
314
343
  : typeof input.sessionId === "string" ? input.sessionId : undefined,
315
344
  trigger: typeof input.trigger === "string" ? input.trigger : undefined,
316
345
  });
317
346
  default:
318
- return silentOutput();
347
+ return null;
319
348
  }
320
349
  }
321
350
  // ── IR Loading ─────────────────────────────────────────────
@@ -504,32 +533,112 @@ async function loadReflectionConfig(projectDir, _workflowName, _stepId) {
504
533
  return null;
505
534
  }
506
535
  }
507
- // ── Context Gate ─────────────────────────────────────────
536
+ // ── PreToolUse Gates (Phase 2B) ──────────────────────────
537
+ /**
538
+ * Evaluate PreToolUse gates in priority order:
539
+ * 1. Workflow Boundary — block Skill() after workflow completed
540
+ * 2. Context Gate — block Edit/Write/Bash when required context files unread
541
+ *
542
+ * Returns the first triggered block, or null if all gates pass.
543
+ * Fail-open on any exception.
544
+ */
545
+ export async function handlePreToolGates(ir, rawInput, wfState, projectDir, sessionId) {
546
+ try {
547
+ const toolName = typeof rawInput.tool_name === "string" ? rawInput.tool_name : "";
548
+ const toolInput = typeof rawInput.tool_input === "object" && rawInput.tool_input !== null
549
+ ? rawInput.tool_input : {};
550
+ // Gate 1: Workflow Boundary — Skill invoked after workflow finished.
551
+ // Interpret "completed" as a state record existing with active === false.
552
+ if (toolName === "Skill" && wfState && wfState.active === false) {
553
+ const skillName = typeof toolInput.skill === "string"
554
+ ? toolInput.skill
555
+ : typeof toolInput.name === "string" ? toolInput.name : "unknown";
556
+ const result = checkWorkflowBoundary(true, skillName);
557
+ if (result.output.continue === false) {
558
+ return { output: result.output, matched_rule: "workflow_boundary" };
559
+ }
560
+ }
561
+ // Gate 2: Context Gate — block write tools when required context files unread.
562
+ const writeTools = new Set(["Edit", "Write", "Bash"]);
563
+ if (writeTools.has(toolName) && wfState?.active && ir.context_files) {
564
+ const required = [];
565
+ if (ir.context_files.mandatory)
566
+ required.push(...ir.context_files.mandatory);
567
+ if (ir.context_files.per_role?.[wfState.current_role]) {
568
+ required.push(...ir.context_files.per_role[wfState.current_role]);
569
+ }
570
+ if (ir.context_files.per_workflow?.[wfState.workflow]) {
571
+ required.push(...ir.context_files.per_workflow[wfState.workflow]);
572
+ }
573
+ if (required.length > 0) {
574
+ const sessionReadsState = await readSessionReads(projectDir, sessionId);
575
+ const ready = checkContextReadiness(sessionReadsState.read_files, required);
576
+ if (!ready.ready) {
577
+ return {
578
+ output: blockOutput(`[Intent DNA] Context Gate: required context files not yet read — ${ready.missing.join(", ")}. Read them before attempting ${toolName}.`),
579
+ matched_rule: "context_gate",
580
+ };
581
+ }
582
+ }
583
+ }
584
+ }
585
+ catch {
586
+ // Fail-open: gate failures never block execution
587
+ }
588
+ return null;
589
+ }
590
+ // ── Stop guidance (Phase 3) ───────────────────────────────
508
591
  /**
509
- * Check if required context files have been read before allowing writes.
510
- * Phase 1: warn mode (allow + inject warning).
511
- * Returns null if all context files read or no context_files configured.
592
+ * Build next-step guidance for Stop hook.
593
+ * Returns a string to append to the Stop output's reason, or null if
594
+ * no explicit guidance applies (normal flow).
595
+ *
596
+ * Guidance cases:
597
+ * 1. Surgeon stalled — fail_count has reached the step's max_attempts
598
+ * for a step that defines handoff_to. Emit Agent() dispatch to the
599
+ * handoff role with experience chain context.
600
+ * 2. Workflow complete — wfState exists but active=false. Emit STOP
601
+ * directive so the assistant doesn't auto-start another workflow.
602
+ *
603
+ * Fail-open: any exception returns null.
512
604
  */
513
- async function handleContextGate(ir, wfState, projectDir, sessionId) {
514
- if (!ir.context_files)
605
+ export async function buildWorkflowGuidance(projectDir, wfState, sessionId) {
606
+ if (!wfState)
515
607
  return null;
516
- const required = [];
517
- if (ir.context_files.mandatory)
518
- required.push(...ir.context_files.mandatory);
519
- if (ir.context_files.per_role?.[wfState.current_role]) {
520
- required.push(...ir.context_files.per_role[wfState.current_role]);
608
+ // Case 2: workflow marked completed (inactive state record still present).
609
+ if (wfState.active === false) {
610
+ return `[DNA WORKFLOW] Workflow "${wfState.workflow}" is complete. ` +
611
+ `Output the final report and STOP. Do NOT start another workflow.`;
521
612
  }
522
- if (ir.context_files.per_workflow?.[wfState.workflow]) {
523
- required.push(...ir.context_files.per_workflow[wfState.workflow]);
613
+ // Case 1: surgeon stalled — load reflection config sidecar + current attempts.
614
+ try {
615
+ const reflectionConfig = await loadReflectionConfig(projectDir, wfState.workflow, wfState.current_step);
616
+ if (!reflectionConfig?.max_attempts)
617
+ return null;
618
+ const attempts = await readSurgeonAttempts(projectDir, sessionId);
619
+ if (attempts.fail_count < reflectionConfig.max_attempts)
620
+ return null;
621
+ const handoffTo = reflectionConfig.handoff_to ?? "handoff role";
622
+ const agentType = `dna-${toKebabCase(handoffTo)}`;
623
+ const expCount = attempts.experience_chain?.length ?? 0;
624
+ return `[DNA WORKFLOW] Surgeon stalled — ${attempts.fail_count} consecutive no-progress attempts ` +
625
+ `(limit: ${reflectionConfig.max_attempts}).\n` +
626
+ `Next step: re-analyze via handoff. Dispatch:\n` +
627
+ `Agent(\n` +
628
+ ` subagent_type="${agentType}",\n` +
629
+ ` prompt="Re-analyze the current step. Read .dna/state/workflow/experience.md ` +
630
+ `for ${expCount} previous failure notes — do NOT repeat those approaches."\n` +
631
+ `)`;
524
632
  }
525
- if (required.length === 0)
633
+ catch {
526
634
  return null;
527
- const sessionReadsState = await readSessionReads(projectDir, sessionId);
528
- const result = checkContextReadiness(sessionReadsState.read_files, required);
529
- if (!result.ready) {
530
- return allowOutput(`WARN [Intent DNA] Context Gate: Required files not yet read: ${result.missing.join(", ")}. Read them before continuing.`);
531
635
  }
532
- return null;
636
+ }
637
+ function toKebabCase(s) {
638
+ return s
639
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
640
+ .replace(/[_\s]+/g, "-")
641
+ .toLowerCase();
533
642
  }
534
643
  // ── Entry Point ────────────────────────────────────────────
535
644
  main().catch(() => {