intentdna 1.4.8 → 1.5.1

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.4.7",
12
+ "version": "1.4.9",
13
13
  "source": "./"
14
14
  }
15
15
  ]
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "intentdna",
3
- "version": "1.4.7",
3
+ "version": "1.4.9",
4
4
  "description": "Declarative policy layer for AI agent governance"
5
5
  }
@@ -7,10 +7,12 @@ import { writeFile, readFile, readdir, mkdir, copyFile, access } from "node:fs/p
7
7
  import { resolve, dirname, basename } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { parseYAML } from "../../schema/yaml-parser.js";
10
+ import { getPackageVersion, sanitizeTemplateName } from "../util/version.js";
10
11
  import { validateDNA } from "../../schema/validate.js";
11
12
  import { askString, askChoice, askNumber, askYesNo, closePrompt } from "../util/prompt.js";
12
13
  const TEMPLATES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "templates");
13
14
  const PROJECT_TEMPLATES_DIR = ".dna/templates";
15
+ const MULTI_CONFIG_DIR = ".dna/configs";
14
16
  async function dirExists(path) {
15
17
  try {
16
18
  await access(path);
@@ -63,18 +65,40 @@ async function listTemplates() {
63
65
  return results;
64
66
  }
65
67
  /**
66
- * Inject _source_template field after the `type:` line in YAML content.
68
+ * Inject _source_template and _template_version fields after the namespace/type line in YAML content.
67
69
  */
68
- function injectSourceTemplate(content, templateName) {
69
- // Insert after the last top-level field before genes/contexts/roles/etc
70
+ function injectSourceTemplate(content, templateName, version) {
70
71
  const marker = /^(namespace:\s*.+)$/m;
71
72
  const typeMarker = /^(type:\s*.+)$/m;
72
73
  const match = content.match(marker) || content.match(typeMarker);
74
+ const versionLine = version ? `\n_template_version: "${version}"` : "";
73
75
  if (match) {
74
- return content.replace(match[0], `${match[0]}\n_source_template: ${templateName}`);
76
+ return content.replace(match[0], `${match[0]}\n_source_template: ${templateName}${versionLine}`);
77
+ }
78
+ return `_source_template: ${templateName}${versionLine}\n${content}`;
79
+ }
80
+ /**
81
+ * Check namespace collision against existing configs in .dna/configs/.
82
+ */
83
+ async function checkNamespaceAgainstExisting(namespace, templateName) {
84
+ if (!namespace)
85
+ return;
86
+ try {
87
+ const files = await readdir(MULTI_CONFIG_DIR);
88
+ for (const f of files.filter(f => f.endsWith(".yaml") || f.endsWith(".yml"))) {
89
+ const content = await readFile(resolve(MULTI_CONFIG_DIR, f), "utf-8");
90
+ const data = parseYAML(content);
91
+ const existingNs = typeof data.namespace === "string" ? data.namespace : undefined;
92
+ if (existingNs === namespace && f !== `${templateName}.yaml`) {
93
+ throw new Error(`Namespace "${namespace}" conflict: already used by ${f}. Each template must have a unique namespace.`);
94
+ }
95
+ }
96
+ }
97
+ catch (err) {
98
+ if (err instanceof Error && err.message.includes("conflict"))
99
+ throw err;
100
+ // Dir doesn't exist yet — no collision possible
75
101
  }
76
- // Fallback: prepend
77
- return `_source_template: ${templateName}\n${content}`;
78
102
  }
79
103
  /**
80
104
  * Infer template name from a DNA config's id field.
@@ -86,18 +110,31 @@ function inferTemplateName(id) {
86
110
  return id.slice("template_".length).replace(/_/g, "-");
87
111
  }
88
112
  /**
89
- * Auto-detect the DNA config file path.
113
+ * Auto-detect DNA config file(s) for upgrade.
114
+ * Priority: .dna/configs/*.yaml (multi-config) > legacy single-config
90
115
  */
91
- async function autoDetectConfig() {
116
+ async function autoDetectConfigsForUpgrade() {
117
+ // Priority 1: .dna/configs/*.yaml (multi-config)
118
+ try {
119
+ const files = await readdir(MULTI_CONFIG_DIR);
120
+ const yamls = files
121
+ .filter(f => f.endsWith(".yaml") || f.endsWith(".yml"))
122
+ .sort()
123
+ .map(f => resolve(MULTI_CONFIG_DIR, f));
124
+ if (yamls.length > 0)
125
+ return yamls;
126
+ }
127
+ catch { /* dir doesn't exist */ }
128
+ // Fallback: legacy single-config paths
92
129
  const candidates = [
93
130
  ".dna/config.yaml", ".dna/config.yml", ".dna/config.json",
94
131
  ".dna.yaml", ".dna.yml", ".dna.json",
95
132
  ];
96
133
  for (const name of candidates) {
97
134
  if (await dirExists(name))
98
- return name;
135
+ return [name];
99
136
  }
100
- return null;
137
+ return [];
101
138
  }
102
139
  /**
103
140
  * Upgrade config from a newer template version while preserving user-filled variables.
@@ -127,8 +164,9 @@ async function upgradeConfig(configPath, templateName) {
127
164
  const srcDir = found.source === "project" ? PROJECT_TEMPLATES_DIR : TEMPLATES_DIR;
128
165
  const srcPath = resolve(srcDir, `${found.name}.dna.yaml`);
129
166
  let newContent = await readFile(srcPath, "utf-8");
130
- // 4. Inject _source_template
131
- newContent = injectSourceTemplate(newContent, found.name);
167
+ // 4. Inject _source_template and _template_version
168
+ const pkgVersion = await getPackageVersion();
169
+ newContent = injectSourceTemplate(newContent, found.name, pkgVersion ?? undefined);
132
170
  // 5. Replace VariableDef entries with user values where they exist
133
171
  for (const [key, value] of Object.entries(userVars)) {
134
172
  // Match multi-line VariableDef: "key:\n description: ...\n default: ..."
@@ -148,27 +186,52 @@ async function upgradeConfig(configPath, templateName) {
148
186
  return 0;
149
187
  }
150
188
  export async function runInit(opts) {
151
- // Upgrade mode: update template while preserving user variables
189
+ // Upgrade mode: update template(s) while preserving user variables
152
190
  if (opts.upgrade !== undefined) {
153
- const configPath = await autoDetectConfig();
154
- if (!configPath) {
191
+ const configPaths = await autoDetectConfigsForUpgrade();
192
+ if (configPaths.length === 0) {
155
193
  process.stderr.write("No DNA config found. Run `dna init --template <name>` first.\n");
156
194
  return 2;
157
195
  }
158
- let templateName = typeof opts.upgrade === "string" ? opts.upgrade : undefined;
159
- // Auto-infer template name if not specified
160
- if (!templateName) {
196
+ // If --upgrade <name> (non-empty string), find the specific config
197
+ if (typeof opts.upgrade === "string" && opts.upgrade.length > 0) {
198
+ // Sequential scan to find config by _source_template
199
+ let targetPath;
200
+ for (const p of configPaths) {
201
+ const content = await readFile(p, "utf-8");
202
+ const data = parseYAML(content);
203
+ if (data._source_template === opts.upgrade) {
204
+ targetPath = p;
205
+ break;
206
+ }
207
+ }
208
+ if (!targetPath) {
209
+ // Try direct template name match on filename
210
+ targetPath = configPaths.find(p => basename(p).replace(/\.(yaml|yml)$/, "") === opts.upgrade);
211
+ }
212
+ if (!targetPath) {
213
+ process.stderr.write(`Config for template "${opts.upgrade}" not found in: ${configPaths.map(p => basename(p)).join(", ")}\n`);
214
+ return 2;
215
+ }
216
+ return upgradeConfig(targetPath, opts.upgrade);
217
+ }
218
+ // --upgrade (no arg): upgrade all configs
219
+ let exitCode = 0;
220
+ for (const configPath of configPaths) {
161
221
  const content = await readFile(configPath, "utf-8");
162
222
  const data = parseYAML(content);
163
- templateName = typeof data._source_template === "string"
223
+ let templateName = typeof data._source_template === "string"
164
224
  ? data._source_template
165
225
  : inferTemplateName(typeof data.id === "string" ? data.id : "") ?? undefined;
226
+ if (!templateName) {
227
+ process.stderr.write(`Cannot infer template for ${basename(configPath)}. Skipping.\n`);
228
+ continue;
229
+ }
230
+ const code = await upgradeConfig(configPath, templateName);
231
+ if (code !== 0)
232
+ exitCode = code;
166
233
  }
167
- if (!templateName) {
168
- process.stderr.write("Cannot infer template name. Specify it: dna init --upgrade <template-name>\n");
169
- return 2;
170
- }
171
- return upgradeConfig(configPath, templateName);
234
+ return exitCode;
172
235
  }
173
236
  // Register mode: copy a template into .dna/templates/ with namespace uniqueness check
174
237
  if (opts.register) {
@@ -226,10 +289,17 @@ export async function runInit(opts) {
226
289
  const srcDir = found.source === "project" ? PROJECT_TEMPLATES_DIR : TEMPLATES_DIR;
227
290
  const srcPath = resolve(srcDir, `${found.name}.dna.yaml`);
228
291
  const content = await readFile(srcPath, "utf-8");
229
- // Inject _source_template metadata for upgrade tracking
230
- const contentWithSource = injectSourceTemplate(content, found.name);
231
- const outPath = opts.output || ".dna/config.yaml";
232
- // Ensure .dna/ directory exists
292
+ // Inject _source_template and _template_version metadata for upgrade tracking
293
+ const pkgVersion = await getPackageVersion();
294
+ const contentWithSource = injectSourceTemplate(content, found.name, pkgVersion ?? undefined);
295
+ // Check namespace collision against existing configs
296
+ const srcData = parseYAML(content);
297
+ const srcNs = typeof srcData.namespace === "string" ? srcData.namespace : undefined;
298
+ await checkNamespaceAgainstExisting(srcNs, found.name);
299
+ // Default output: .dna/configs/<template-name>.yaml (multi-config)
300
+ const safeName = sanitizeTemplateName(found.name);
301
+ const outPath = opts.output || resolve(MULTI_CONFIG_DIR, `${safeName}.yaml`);
302
+ // Ensure output directory exists
233
303
  const outDir = resolve(outPath, "..");
234
304
  await mkdir(outDir, { recursive: true });
235
305
  await writeFile(outPath, contentWithSource, "utf-8");
@@ -40,4 +40,18 @@ 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
+ * Auto-detect DNA configs: multi-config (.dna/configs/*.yaml) or legacy single-config.
45
+ * Returns array of absolute paths.
46
+ */
47
+ export declare function autoDetectConfigs(): Promise<string[]>;
48
+ /**
49
+ * Read the current package version from package.json.
50
+ */
51
+ export { getPackageVersion } from "../util/version.js";
52
+ /**
53
+ * Check for namespace collisions across multiple config files.
54
+ * Throws if two configs declare the same namespace.
55
+ */
56
+ export declare function checkNamespaceCollisions(configPaths: string[]): Promise<void>;
43
57
  export declare function runSync(opts: SyncOptions): Promise<number>;
@@ -10,12 +10,14 @@
10
10
  *
11
11
  * Targets: claude-md, soul-md, cursorrules, system-prompt
12
12
  */
13
- import { resolve, dirname } from "node:path";
13
+ import { resolve, dirname, basename } from "node:path";
14
14
  import { readFile, writeFile as writeFileAsync, stat, mkdir, readdir, unlink, rm } from "node:fs/promises";
15
15
  import { createHash } from "node:crypto";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { createInterface } from "node:readline";
18
18
  import { loadDNA, compileFromFiles } from "../../compiler/index.js";
19
+ import { parseYAML } from "../../schema/yaml-parser.js";
20
+ import { getPackageVersion } from "../util/version.js";
19
21
  import { compileToMarkdown, injectIntoFile, removeFromFile } from "../../runtime/markdown.js";
20
22
  import { compileAllRolesToAgentMD, writeAgentMDFiles, removeAgentMDFiles } from "../../runtime/agent-md.js";
21
23
  import { removeWorkflowScripts } from "../../runtime/workflow-runner.js";
@@ -181,7 +183,7 @@ async function resolveVariables(rawVars, configPath) {
181
183
  return resolved;
182
184
  }
183
185
  /**
184
- * Auto-detect DNA config file in current directory.
186
+ * Auto-detect DNA config file in current directory (legacy single-config).
185
187
  * Priority: .dna/config.yaml > .dna/config.yml > .dna/config.json > .dna.yaml > .dna.yml > .dna.json
186
188
  */
187
189
  async function autoDetectDNA() {
@@ -197,6 +199,83 @@ async function autoDetectDNA() {
197
199
  }
198
200
  return null;
199
201
  }
202
+ /**
203
+ * Auto-detect DNA configs: multi-config (.dna/configs/*.yaml) or legacy single-config.
204
+ * Returns array of absolute paths.
205
+ */
206
+ export async function autoDetectConfigs() {
207
+ const cwd = process.cwd();
208
+ // Priority 1: .dna/configs/*.yaml (multi-config)
209
+ const configsDir = resolve(cwd, ".dna", "configs");
210
+ try {
211
+ const files = await readdir(configsDir);
212
+ const yamls = files
213
+ .filter(f => f.endsWith(".yaml") || f.endsWith(".yml"))
214
+ .sort()
215
+ .map(f => resolve(configsDir, f));
216
+ if (yamls.length > 0)
217
+ return yamls;
218
+ }
219
+ catch { /* dir doesn't exist */ }
220
+ // Fallback: legacy single-config paths
221
+ const legacy = await autoDetectDNA();
222
+ if (legacy)
223
+ return [legacy];
224
+ return [];
225
+ }
226
+ /**
227
+ * Read the current package version from package.json.
228
+ */
229
+ export { getPackageVersion } from "../util/version.js";
230
+ /**
231
+ * Check for namespace collisions across multiple config files.
232
+ * Throws if two configs declare the same namespace.
233
+ */
234
+ export async function checkNamespaceCollisions(configPaths) {
235
+ const namespaces = new Map();
236
+ for (const configPath of configPaths) {
237
+ try {
238
+ const content = await readFile(configPath, "utf-8");
239
+ const data = parseYAML(content);
240
+ const ns = typeof data.namespace === "string" ? data.namespace : undefined;
241
+ if (ns) {
242
+ const file = basename(configPath);
243
+ if (namespaces.has(ns)) {
244
+ throw new Error(`Namespace "${ns}" conflict: ${namespaces.get(ns)} and ${file}. Each template must have a unique namespace.`);
245
+ }
246
+ namespaces.set(ns, file);
247
+ }
248
+ }
249
+ catch (err) {
250
+ if (err instanceof Error && err.message.includes("conflict"))
251
+ throw err;
252
+ // Parse error — will be caught later during compilation
253
+ }
254
+ }
255
+ }
256
+ /**
257
+ * Check template versions against current package version.
258
+ * Prints upgrade suggestions for outdated configs.
259
+ */
260
+ async function checkTemplateVersions(configPaths) {
261
+ const pkgVersion = await getPackageVersion();
262
+ if (!pkgVersion)
263
+ return;
264
+ for (const configPath of configPaths) {
265
+ try {
266
+ const content = await readFile(configPath, "utf-8");
267
+ const data = parseYAML(content);
268
+ const tmplVersion = typeof data._template_version === "string" ? data._template_version : undefined;
269
+ 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`);
272
+ }
273
+ }
274
+ catch {
275
+ // Non-critical — skip version check for unparseable files
276
+ }
277
+ }
278
+ }
200
279
  function resolveSpeciesPath(ref) {
201
280
  if (!ref.startsWith("species:"))
202
281
  return null;
@@ -253,10 +332,21 @@ async function generateLockFile(lockPath, dnaSources, outputFiles) {
253
332
  export async function runSync(opts) {
254
333
  // ── Auto-detect mode: zero-config when no files/outputs specified ──
255
334
  if (!opts.remove && opts.files.length === 0) {
256
- const autoFile = await autoDetectDNA();
257
- if (autoFile) {
258
- opts.files = [autoFile];
259
- process.stderr.write(`Auto-detected: ${autoFile}\n`);
335
+ const autoFiles = await autoDetectConfigs();
336
+ if (autoFiles.length > 0) {
337
+ opts.files = autoFiles;
338
+ if (autoFiles.length === 1) {
339
+ process.stderr.write(`Auto-detected: ${autoFiles[0]}\n`);
340
+ }
341
+ else {
342
+ process.stderr.write(`Auto-detected: ${autoFiles.length} config(s) in .dna/configs/\n`);
343
+ }
344
+ // Namespace collision check (multi-config)
345
+ if (autoFiles.length > 1) {
346
+ await checkNamespaceCollisions(autoFiles);
347
+ }
348
+ // Template version check
349
+ await checkTemplateVersions(autoFiles);
260
350
  }
261
351
  }
262
352
  if (!opts.remove && !opts.inject && !opts.hooksDir && !opts.agentsDir && !opts.workflowDir && !opts.skillsDir && !opts.settingsPath) {
@@ -340,12 +430,15 @@ export async function runSync(opts) {
340
430
  try {
341
431
  // Track all output file paths for lock file generation
342
432
  const trackedOutputs = [];
433
+ // Step 0: Expand and pre-validate all DNA files before any work
434
+ // loadDNA() calls validateDNA() internally — validation failures throw here,
435
+ // before evolve/compile/inject waste any work.
436
+ const expandedFiles = await expandFiles(opts.files);
437
+ const loadedDNAs = await Promise.all(expandedFiles.map(loadDNA));
343
438
  // Step 1: Evolve if requested
344
439
  let epigeneticMarkers = undefined;
345
440
  if (opts.evolve) {
346
- // Need to know the DNA ID for evolution
347
- const expandedFiles = await expandFiles(opts.files);
348
- const dnas = await Promise.all(expandedFiles.map(loadDNA));
441
+ const dnas = loadedDNAs;
349
442
  // Use the highest-priority DNA's ID
350
443
  const primaryDna = dnas.reduce((a, b) => a.cascade.priority >= b.cascade.priority ? a : b);
351
444
  const engine = new EvolutionEngine(opts.storeDir ? { store_dir: opts.storeDir } : undefined);
@@ -356,7 +449,6 @@ export async function runSync(opts) {
356
449
  }
357
450
  }
358
451
  // Step 2: Compile
359
- const expandedFiles = await expandFiles(opts.files);
360
452
  const ir = await compileFromFiles(expandedFiles, {
361
453
  context: opts.context,
362
454
  role: opts.role,
@@ -378,7 +470,7 @@ export async function runSync(opts) {
378
470
  // Step 3.5: Write compiled IR (needed for both plugin and bin mode)
379
471
  {
380
472
  const cwd = process.cwd();
381
- const dnas = await Promise.all(expandedFiles.map(loadDNA));
473
+ const dnas = loadedDNAs;
382
474
  const cascadedForIR = cascadeDNA(dnas);
383
475
  const roles = cascadedForIR.roles;
384
476
  const compiled = createCompiledIR(ir, roles);
@@ -413,7 +505,7 @@ export async function runSync(opts) {
413
505
  if (staleAgents.length > 0) {
414
506
  process.stderr.write(`Cleaned ${staleAgents.length} stale agent file(s)\n`);
415
507
  }
416
- const dnas = await Promise.all(expandedFiles.map(loadDNA));
508
+ const dnas = loadedDNAs;
417
509
  const cascaded = cascadeDNA(dnas);
418
510
  const roles = cascaded.roles;
419
511
  if (Object.keys(roles).length === 0) {
@@ -433,7 +525,7 @@ export async function runSync(opts) {
433
525
  }
434
526
  // Step 6: Generate workflow script(s)
435
527
  if (opts.workflowDir) {
436
- const dnas = await Promise.all(expandedFiles.map(loadDNA));
528
+ const dnas = loadedDNAs;
437
529
  const cascadedForWf = cascadeDNA(dnas);
438
530
  const workflows = cascadedForWf.workflows;
439
531
  if (Object.keys(workflows).length === 0) {
@@ -474,7 +566,7 @@ export async function runSync(opts) {
474
566
  if (staleSkills.length > 0) {
475
567
  process.stderr.write(`Cleaned ${staleSkills.length} stale skill dir(s)\n`);
476
568
  }
477
- const dnas = await Promise.all(expandedFiles.map(loadDNA));
569
+ const dnas = loadedDNAs;
478
570
  const cascadedForSkills = cascadeDNA(dnas);
479
571
  const workflows = cascadedForSkills.workflows;
480
572
  const roles = cascadedForSkills.roles;
@@ -484,7 +576,7 @@ export async function runSync(opts) {
484
576
  if (d.variables)
485
577
  Object.assign(rawVars, d.variables);
486
578
  }
487
- const configPath = await autoDetectDNA();
579
+ const configPath = opts.files.length === 1 ? opts.files[0] : null;
488
580
  const variables = await resolveVariables(rawVars, configPath);
489
581
  // Step 8: Generate .claude/.mcp.json from MCP dependencies (inside skillsDir block, shares variables)
490
582
  {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Shared CLI utilities for package metadata.
3
+ */
4
+ /**
5
+ * Read the current package version from package.json.
6
+ */
7
+ export declare function getPackageVersion(): Promise<string | null>;
8
+ /**
9
+ * Sanitize a template name to prevent path traversal.
10
+ * Strips path separators and parent directory references.
11
+ */
12
+ export declare function sanitizeTemplateName(name: string): string;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Shared CLI utilities for package metadata.
3
+ */
4
+ import { readFile } from "node:fs/promises";
5
+ import { resolve, dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ /**
8
+ * Read the current package version from package.json.
9
+ */
10
+ export async function getPackageVersion() {
11
+ try {
12
+ const thisDir = dirname(fileURLToPath(import.meta.url));
13
+ const pkgPath = resolve(thisDir, "..", "..", "..", "package.json");
14
+ const raw = await readFile(pkgPath, "utf-8");
15
+ const pkg = JSON.parse(raw);
16
+ return typeof pkg.version === "string" ? pkg.version : null;
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ /**
23
+ * Sanitize a template name to prevent path traversal.
24
+ * Strips path separators and parent directory references.
25
+ */
26
+ export function sanitizeTemplateName(name) {
27
+ return name.replace(/[/\\]/g, "").replace(/\.\./g, "");
28
+ }
@@ -18,6 +18,8 @@ export interface CascadedDNA {
18
18
  workflows: Record<string, WorkflowDef>;
19
19
  epigenetic_markers: EpigeneticMarker[];
20
20
  source_ids: string[];
21
+ /** Warnings from cascade process (e.g., cross-namespace gene collisions) */
22
+ warnings?: string[];
21
23
  }
22
24
  /**
23
25
  * Cascade multiple DNA layers into a single merged DNA.
@@ -98,21 +98,37 @@ export function cascadeDNA(layers) {
98
98
  // Sort by priority ascending (species first, task last)
99
99
  const sorted = [...layers].sort((a, b) => a.cascade.priority - b.cascade.priority);
100
100
  const mergedGenes = {};
101
+ const geneSourceNs = {}; // track which namespace defined each gene
101
102
  const mergedContexts = {};
102
103
  const mergedRoles = {};
103
104
  const mergedWorkflows = {};
104
105
  const allMarkers = [];
105
106
  const sourceIds = [];
107
+ const warnings = [];
106
108
  for (const dna of sorted) {
107
109
  sourceIds.push(dna.id);
108
110
  const ns = dna.namespace;
109
- // Merge genes
111
+ // Merge genes (cross-namespace: additive merge + warning)
110
112
  for (const [name, gene] of Object.entries(dna.genes)) {
111
113
  if (mergedGenes[name]) {
112
- mergedGenes[name] = mergeGenes(mergedGenes[name], gene);
114
+ const existingNs = geneSourceNs[name];
115
+ if (ns !== existingNs) {
116
+ // Different namespaces define same gene — additive codon merge + warning
117
+ mergedGenes[name] = {
118
+ description: gene.description || mergedGenes[name].description,
119
+ codons: [...mergedGenes[name].codons, ...gene.codons],
120
+ tags: [...new Set([...(mergedGenes[name].tags ?? []), ...(gene.tags ?? [])])],
121
+ };
122
+ warnings.push(`Gene "${name}" defined in both ${existingNs ?? "(no namespace)"} and ${ns ?? "(no namespace)"}. Codons merged.`);
123
+ }
124
+ else {
125
+ // Same namespace — normal cascade (higher priority wins)
126
+ mergedGenes[name] = mergeGenes(mergedGenes[name], gene);
127
+ }
113
128
  }
114
129
  else {
115
130
  mergedGenes[name] = { ...gene, codons: [...gene.codons] };
131
+ geneSourceNs[name] = ns;
116
132
  }
117
133
  }
118
134
  // Merge contexts (higher priority replaces entirely)
@@ -147,5 +163,6 @@ export function cascadeDNA(layers) {
147
163
  workflows: mergedWorkflows,
148
164
  epigenetic_markers: allMarkers,
149
165
  source_ids: sourceIds,
166
+ warnings: warnings.length > 0 ? warnings : undefined,
150
167
  };
151
168
  }
package/dist/hooks/cli.js CHANGED
@@ -19,7 +19,7 @@ import { resolve } from "node:path";
19
19
  import { randomUUID } from "node:crypto";
20
20
  import { readStdin, writeOutput, silentOutput, allowOutput } from "./protocol.js";
21
21
  import { enforcePreToolUse, enforcePostToolUse, enforceUserPromptSubmit, enforceSubagentStop, enforcePreCompact, enforceNotification, enforceSessionStart, enforceStop, extractBashWritePaths, } from "./enforce.js";
22
- import { appendAudit, readWorkflowState, appendTrace, rotateTraces, readTraces } from "./state.js";
22
+ import { appendAudit, readWorkflowState, appendTrace, rotateTraces, readTraces, cleanStaleState } from "./state.js";
23
23
  // ── Constants ──────────────────────────────────────────────
24
24
  const DEFAULT_IR_PATH = ".dna/compiled/ir.json";
25
25
  const VALID_EVENTS = new Set([
@@ -127,7 +127,7 @@ async function main() {
127
127
  }, stopContext);
128
128
  // Session summary: aggregate block/warn stats from trace
129
129
  try {
130
- const traces = await readTraces(projectDir, 1);
130
+ const traces = await readTraces(projectDir, 1, sessionId);
131
131
  const summary = computeSummary(traces);
132
132
  const summaryText = formatSummary(summary);
133
133
  if (summaryText) {
@@ -152,7 +152,7 @@ async function main() {
152
152
  decision: stopOutput.continue === false ? "block" : "allow",
153
153
  duration_ms: 0,
154
154
  timestamp: new Date().toISOString(),
155
- }).catch(() => { });
155
+ }, sessionId).catch(() => { });
156
156
  return;
157
157
  }
158
158
  // Dispatch to enforcement engine with timing
@@ -171,7 +171,7 @@ async function main() {
171
171
  // O4: Block pattern recognition — suggest scope expansion on repeated blocks
172
172
  if (event === "PreToolUse" && output.continue === false && targetPath) {
173
173
  try {
174
- const traces = await readTraces(projectDir, 1);
174
+ const traces = await readTraces(projectDir, 1, sessionId);
175
175
  const blockCount = traces.filter(t => t.decision === "block" && t.target_path === targetPath).length;
176
176
  if (blockCount >= 2) {
177
177
  output = {
@@ -200,10 +200,11 @@ async function main() {
200
200
  target_path: decision !== "allow" ? targetPath : undefined,
201
201
  duration_ms: durationMs,
202
202
  timestamp: new Date().toISOString(),
203
- }).catch(() => { }); // Fail-open
204
- // Side effect: rotate traces on SessionStart
203
+ }, sessionId).catch(() => { }); // Fail-open
204
+ // Side effect: rotate traces + clean stale state on SessionStart
205
205
  if (event === "SessionStart") {
206
206
  rotateTraces(projectDir).catch(() => { });
207
+ cleanStaleState(projectDir).catch(() => { });
207
208
  }
208
209
  // Side effect: audit log for notifications
209
210
  if (event === "Notification" && output.hookSpecificOutput?.additionalContext?.includes("violation")) {
@@ -267,12 +268,17 @@ function dispatch(event, ir, input, state) {
267
268
  }
268
269
  }
269
270
  // ── IR Loading ─────────────────────────────────────────────
271
+ /** Expected IR schema version — must match plugin-adapter.ts */
272
+ const EXPECTED_IR_VERSION = 1;
270
273
  async function loadIR(irPath) {
271
274
  try {
272
275
  const raw = await readFile(irPath, "utf-8");
273
276
  const data = JSON.parse(raw);
274
277
  // Support both raw IR and wrapped CompiledIRFile format
275
278
  if (data.ir_version && data.ir) {
279
+ if (data.ir_version !== EXPECTED_IR_VERSION) {
280
+ 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
+ }
276
282
  return data.ir;
277
283
  }
278
284
  // Direct ConstraintIR
@@ -383,10 +383,10 @@ function checkPathAgainstScope(rolesScopeMap, input, filePath) {
383
383
  continue;
384
384
  const writeGlobs = entry.scope.write ?? [];
385
385
  if (writeGlobs.length === 0) {
386
- return blockOutput(`[Intent DNA]: Role '${entry.role_name}' has no write permission (path: ${filePath})`);
386
+ return allowOutput(`WARN [Intent DNA]: Role '${entry.role_name}' has no write permission (path: ${filePath}). Merge-time scope gate will filter.`);
387
387
  }
388
388
  if (!checkWriteAllowed(relativePath, writeGlobs)) {
389
- return blockOutput(`[Intent DNA]: Role '${entry.role_name}' cannot write to '${filePath}' (allowed: ${writeGlobs.join(", ")})`);
389
+ return allowOutput(`WARN [Intent DNA]: Role '${entry.role_name}' cannot write to '${filePath}' (allowed: ${writeGlobs.join(", ")}). Merge-time scope gate will filter.`);
390
390
  }
391
391
  return null; // Role matched, write allowed
392
392
  }
@@ -62,6 +62,8 @@ export declare function appendCompletedArtifact(projectDir: string, stepId: stri
62
62
  type: string;
63
63
  path: string;
64
64
  }, sessionId?: string): Promise<void>;
65
+ /** Atomic write: write to temp file then rename. */
66
+ export declare function atomicWrite(filePath: string, data: string): Promise<void>;
65
67
  /** Trace entry for hook call observability */
66
68
  export interface TraceEntry {
67
69
  trace_id: string;
@@ -78,17 +80,27 @@ export interface TraceEntry {
78
80
  }
79
81
  /**
80
82
  * Append a trace entry to the daily trace file.
81
- * File: `.dna/state/trace/trace-YYYY-MM-DD.jsonl`
83
+ * With sessionId: `.dna/state/trace/trace-YYYY-MM-DD-{sessionId}.jsonl`
84
+ * Without: `.dna/state/trace/trace-YYYY-MM-DD.jsonl` (backward compat)
82
85
  * Fail-open: never throws.
83
86
  */
84
- export declare function appendTrace(projectDir: string, entry: TraceEntry): Promise<void>;
87
+ export declare function appendTrace(projectDir: string, entry: TraceEntry, sessionId?: string): Promise<void>;
85
88
  /**
86
89
  * Read trace entries from the last N days.
90
+ * With sessionId: reads only that session's trace files.
91
+ * Without: reads all trace files (merged view).
87
92
  * Returns parsed entries sorted by timestamp.
88
93
  */
89
- export declare function readTraces(projectDir: string, days?: number): Promise<TraceEntry[]>;
94
+ export declare function readTraces(projectDir: string, days?: number, sessionId?: string): Promise<TraceEntry[]>;
90
95
  /**
91
96
  * Clean up trace files older than retention period.
92
97
  * Removes `.dna/state/trace/trace-*.jsonl` files older than TRACE_RETENTION_DAYS.
93
98
  */
94
99
  export declare function rotateTraces(projectDir: string): Promise<number>;
100
+ /**
101
+ * Clean up stale state from `.dna/state/sessions/` and root state.
102
+ * Removes workflow.json files older than DEFAULT_STALENESS_MS (2h).
103
+ * Called on SessionStart to prevent state accumulation.
104
+ * Fail-open: never throws.
105
+ */
106
+ export declare function cleanStaleState(projectDir: string): Promise<number>;