intentdna 1.5.3 → 1.5.4

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.
@@ -9,7 +9,7 @@
9
9
  {
10
10
  "name": "intentdna",
11
11
  "description": "DNA template compilation + runtime enforcement",
12
- "version": "1.5.3",
12
+ "version": "1.5.4",
13
13
  "source": "./"
14
14
  }
15
15
  ]
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "intentdna",
3
- "version": "1.5.3",
3
+ "version": "1.5.4",
4
4
  "description": "Declarative policy layer for AI agent governance"
5
5
  }
@@ -54,4 +54,27 @@ export { getPackageVersion } from "../util/version.js";
54
54
  * Throws if two configs declare the same namespace.
55
55
  */
56
56
  export declare function checkNamespaceCollisions(configPaths: string[]): Promise<void>;
57
+ /**
58
+ * Compare two semver strings. Returns:
59
+ * 1 if a > b
60
+ * 0 if a == b
61
+ * -1 if a < b
62
+ * Handles major.minor.patch format. Non-numeric parts are ignored.
63
+ */
64
+ export declare function compareSemver(a: string, b: string): -1 | 0 | 1;
65
+ /** Info about a single template that has an available upgrade. */
66
+ export interface TemplateUpgradeInfo {
67
+ configPath: string;
68
+ templateName: string;
69
+ currentVersion: string;
70
+ availableVersion: string;
71
+ }
72
+ /**
73
+ * Check template versions against current package version.
74
+ * Returns upgrade info for outdated configs and prints human-readable suggestions.
75
+ *
76
+ * U1: Semver-aware comparison — only suggests upgrades when package is strictly newer.
77
+ * Handles missing _template_version (old configs created before versioning).
78
+ */
79
+ export declare function checkTemplateVersions(configPaths: string[]): Promise<TemplateUpgradeInfo[]>;
57
80
  export declare function runSync(opts: SyncOptions): Promise<number>;
@@ -253,28 +253,72 @@ export async function checkNamespaceCollisions(configPaths) {
253
253
  }
254
254
  }
255
255
  }
256
+ /**
257
+ * Compare two semver strings. Returns:
258
+ * 1 if a > b
259
+ * 0 if a == b
260
+ * -1 if a < b
261
+ * Handles major.minor.patch format. Non-numeric parts are ignored.
262
+ */
263
+ export function compareSemver(a, b) {
264
+ const parse = (v) => v.split(".").map(s => parseInt(s, 10) || 0);
265
+ const pa = parse(a);
266
+ const pb = parse(b);
267
+ const len = Math.max(pa.length, pb.length);
268
+ for (let i = 0; i < len; i++) {
269
+ const va = pa[i] ?? 0;
270
+ const vb = pb[i] ?? 0;
271
+ if (va > vb)
272
+ return 1;
273
+ if (va < vb)
274
+ return -1;
275
+ }
276
+ return 0;
277
+ }
256
278
  /**
257
279
  * Check template versions against current package version.
258
- * Prints upgrade suggestions for outdated configs.
280
+ * Returns upgrade info for outdated configs and prints human-readable suggestions.
281
+ *
282
+ * U1: Semver-aware comparison — only suggests upgrades when package is strictly newer.
283
+ * Handles missing _template_version (old configs created before versioning).
259
284
  */
