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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/dist/cli/commands/sync.d.ts +23 -0
- package/dist/cli/commands/sync.js +49 -5
- package/dist/cli/commands/verify.d.ts +11 -0
- package/dist/cli/commands/verify.js +190 -0
- package/dist/cli/index.js +3 -0
- package/dist/hooks/cli.js +15 -2
- package/dist/mcp/index.js +2 -0
- package/dist/mcp/tools-enforce.d.ts +27 -0
- package/dist/mcp/tools-enforce.js +178 -0
- package/package.json +1 -1
- package/spec/foundation-hardening.md +3 -3
|
@@ -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
|
-
*
|
|
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 (
|
|
271
|
-
|
|
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(
|
|
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
|
@@ -172,6 +172,6 @@ hook 报错时输出人类可读的提示而非 JSON。`dna verify` 输出友好
|
|
|
172
172
|
- [x] 远程通信架构预留(接口定义,不实现)
|
|
173
173
|
|
|
174
174
|
### 第三阶段
|
|
175
|
-
- [
|
|
176
|
-
- [
|
|
177
|
-
- [
|
|
175
|
+
- [x] dna sync 自动检测模板版本 + 提示升级
|
|
176
|
+
- [x] MCP server 常驻进程解决冷启动
|
|
177
|
+
- [x] 错误信息人类可读
|