260
- async function checkTemplateVersions(configPaths) {
285
+ export async function checkTemplateVersions(configPaths) {
261
286
  const pkgVersion = await getPackageVersion();
262
287
  if (!pkgVersion)
263
- return;
288
+ return [];
289
+ const upgrades = [];
264
290
  for (const configPath of configPaths) {
265
291
  try {
266
292
  const content = await readFile(configPath, "utf-8");
267
293
  const data = parseYAML(content);
268
294
  const tmplVersion = typeof data._template_version === "string" ? data._template_version : undefined;
269
295
  const tmplName = typeof data._source_template === "string" ? data._source_template : undefined;
270
- if (tmplVersion && tmplName && tmplVersion !== pkgVersion) {
271
- process.stderr.write(`Template "${tmplName}" has update: ${tmplVersion} ${pkgVersion}. Run: dna init --upgrade ${tmplName}\n`);
296
+ if (tmplName && !tmplVersion) {
297
+ // Old config without version tracking suggest upgrade
298
+ upgrades.push({
299
+ configPath,
300
+ templateName: tmplName,
301
+ currentVersion: "unknown",
302
+ availableVersion: pkgVersion,
303
+ });
304
+ process.stderr.write(`Upgrade available: "${tmplName}" has no version tag. Run: dna init --upgrade ${tmplName}\n`);
305
+ }
306
+ else if (tmplVersion && tmplName && compareSemver(pkgVersion, tmplVersion) > 0) {
307
+ // Package is strictly newer than template — suggest upgrade
308
+ upgrades.push({
309
+ configPath,
310
+ templateName: tmplName,
311
+ currentVersion: tmplVersion,
312
+ availableVersion: pkgVersion,
313
+ });
314
+ process.stderr.write(`Upgrade available: "${tmplName}" ${tmplVersion} → ${pkgVersion}. Run: dna init --upgrade ${tmplName}\n`);
272
315
  }
273
316
  }
274
317
  catch {
275
318
  // Non-critical — skip version check for unparseable files
276
319
  }
277
320
  }
321
+ return upgrades;
278
322
  }
279
323
  function resolveSpeciesPath(ref) {
280
324
  if (!ref.startsWith("species:"))
@@ -12,6 +12,7 @@
12
12
  export interface VerifyOptions {
13
13
  lockFile: string;
14
14
  stats?: boolean;
15
+ health?: boolean;
15
16
  }
16
17
  export interface LockFileEntry {
17
18
  sha256: string;
@@ -31,3 +32,13 @@ export declare function sha256(content: string): string;
31
32
  export declare function parseLockFile(raw: string): LockFile;
32
33
  export declare function verifyLock(lock: LockFile): Promise<VerifyResult[]>;
33
34
  export declare function runVerify(opts: VerifyOptions): Promise<number>;
35
+ export interface HealthCheckResult {
36
+ name: string;
37
+ status: "pass" | "warn" | "fail";
38
+ detail: string;
39
+ }
40
+ /**
41
+ * U3: Comprehensive health report for DNA installation.
42
+ * Checks IR, configs, templates, hooks, and trace system.
43
+ */
44
+ export declare function runHealth(): Promise<number>;
@@ -54,6 +54,10 @@ export async function verifyLock(lock) {
54
54
  }
55
55
  // ── CLI entry ──────────────────────────────────────────────
56
56
  export async function runVerify(opts) {
57
+ // Handle --health mode
58
+ if (opts.health) {
59
+ return runHealth();
60
+ }
57
61
  // Handle --stats mode
58
62
  if (opts.stats) {
59
63
  return runStats();
@@ -214,3 +218,189 @@ function truncate(s, max) {
214
218
  return s;
215
219
  return s.slice(0, max - 3) + "...";
216
220
  }
221
+ /**
222
+ * U3: Comprehensive health report for DNA installation.
223
+ * Checks IR, configs, templates, hooks, and trace system.
224
+ */
225
+ export async function runHealth() {
226
+ const cwd = process.cwd();
227
+ const checks = [];
228
+ const { resolve } = await import("node:path");
229
+ const { stat, readFile: rf, readdir } = await import("node:fs/promises");
230
+ // 1. Check compiled IR
231
+ const irPath = resolve(cwd, ".dna", "compiled", "ir.json");
232
+ try {
233
+ const raw = await rf(irPath, "utf-8");
234
+ const data = JSON.parse(raw);
235
+ if (data.ir_version && data.ir) {
236
+ const age = Date.now() - new Date(data.compiled_at ?? data.ir.compiled_at).getTime();
237
+ const ageHours = Math.round(age / 3600000);
238
+ checks.push({
239
+ name: "Compiled IR",
240
+ status: "pass",
241
+ detail: `v${data.ir_version}, compiled ${ageHours}h ago, ${data.ir.source_dna_ids?.length ?? 0} template(s)`,
242
+ });
243
+ }
244
+ else if (data.compiled_at) {
245
+ checks.push({ name: "Compiled IR", status: "warn", detail: "Legacy format — run `dna sync` to upgrade" });
246
+ }
247
+ else {
248
+ checks.push({ name: "Compiled IR", status: "fail", detail: "Unrecognized format" });
249
+ }
250
+ }
251
+ catch {
252
+ checks.push({ name: "Compiled IR", status: "fail", detail: "Not found — run `dna sync`" });
253
+ }
254
+ // 2. Check DNA config files
255
+ const { autoDetectConfigs } = await import("./sync.js");
256
+ const configs = await autoDetectConfigs();
257
+ if (configs.length > 0) {
258
+ checks.push({ name: "DNA configs", status: "pass", detail: `${configs.length} config(s) found` });
259
+ }
260
+ else {
261
+ checks.push({ name: "DNA configs", status: "fail", detail: "No config files found — run `dna init --template <name>`" });
262
+ }
263
+ // 3. Check config validity
264
+ if (configs.length > 0) {
265
+ const { loadDNA } = await import("../../compiler/index.js");
266
+ let valid = 0;
267
+ let invalid = 0;
268
+ const errors = [];
269
+ for (const configPath of configs) {
270
+ try {
271
+ await loadDNA(configPath);
272
+ valid++;
273
+ }
274
+ catch (err) {
275
+ invalid++;
276
+ const { basename } = await import("node:path");
277
+ errors.push(`${basename(configPath)}: ${err instanceof Error ? err.message : String(err)}`);
278
+ }
279
+ }
280
+ if (invalid === 0) {
281
+ checks.push({ name: "Config validity", status: "pass", detail: `${valid} config(s) valid` });
282
+ }
283
+ else {
284
+ checks.push({ name: "Config validity", status: "fail", detail: errors.join("; ") });
285
+ }
286
+ }
287
+ // 4. Check template versions
288
+ if (configs.length > 0) {
289
+ const { checkTemplateVersions: checkVersions } = await import("./sync.js");
290
+ // Suppress stderr output from checkTemplateVersions — we'll report ourselves
291
+ const origWrite = process.stderr.write;
292
+ process.stderr.write = (() => true);
293
+ try {
294
+ const upgrades = await checkVersions(configs);
295
+ if (upgrades.length === 0) {
296
+ checks.push({ name: "Template versions", status: "pass", detail: "All up to date" });
297
+ }
298
+ else {
299
+ const names = upgrades.map(u => `${u.templateName} (${u.currentVersion} → ${u.availableVersion})`);
300
+ checks.push({ name: "Template versions", status: "warn", detail: `Upgrade available: ${names.join(", ")}` });
301
+ }
302
+ }
303
+ catch {
304
+ checks.push({ name: "Template versions", status: "warn", detail: "Could not check versions" });
305
+ }
306
+ finally {
307
+ process.stderr.write = origWrite;
308
+ }
309
+ }
310
+ // 5. Check .claude settings or plugin registration
311
+ const settingsPath = resolve(cwd, ".claude", "settings.json");
312
+ try {
313
+ const raw = await rf(settingsPath, "utf-8");
314
+ const settings = JSON.parse(raw);
315
+ const hooks = settings.hooks;
316
+ const hasHooks = hooks && typeof hooks === "object" && Object.keys(hooks).length > 0;
317
+ if (hasHooks) {
318
+ const eventCount = Object.keys(hooks).length;
319
+ checks.push({ name: "Hook registration", status: "pass", detail: `${eventCount} event(s) in settings.json` });
320
+ }
321
+ else {
322
+ checks.push({ name: "Hook registration", status: "warn", detail: "No hooks in settings.json — using plugin mode?" });
323
+ }
324
+ }
325
+ catch {
326
+ // Check for plugin registration instead
327
+ const mcpPath = resolve(cwd, ".claude", ".mcp.json");
328
+ try {
329
+ const raw = await rf(mcpPath, "utf-8");
330
+ const mcp = JSON.parse(raw);
331
+ if (mcp.mcpServers?.intentdna) {
332
+ checks.push({ name: "MCP server", status: "pass", detail: "dna-mcp registered in .mcp.json" });
333
+ }
334
+ else {
335
+ checks.push({ name: "Hook registration", status: "warn", detail: "No hooks or MCP server found" });
336
+ }
337
+ }
338
+ catch {
339
+ checks.push({ name: "Hook registration", status: "warn", detail: "No settings.json or .mcp.json — run `dna setup` or `dna sync`" });
340
+ }
341
+ }
342
+ // 6. Check trace system
343
+ const traceDir = resolve(cwd, ".dna", "state", "trace");
344
+ try {
345
+ const files = await readdir(traceDir);
346
+ const traceFiles = files.filter(f => f.startsWith("trace-") && f.endsWith(".jsonl"));
347
+ if (traceFiles.length > 0) {
348
+ // Read latest trace file size
349
+ const latest = traceFiles.sort().pop();
350
+ const latestStat = await stat(resolve(traceDir, latest));
351
+ const sizeKB = Math.round(latestStat.size / 1024);
352
+ checks.push({ name: "Trace system", status: "pass", detail: `${traceFiles.length} file(s), latest ${sizeKB}KB` });
353
+ }
354
+ else {
355
+ checks.push({ name: "Trace system", status: "warn", detail: "No trace files — hooks not fired yet" });
356
+ }
357
+ }
358
+ catch {
359
+ checks.push({ name: "Trace system", status: "warn", detail: "No trace directory — will be created on first hook call" });
360
+ }
361
+ // 7. Check lock file drift
362
+ const lockPath = resolve(cwd, ".dna", "lock");
363
+ try {
364
+ const raw = await rf(lockPath, "utf-8");
365
+ const lock = parseLockFile(raw);
366
+ const results = await verifyLock(lock);
367
+ const drifted = results.filter(r => r.status !== "match");
368
+ if (drifted.length === 0) {
369
+ checks.push({ name: "Output drift", status: "pass", detail: `${results.length} file(s) match lock` });
370
+ }
371
+ else {
372
+ checks.push({ name: "Output drift", status: "warn", detail: `${drifted.length}/${results.length} file(s) drifted — run \`dna sync\`` });
373
+ }
374
+ }
375
+ catch {
376
+ checks.push({ name: "Output drift", status: "warn", detail: "No lock file — run `dna sync` to generate" });
377
+ }
378
+ // ── Format output ──────────────────────────────────────
379
+ const ICONS = { pass: "ok", warn: "!!", fail: "XX" };
380
+ process.stderr.write("\nIntent DNA Health Report\n");
381
+ process.stderr.write("========================\n\n");
382
+ let hasFailure = false;
383
+ let hasWarning = false;
384
+ for (const check of checks) {
385
+ const icon = ICONS[check.status];
386
+ const label = check.name.padEnd(20);
387
+ process.stderr.write(` [${icon}] ${label} ${check.detail}\n`);
388
+ if (check.status === "fail")
389
+ hasFailure = true;
390
+ if (check.status === "warn")
391
+ hasWarning = true;
392
+ }
393
+ process.stderr.write("\n");
394
+ if (hasFailure) {
395
+ process.stderr.write("Status: issues found — see [XX] items above\n");
396
+ return 1;
397
+ }
398
+ else if (hasWarning) {
399
+ process.stderr.write("Status: healthy with warnings — see [!!] items above\n");
400
+ return 0;
401
+ }
402
+ else {
403
+ process.stderr.write("Status: all checks passed\n");
404
+ return 0;
405
+ }
406
+ }
package/dist/cli/index.js CHANGED
@@ -58,6 +58,7 @@ Examples:
58
58
  dna verify Verify synced files against .dna/lock
59
59
  dna verify --lock /path/to/.dna/lock
60
60
  dna verify --stats Show hook call statistics (last 24h)
61
+ dna verify --health Comprehensive health check of DNA installation
61
62
  dna import . Import existing harness configs into DNA format
62
63
  dna run --dna my.dna.json --workflow dev-pipeline --task P5.8
63
64
  dna run --dna my.dna.json --workflow dev-pipeline --task P5.8 --dry-run
@@ -126,6 +127,7 @@ async function main() {
126
127
  options: {
127
128
  lock: { type: "string", default: ".dna/lock" },
128
129
  stats: { type: "boolean", default: false },
130
+ health: { type: "boolean", default: false },
129
131
  },
130
132
  allowPositionals: true,
131
133
  strict: false,
@@ -134,6 +136,7 @@ async function main() {
134
136
  const code = await runVerify({
135
137
  lockFile: verifyValues.lock,
136
138
  stats: verifyValues.stats,
139
+ health: verifyValues.health,
137
140
  });
138
141
  process.exit(code);
139
142
  break;
package/dist/hooks/cli.js CHANGED
@@ -278,7 +278,8 @@ async function loadIR(irPath) {
278
278
  // Support both raw IR and wrapped CompiledIRFile format
279
279
  if (data.ir_version && data.ir) {
280
280
  if (data.ir_version !== EXPECTED_IR_VERSION) {
281
- process.stderr.write(`[Intent DNA] IR version mismatch: file has v${data.ir_version}, expected v${EXPECTED_IR_VERSION}. Run \`dna sync\` to recompile.\n`);
281
+ process.stderr.write(`\n Intent DNA: IR version mismatch (v${data.ir_version} on disk, v${EXPECTED_IR_VERSION} expected)\n` +
282
+ ` Fix: run \`dna sync\` to recompile\n\n`);
282
283
  }
283
284
  return data.ir;
284
285
  }
@@ -286,9 +287,21 @@ async function loadIR(irPath) {
286
287
  if (data.compiled_at && data.source_dna_ids) {
287
288
  return data;
288
289
  }
290
+ process.stderr.write(`\n Intent DNA: unrecognized IR format at ${irPath}\n` +
291
+ ` Fix: run \`dna sync\` to regenerate\n\n`);
289
292
  return null;
290
293
  }
291
- catch {
294
+ catch (err) {
295
+ // U3: Human-readable error instead of silent failure
296
+ const isNotFound = err instanceof Error && "code" in err && err.code === "ENOENT";
297
+ if (isNotFound) {
298
+ process.stderr.write(`\n Intent DNA: no compiled IR found at ${irPath}\n` +
299
+ ` Fix: run \`dna sync\` to compile your DNA config\n\n`);
300
+ }
301
+ else {
302
+ process.stderr.write(`\n Intent DNA: failed to load IR — ${err instanceof Error ? err.message : String(err)}\n` +
303
+ ` Fix: run \`dna sync\` to recompile\n\n`);
304
+ }
292
305
  return null;
293
306
  }
294
307
  }
package/dist/mcp/index.js CHANGED
@@ -13,6 +13,7 @@
13
13
  import { createMCPServer } from "./server.js";
14
14
  import { createStateTools } from "./tools-state.js";
15
15
  import { createCompileTools } from "./tools-compile.js";
16
+ import { createEnforceTools } from "./tools-enforce.js";
16
17
  // Parse args
17
18
  const args = process.argv.slice(2);
18
19
  let projectDir = process.cwd();
@@ -34,5 +35,6 @@ catch { /* use default */ }
34
35
  const tools = [
35
36
  ...createStateTools(projectDir),
36
37
  ...createCompileTools(projectDir),
38
+ ...createEnforceTools(projectDir),
37
39
  ];
38
40
  createMCPServer({ name: "intentdna", version }, tools);
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Intent DNA — MCP Enforce Tools (U2)
3
+ *
4
+ * Provides enforcement via MCP server with in-memory IR cache.
5
+ * Zero cold-start: IR is loaded once and cached with mtime-based invalidation.
6
+ *
7
+ * Tools:
8
+ * - dna_enforce: Run enforcement for a hook event (PreToolUse, PostToolUse, etc.)
9
+ */
10
+ import type { ConstraintIR, RoleDef } from "../schema/types.js";
11
+ import type { ToolDef } from "./server.js";
12
+ /** In-memory IR cache with mtime-based invalidation. */
13
+ export declare class IRCache {
14
+ private ir;
15
+ private roles;
16
+ private lastMtime;
17
+ private irPath;
18
+ constructor(projectDir: string);
19
+ /** Get cached IR, reloading from disk only if file changed. */
20
+ get(): Promise<{
21
+ ir: ConstraintIR;
22
+ roles?: Record<string, RoleDef>;
23
+ } | null>;
24
+ /** Invalidate the cache (e.g., after dna sync). */
25
+ invalidate(): void;
26
+ }
27
+ export declare function createEnforceTools(projectDir: string): ToolDef[];
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Intent DNA — MCP Enforce Tools (U2)
3
+ *
4
+ * Provides enforcement via MCP server with in-memory IR cache.
5
+ * Zero cold-start: IR is loaded once and cached with mtime-based invalidation.
6
+ *
7
+ * Tools:
8
+ * - dna_enforce: Run enforcement for a hook event (PreToolUse, PostToolUse, etc.)
9
+ */
10
+ import { resolve } from "node:path";
11
+ import { stat, readFile } from "node:fs/promises";
12
+ import { enforcePreToolUse, enforcePostToolUse, enforceUserPromptSubmit, enforceSubagentStop, enforcePreCompact, enforceNotification, enforceSessionStart, } from "../hooks/enforce.js";
13
+ import { textResult, errorResult } from "./server.js";
14
+ // ── IR Cache ────────────────────────────────────────────────
15
+ /** In-memory IR cache with mtime-based invalidation. */
16
+ export class IRCache {
17
+ ir = null;
18
+ roles = undefined;
19
+ lastMtime = 0;
20
+ irPath;
21
+ constructor(projectDir) {
22
+ this.irPath = resolve(projectDir, ".dna", "compiled", "ir.json");
23
+ }
24
+ /** Get cached IR, reloading from disk only if file changed. */
25
+ async get() {
26
+ try {
27
+ const fileStat = await stat(this.irPath);
28
+ const mtime = fileStat.mtimeMs;
29
+ const cachedIR = this.ir;
30
+ if (cachedIR && mtime === this.lastMtime) {
31
+ return { ir: cachedIR, roles: this.roles };
32
+ }
33
+ // File changed or first load — read from disk
34
+ const raw = await readFile(this.irPath, "utf-8");
35
+ const data = JSON.parse(raw);
36
+ if (data.ir_version && data.ir) {
37
+ const ir = data.ir;
38
+ this.ir = ir;
39
+ this.roles = data.roles;
40
+ this.lastMtime = mtime;
41
+ return { ir, roles: this.roles };
42
+ }
43
+ // Direct ConstraintIR format (backward compat)
44
+ if (data.compiled_at && data.source_dna_ids) {
45
+ const ir = data;
46
+ this.ir = ir;
47
+ this.roles = undefined;
48
+ this.lastMtime = mtime;
49
+ return { ir };
50
+ }
51
+ return null;
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ /** Invalidate the cache (e.g., after dna sync). */
58
+ invalidate() {
59
+ this.ir = null;
60
+ this.roles = undefined;
61
+ this.lastMtime = 0;
62
+ }
63
+ }
64
+ const VALID_EVENTS = new Set([
65
+ "PreToolUse", "PostToolUse", "UserPromptSubmit",
66
+ "SubagentStop", "PreCompact", "Notification", "SessionStart",
67
+ ]);
68
+ function dispatchEnforce(event, ir, input, roles) {
69
+ switch (event) {
70
+ case "PreToolUse":
71
+ return enforcePreToolUse(ir, {
72
+ tool_name: String(input.tool_name ?? ""),
73
+ tool_input: (input.tool_input ?? {}),
74
+ agent_type: typeof input.agent_type === "string" ? input.agent_type : undefined,
75
+ cwd: typeof input.cwd === "string" ? input.cwd : undefined,
76
+ }, undefined, roles);
77
+ case "PostToolUse":
78
+ return enforcePostToolUse(ir, {
79
+ tool_name: String(input.tool_name ?? ""),
80
+ tool_input: (input.tool_input ?? {}),
81
+ tool_output: typeof input.tool_output === "string" ? input.tool_output : undefined,
82
+ agent_type: typeof input.agent_type === "string" ? input.agent_type : undefined,
83
+ });
84
+ case "UserPromptSubmit":
85
+ return enforceUserPromptSubmit(ir, {
86
+ prompt: typeof input.prompt === "string" ? input.prompt : undefined,
87
+ });
88
+ case "SubagentStop":
89
+ return enforceSubagentStop(ir, {
90
+ agent_name: typeof input.agent_name === "string" ? input.agent_name : undefined,
91
+ agent_type: typeof input.agent_type === "string" ? input.agent_type : undefined,
92
+ });
93
+ case "PreCompact":
94
+ return enforcePreCompact(ir);
95
+ case "Notification":
96
+ return enforceNotification(ir, {
97
+ title: typeof input.title === "string" ? input.title : undefined,
98
+ message: typeof input.message === "string" ? input.message : undefined,
99
+ });
100
+ case "SessionStart":
101
+ return enforceSessionStart(ir, {
102
+ cwd: typeof input.cwd === "string" ? input.cwd : undefined,
103
+ session_id: typeof input.session_id === "string" ? input.session_id : undefined,
104
+ });
105
+ default:
106
+ return { continue: true, suppressOutput: true };
107
+ }
108
+ }
109
+ // ── Tool Registration ───────────────────────────────────────
110
+ export function createEnforceTools(projectDir) {
111
+ const cache = new IRCache(projectDir);
112
+ return [
113
+ {
114
+ name: "dna_enforce",
115
+ description: "Run DNA enforcement for a hook event. Uses in-memory cached IR for zero cold-start. " +
116
+ "Returns the same result as the dna-hook binary but without spawning a new process.",
117
+ inputSchema: {
118
+ type: "object",
119
+ properties: {
120
+ event: {
121
+ type: "string",
122
+ description: "Hook event type",
123
+ enum: [...VALID_EVENTS],
124
+ },
125
+ input: {
126
+ type: "object",
127
+ description: "Hook input (same fields as Claude Code passes to hooks via stdin)",
128
+ },
129
+ },
130
+ required: ["event", "input"],
131
+ },
132
+ handler: async (args) => {
133
+ const event = String(args.event);
134
+ if (!VALID_EVENTS.has(event)) {
135
+ return errorResult(`Invalid event: ${event}. Valid: ${[...VALID_EVENTS].join(", ")}`);
136
+ }
137
+ const cached = await cache.get();
138
+ if (!cached) {
139
+ return errorResult("No compiled IR found. Run `dna sync` to compile your DNA config.");
140
+ }
141
+ const input = (args.input ?? {});
142
+ const result = dispatchEnforce(event, cached.ir, input, cached.roles);
143
+ // Format as human-readable text
144
+ const lines = [];
145
+ lines.push(`Decision: ${result.continue ? "allow" : "BLOCK"}`);
146
+ if (result.reason)
147
+ lines.push(`Reason: ${result.reason}`);
148
+ if (result.hookSpecificOutput?.additionalContext) {
149
+ lines.push(`Context: ${result.hookSpecificOutput.additionalContext}`);
150
+ }
151
+ return textResult(lines.join("\n"));
152
+ },
153
+ },
154
+ {
155
+ name: "dna_enforce_cache_status",
156
+ description: "Check IR cache status — whether IR is loaded, file path, and last load time",
157
+ inputSchema: { type: "object", properties: {} },
158
+ handler: async () => {
159
+ const cached = await cache.get();
160
+ if (!cached) {
161
+ return textResult("IR cache: empty (no compiled IR found at .dna/compiled/ir.json)");
162
+ }
163
+ const lines = [
164
+ "IR cache: loaded",
165
+ ` Templates: ${cached.ir.source_dna_ids.join(", ")}`,
166
+ ` Compiled: ${cached.ir.compiled_at}`,
167
+ ` Directives: ${cached.ir.prompt_directives.length}`,
168
+ ` Gates: ${cached.ir.pre_execution_gates.length}`,
169
+ ` Filters: ${cached.ir.tool_filters.length}`,
170
+ ];
171
+ if (cached.roles) {
172
+ lines.push(` Roles: ${Object.keys(cached.roles).join(", ")}`);
173
+ }
174
+ return textResult(lines.join("\n"));
175
+ },
176
+ },
177
+ ];
178
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "intentdna",
3
- "version": "1.5.3",
3
+ "version": "1.5.4",
4
4
  "description": "Intent DNA — Declarative policy layer for AI agent behavior",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -172,6 +172,6 @@ hook 报错时输出人类可读的提示而非 JSON。`dna verify` 输出友好
172
172
  - [x] 远程通信架构预留(接口定义,不实现)
173
173
 
174
174
  ### 第三阶段
175
- - [ ] dna sync 自动检测模板版本 + 提示升级
176
- - [ ] MCP server 常驻进程解决冷启动
177
- - [ ] 错误信息人类可读
175
+ - [x] dna sync 自动检测模板版本 + 提示升级
176
+ - [x] MCP server 常驻进程解决冷启动
177
+ - [x] 错误信息人类可读