intentdna 1.4.9 → 1.5.2
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/init.js +98 -28
- package/dist/cli/commands/sync.d.ts +14 -0
- package/dist/cli/commands/sync.js +107 -15
- package/dist/cli/util/version.d.ts +12 -0
- package/dist/cli/util/version.js +28 -0
- package/dist/compiler/cascade.d.ts +2 -0
- package/dist/compiler/cascade.js +19 -2
- package/dist/hooks/cli.js +12 -6
- package/dist/hooks/state.d.ts +15 -3
- package/dist/hooks/state.js +94 -7
- package/dist/runtime/plugin-adapter.js +3 -2
- package/dist/schema/validate.js +30 -0
- package/package.json +2 -2
- package/spec/foundation-hardening.md +177 -0
- package/spec/multi-config.md +172 -0
|
@@ -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
|
|
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
|
|
113
|
+
* Auto-detect DNA config file(s) for upgrade.
|
|
114
|
+
* Priority: .dna/configs/*.yaml (multi-config) > legacy single-config
|
|
90
115
|
*/
|
|
91
|
-
async function
|
|
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
|
|
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
|
-
|
|
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
|
|
154
|
-
if (
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
|
231
|
-
const
|
|
232
|
-
//
|
|
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
|
|
257
|
-
if (
|
|
258
|
-
opts.files =
|
|
259
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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.
|
package/dist/compiler/cascade.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/hooks/state.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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>;
|
package/dist/hooks/state.js
CHANGED
|
@@ -130,7 +130,7 @@ export async function appendCompletedArtifact(projectDir, stepId, artifact, sess
|
|
|
130
130
|
}
|
|
131
131
|
// ── Internal ───────────────────────────────────────────────
|
|
132
132
|
/** Atomic write: write to temp file then rename. */
|
|
133
|
-
async function atomicWrite(filePath, data) {
|
|
133
|
+
export async function atomicWrite(filePath, data) {
|
|
134
134
|
const tmpPath = filePath + ".tmp." + process.pid;
|
|
135
135
|
await mkdir(dirname(filePath), { recursive: true });
|
|
136
136
|
await writeFile(tmpPath, data, "utf-8");
|
|
@@ -139,17 +139,29 @@ async function atomicWrite(filePath, data) {
|
|
|
139
139
|
const TRACE_DIR = "trace";
|
|
140
140
|
const MAX_TRACE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
141
141
|
const TRACE_RETENTION_DAYS = 7;
|
|
142
|
+
/**
|
|
143
|
+
* Build trace file name.
|
|
144
|
+
* With session isolation: `trace-{date}-{sessionId}.jsonl`
|
|
145
|
+
* Without: `trace-{date}.jsonl` (backward compat / merged view)
|
|
146
|
+
*/
|
|
147
|
+
function traceFileName(date, sessionId) {
|
|
148
|
+
if (sessionId) {
|
|
149
|
+
return `trace-${date}-${sessionId}.jsonl`;
|
|
150
|
+
}
|
|
151
|
+
return `trace-${date}.jsonl`;
|
|
152
|
+
}
|
|
142
153
|
/**
|
|
143
154
|
* Append a trace entry to the daily trace file.
|
|
144
|
-
*
|
|
155
|
+
* With sessionId: `.dna/state/trace/trace-YYYY-MM-DD-{sessionId}.jsonl`
|
|
156
|
+
* Without: `.dna/state/trace/trace-YYYY-MM-DD.jsonl` (backward compat)
|
|
145
157
|
* Fail-open: never throws.
|
|
146
158
|
*/
|
|
147
|
-
export async function appendTrace(projectDir, entry) {
|
|
159
|
+
export async function appendTrace(projectDir, entry, sessionId) {
|
|
148
160
|
try {
|
|
149
161
|
const traceDir = join(projectDir, ".dna", "state", TRACE_DIR);
|
|
150
162
|
await mkdir(traceDir, { recursive: true });
|
|
151
163
|
const date = entry.timestamp.slice(0, 10);
|
|
152
|
-
const tracePath = join(traceDir,
|
|
164
|
+
const tracePath = join(traceDir, traceFileName(date, sessionId));
|
|
153
165
|
// Check file size — rotate if over limit
|
|
154
166
|
try {
|
|
155
167
|
const stats = await stat(tracePath);
|
|
@@ -165,9 +177,11 @@ export async function appendTrace(projectDir, entry) {
|
|
|
165
177
|
}
|
|
166
178
|
/**
|
|
167
179
|
* Read trace entries from the last N days.
|
|
180
|
+
* With sessionId: reads only that session's trace files.
|
|
181
|
+
* Without: reads all trace files (merged view).
|
|
168
182
|
* Returns parsed entries sorted by timestamp.
|
|
169
183
|
*/
|
|
170
|
-
export async function readTraces(projectDir, days = 1) {
|
|
184
|
+
export async function readTraces(projectDir, days = 1, sessionId) {
|
|
171
185
|
const traceDir = join(projectDir, ".dna", "state", TRACE_DIR);
|
|
172
186
|
const entries = [];
|
|
173
187
|
const cutoff = new Date();
|
|
@@ -178,8 +192,16 @@ export async function readTraces(projectDir, days = 1) {
|
|
|
178
192
|
const traceFiles = files
|
|
179
193
|
.filter(f => f.startsWith("trace-") && f.endsWith(".jsonl"))
|
|
180
194
|
.filter(f => {
|
|
181
|
-
|
|
182
|
-
|
|
195
|
+
// Extract date from filename: trace-YYYY-MM-DD.jsonl or trace-YYYY-MM-DD-{sessionId}.jsonl
|
|
196
|
+
const fileDate = f.slice(6, 16); // "trace-YYYY-MM-DD..."
|
|
197
|
+
if (fileDate < cutoffDate)
|
|
198
|
+
return false;
|
|
199
|
+
// Session filter: only include files for this session (or shared files)
|
|
200
|
+
if (sessionId) {
|
|
201
|
+
const suffix = f.slice(16); // "-{sessionId}.jsonl" or ".jsonl"
|
|
202
|
+
return suffix === `-${sessionId}.jsonl` || suffix === ".jsonl";
|
|
203
|
+
}
|
|
204
|
+
return true;
|
|
183
205
|
})
|
|
184
206
|
.sort();
|
|
185
207
|
for (const file of traceFiles) {
|
|
@@ -226,3 +248,68 @@ export async function rotateTraces(projectDir) {
|
|
|
226
248
|
}
|
|
227
249
|
return removed;
|
|
228
250
|
}
|
|
251
|
+
// ── Stale State Cleanup ──────────────────────────────────
|
|
252
|
+
/**
|
|
253
|
+
* Clean up stale state from `.dna/state/sessions/` and root state.
|
|
254
|
+
* Removes workflow.json files older than DEFAULT_STALENESS_MS (2h).
|
|
255
|
+
* Called on SessionStart to prevent state accumulation.
|
|
256
|
+
* Fail-open: never throws.
|
|
257
|
+
*/
|
|
258
|
+
export async function cleanStaleState(projectDir) {
|
|
259
|
+
let removed = 0;
|
|
260
|
+
const sessionsDir = join(projectDir, ".dna", "state", "sessions");
|
|
261
|
+
try {
|
|
262
|
+
const sessions = await readdir(sessionsDir);
|
|
263
|
+
for (const sessionDir of sessions) {
|
|
264
|
+
const wfPath = join(sessionsDir, sessionDir, WORKFLOW_FILE);
|
|
265
|
+
try {
|
|
266
|
+
const raw = await readFile(wfPath, "utf-8");
|
|
267
|
+
const state = JSON.parse(raw);
|
|
268
|
+
if (state.started_at) {
|
|
269
|
+
const age = Date.now() - new Date(state.started_at).getTime();
|
|
270
|
+
if (age > DEFAULT_STALENESS_MS) {
|
|
271
|
+
// Remove stale session directory
|
|
272
|
+
const { rm } = await import("node:fs/promises");
|
|
273
|
+
await rm(join(sessionsDir, sessionDir), { recursive: true, force: true });
|
|
274
|
+
removed++;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
// Malformed or missing workflow.json — clean up the directory
|
|
280
|
+
try {
|
|
281
|
+
const dirStat = await stat(join(sessionsDir, sessionDir));
|
|
282
|
+
if (dirStat.isDirectory()) {
|
|
283
|
+
const dirAge = Date.now() - dirStat.mtimeMs;
|
|
284
|
+
if (dirAge > DEFAULT_STALENESS_MS) {
|
|
285
|
+
const { rm } = await import("node:fs/promises");
|
|
286
|
+
await rm(join(sessionsDir, sessionDir), { recursive: true, force: true });
|
|
287
|
+
removed++;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
catch { /* skip */ }
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
// No sessions dir — nothing to clean
|
|
297
|
+
}
|
|
298
|
+
// Also clean root-level stale workflow state
|
|
299
|
+
const rootWfPath = join(projectDir, ".dna", "state", WORKFLOW_FILE);
|
|
300
|
+
try {
|
|
301
|
+
const raw = await readFile(rootWfPath, "utf-8");
|
|
302
|
+
const state = JSON.parse(raw);
|
|
303
|
+
if (state.started_at) {
|
|
304
|
+
const age = Date.now() - new Date(state.started_at).getTime();
|
|
305
|
+
if (age > DEFAULT_STALENESS_MS) {
|
|
306
|
+
await unlink(rootWfPath).catch(() => { });
|
|
307
|
+
removed++;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
// No root workflow state
|
|
313
|
+
}
|
|
314
|
+
return removed;
|
|
315
|
+
}
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
*
|
|
7
7
|
* This is the bridge between compile-time (dna sync) and runtime (hook execution).
|
|
8
8
|
*/
|
|
9
|
-
import { readFile,
|
|
9
|
+
import { readFile, mkdir } from "node:fs/promises";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
+
import { atomicWrite } from "../hooks/state.js";
|
|
11
12
|
// ── Serialize ──────────────────────────────────────────────
|
|
12
13
|
/**
|
|
13
14
|
* Create a CompiledIRFile wrapper around a ConstraintIR.
|
|
@@ -30,7 +31,7 @@ export async function writeCompiledIR(projectDir, compiled) {
|
|
|
30
31
|
const outputDir = join(projectDir, ".dna", "compiled");
|
|
31
32
|
await mkdir(outputDir, { recursive: true });
|
|
32
33
|
const outputPath = join(outputDir, "ir.json");
|
|
33
|
-
await
|
|
34
|
+
await atomicWrite(outputPath, JSON.stringify(compiled, null, 2) + "\n");
|
|
34
35
|
return outputPath;
|
|
35
36
|
}
|
|
36
37
|
/**
|
package/dist/schema/validate.js
CHANGED
|
@@ -235,6 +235,16 @@ function validateWorkflow(workflow, roleNames, pathPrefix) {
|
|
|
235
235
|
if (step.prompt !== undefined && (typeof step.prompt !== "string" || !step.prompt)) {
|
|
236
236
|
errors.push({ path: `${stepPath}.prompt`, message: "prompt must be a non-empty string" });
|
|
237
237
|
}
|
|
238
|
+
// isolation: must be a valid enum value
|
|
239
|
+
if (step.isolation !== undefined) {
|
|
240
|
+
const validIsolation = ["none", "worktree", "auto"];
|
|
241
|
+
if (!validIsolation.includes(step.isolation)) {
|
|
242
|
+
errors.push({
|
|
243
|
+
path: `${stepPath}.isolation`,
|
|
244
|
+
message: `must be one of: ${validIsolation.join(", ")}`,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
238
248
|
// checkpoints validation
|
|
239
249
|
if (step.checkpoints) {
|
|
240
250
|
if (!Array.isArray(step.checkpoints)) {
|
|
@@ -283,6 +293,26 @@ function validateWorkflow(workflow, roleNames, pathPrefix) {
|
|
|
283
293
|
}
|
|
284
294
|
}
|
|
285
295
|
}
|
|
296
|
+
// Validate default_isolation
|
|
297
|
+
if (workflow.default_isolation !== undefined) {
|
|
298
|
+
const validIsolation = ["none", "worktree", "auto"];
|
|
299
|
+
if (!validIsolation.includes(workflow.default_isolation)) {
|
|
300
|
+
errors.push({
|
|
301
|
+
path: `${path}.default_isolation`,
|
|
302
|
+
message: `must be one of: ${validIsolation.join(", ")}`,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// Validate merge_strategy
|
|
307
|
+
if (workflow.merge_strategy !== undefined) {
|
|
308
|
+
const validStrategies = ["escalate"];
|
|
309
|
+
if (!validStrategies.includes(workflow.merge_strategy)) {
|
|
310
|
+
errors.push({
|
|
311
|
+
path: `${path}.merge_strategy`,
|
|
312
|
+
message: `must be one of: ${validStrategies.join(", ")}`,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
286
316
|
// Validate transitions
|
|
287
317
|
const validConditions = ["pass", "fail", "always", "error"];
|
|
288
318
|
for (let i = 0; i < (workflow.transitions?.length ?? 0); i++) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "intentdna",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.2",
|
|
4
4
|
"description": "Intent DNA — Declarative policy layer for AI agent behavior",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"dna-hook": "dist/hooks/cli.js"
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
|
-
"build": "tsc && mkdir -p dist/species dist/templates && cp src/species/*.json dist/species/ && cp src/templates/*.yaml dist/templates/",
|
|
13
|
+
"build": "tsc && mkdir -p dist/species dist/templates && cp src/species/*.json dist/species/ && cp src/templates/*.yaml dist/templates/ && node scripts/sync-plugin-version.cjs",
|
|
14
14
|
"dev": "tsc --watch",
|
|
15
15
|
"test": "node --experimental-vm-modules node_modules/.bin/vitest run",
|
|
16
16
|
"test:watch": "node --experimental-vm-modules node_modules/.bin/vitest"
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# Spec: DNA 基础设施加固
|
|
2
|
+
|
|
3
|
+
## Metadata
|
|
4
|
+
- Interview: 5 rounds, final ambiguity 20%
|
|
5
|
+
- Date: 2026-04-15
|
|
6
|
+
- Status: 设计完成
|
|
7
|
+
|
|
8
|
+
## Goal
|
|
9
|
+
|
|
10
|
+
全面审视 DNA 基础设施的薄弱点,从可靠性 → 治理能力 → 工具体验三个维度加固,对标 OMC 补齐差距,为组织级治理奠基。
|
|
11
|
+
|
|
12
|
+
## 成功标准
|
|
13
|
+
|
|
14
|
+
- 零已知缺陷
|
|
15
|
+
- 关键路径测试覆盖 >90%
|
|
16
|
+
- 可交付给他人使用(不需要作者盯着)
|
|
17
|
+
- 架构可扩展(后续治理功能不需要重构基础)
|
|
18
|
+
|
|
19
|
+
## 优先级: 可靠性 → 治理能力 → 工具体验
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 第一阶段: 可靠性加固
|
|
24
|
+
|
|
25
|
+
### R1: Config 编译前验证
|
|
26
|
+
|
|
27
|
+
**问题**: `dna compile` 绕过 `validateDNA()`,无效 DNA 静默编译。
|
|
28
|
+
|
|
29
|
+
**修复**: sync/compile 入口统一调用 `validateDNA()`,验证失败 → 报错退出。
|
|
30
|
+
|
|
31
|
+
### R2: Trace 并发安全
|
|
32
|
+
|
|
33
|
+
**问题**: 多 session 同时 `appendFile` 写 trace.jsonl,可能 corrupt。
|
|
34
|
+
|
|
35
|
+
**修复**:
|
|
36
|
+
- 方案 A: trace 文件按 session 隔离 → `trace-{date}-{sessionId}.jsonl`
|
|
37
|
+
- 方案 B: 使用 `O_APPEND` 原子追加(POSIX 保证 ≤ PIPE_BUF 的 write 原子性)
|
|
38
|
+
- 推荐 A(简单、和 OMC 一致)
|
|
39
|
+
|
|
40
|
+
### R3: IR 版本迁移
|
|
41
|
+
|
|
42
|
+
**问题**: `ir_version` 字段存在但无迁移逻辑。破坏性 IR 变更让旧 hook 静默失败。
|
|
43
|
+
|
|
44
|
+
**修复**:
|
|
45
|
+
- dna-hook 检测 `ir_version` 不匹配 → 输出明确错误 "IR version mismatch, run dna sync"
|
|
46
|
+
- 保留 fail-open(不 block,但警告)
|
|
47
|
+
|
|
48
|
+
### R4: 原子写入
|
|
49
|
+
|
|
50
|
+
**问题**: state 写入使用普通 `writeFile`,进程中断可能 corrupt。
|
|
51
|
+
|
|
52
|
+
**修复**: 参考 OMC 的 temp+rename 模式(state.ts 已有,确认所有写入路径都用)
|
|
53
|
+
|
|
54
|
+
### R5: Stale 状态清理
|
|
55
|
+
|
|
56
|
+
**问题**: 无超时机制,残留 state 文件永久存在。
|
|
57
|
+
|
|
58
|
+
**修复**: 参考 OMC 的 2h stale 超时。SessionStart 时检查并清理过期 state。
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## 第二阶段: 治理能力
|
|
63
|
+
|
|
64
|
+
### G1: Session 隔离
|
|
65
|
+
|
|
66
|
+
**问题**: `.dna/state/` 无 session 目录,多 session 状态冲突。
|
|
67
|
+
|
|
68
|
+
**修复**:
|
|
69
|
+
```
|
|
70
|
+
.dna/state/
|
|
71
|
+
sessions/
|
|
72
|
+
{sessionId}/
|
|
73
|
+
workflow.json
|
|
74
|
+
trace.jsonl ← session 级 trace
|
|
75
|
+
trace/
|
|
76
|
+
trace-{date}.jsonl ← 全局 trace(合并视图)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### G2: MCP Server — 状态管理工具
|
|
80
|
+
|
|
81
|
+
DNA 的第一个 MCP server,提供:
|
|
82
|
+
- `dna_state_read(mode)` — 读 workflow/enforce 状态
|
|
83
|
+
- `dna_state_write(mode, data)` — 写状态
|
|
84
|
+
- `dna_trace_query(filters)` — 查询 trace 数据
|
|
85
|
+
- `dna_workflow_status()` — 当前 workflow 进度
|
|
86
|
+
|
|
87
|
+
实现方式: 参考 OMC 的 `createSdkMcpServer()`,注册为 Claude Code plugin 的 MCP server。
|
|
88
|
+
|
|
89
|
+
### G3: MCP Server — 编译工具链
|
|
90
|
+
|
|
91
|
+
- `dna_compile(config_path)` — 编译 DNA 到 IR
|
|
92
|
+
- `dna_sync()` — 一键同步(等同 CLI dna sync)
|
|
93
|
+
- `dna_generate(description)` — 从描述生成 DNA
|
|
94
|
+
|
|
95
|
+
让 LLM 直接操作 DNA,不需要 Bash 中转。
|
|
96
|
+
|
|
97
|
+
### G4: 状态驱动 Enforce
|
|
98
|
+
|
|
99
|
+
**问题**: enforce 只读静态 IR,不能根据 workflow 运行时状态调整。
|
|
100
|
+
|
|
101
|
+
**修复**: enforce 接受 state 参数,支持条件规则:
|
|
102
|
+
- "rescue 第 3 轮后放宽 scope"
|
|
103
|
+
- "scan 步骤只读,fix 步骤可写"
|
|
104
|
+
- 当前 WorkflowState 已传入 enforce,但只用于 checkpoint。扩展为通用条件。
|
|
105
|
+
|
|
106
|
+
### G5: MCP Server — 远程治理通信(后续)
|
|
107
|
+
|
|
108
|
+
- 上报审计数据到中央服务器
|
|
109
|
+
- 拉取企业级 DNA 策略
|
|
110
|
+
- 接收策略更新推送
|
|
111
|
+
|
|
112
|
+
这是 Phase 7 的企业治理基础,当前只做架构预留。
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## 第三阶段: 工具体验
|
|
117
|
+
|
|
118
|
+
### U1: dna sync 自动检测升级
|
|
119
|
+
|
|
120
|
+
sync 时对比 `_template_version`,有更新 → 提示升级。
|
|
121
|
+
|
|
122
|
+
### U2: 冷启动优化
|
|
123
|
+
|
|
124
|
+
**问题**: 每次 hook 调用 spawn Node.js ~50ms。
|
|
125
|
+
|
|
126
|
+
**方案**:
|
|
127
|
+
- 短期: IR 缓存到内存(MCP server 常驻进程时自然解决)
|
|
128
|
+
- 长期: MCP server 内置 enforce,hook 调用走 MCP 而非 spawn
|
|
129
|
+
|
|
130
|
+
### U3: 错误信息优化
|
|
131
|
+
|
|
132
|
+
hook 报错时输出人类可读的提示而非 JSON。`dna verify` 输出友好的健康报告。
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## 与 OMC 差距对标
|
|
137
|
+
|
|
138
|
+
| 差距 | 对应任务 | 阶段 |
|
|
139
|
+
|------|---------|------|
|
|
140
|
+
| 无 session 隔离 | G1 | 二 |
|
|
141
|
+
| 无 MCP 工具 | G2 + G3 | 二 |
|
|
142
|
+
| 每次冷启动 | U2 | 三 |
|
|
143
|
+
| 纯静态 enforce | G4 | 二 |
|
|
144
|
+
| trace 并发 | R2 | 一 |
|
|
145
|
+
| 无原子写入 | R4 | 一 |
|
|
146
|
+
| 无 stale 清理 | R5 | 一 |
|
|
147
|
+
|
|
148
|
+
## DNA 已有优势(保持)
|
|
149
|
+
|
|
150
|
+
| 优势 | 不要丢 |
|
|
151
|
+
|------|--------|
|
|
152
|
+
| 纯函数 enforce | 不引入 I/O 到 enforce.ts |
|
|
153
|
+
| 编译时 IR | 保持预编译模式,MCP 是补充不是替代 |
|
|
154
|
+
| Fail-open | 所有错误 → allow |
|
|
155
|
+
| 确定性 | 同 IR + 同 input = 同 output |
|
|
156
|
+
|
|
157
|
+
## Acceptance Criteria
|
|
158
|
+
|
|
159
|
+
### 第一阶段
|
|
160
|
+
- [ ] dna compile/sync 编译前调用 validateDNA()
|
|
161
|
+
- [ ] trace 写入 session 隔离或原子追加
|
|
162
|
+
- [ ] IR 版本不匹配 → 明确警告
|
|
163
|
+
- [ ] 所有 state 写入使用 temp+rename
|
|
164
|
+
- [ ] SessionStart 清理 >2h stale state
|
|
165
|
+
- [ ] 测试覆盖所有修复
|
|
166
|
+
|
|
167
|
+
### 第二阶段
|
|
168
|
+
- [ ] .dna/state/sessions/{id}/ 目录结构
|
|
169
|
+
- [ ] MCP server 提供 state_read/write/trace_query
|
|
170
|
+
- [ ] MCP server 提供 compile/sync/generate
|
|
171
|
+
- [ ] enforce 支持 WorkflowState 条件规则
|
|
172
|
+
- [ ] 远程通信架构预留(接口定义,不实现)
|
|
173
|
+
|
|
174
|
+
### 第三阶段
|
|
175
|
+
- [ ] dna sync 自动检测模板版本 + 提示升级
|
|
176
|
+
- [ ] MCP server 常驻进程解决冷启动
|
|
177
|
+
- [ ] 错误信息人类可读
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Spec: Multi-Config 模板管理
|
|
2
|
+
|
|
3
|
+
## Metadata
|
|
4
|
+
- Date: 2026-04-15
|
|
5
|
+
- Phase: 6 (持续改进 C2/C3)
|
|
6
|
+
- Status: 设计完成,待实现
|
|
7
|
+
|
|
8
|
+
## Goal
|
|
9
|
+
|
|
10
|
+
支持一个项目同时使用多个 DNA 模板,每个模板有独立的 namespace、roles、workflows。统一编译到一个 IR,运行时透明。
|
|
11
|
+
|
|
12
|
+
## 目录结构
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
.dna/
|
|
16
|
+
configs/ # 多模板配置目录
|
|
17
|
+
flutter-rewrite.yaml # namespace: frw
|
|
18
|
+
secure-dev.yaml # namespace: sec
|
|
19
|
+
code-review.yaml # namespace: cr
|
|
20
|
+
compiled/
|
|
21
|
+
ir.json # 合并后的单一 IR
|
|
22
|
+
state/
|
|
23
|
+
trace/
|
|
24
|
+
lock # 追踪所有 configs 的 hash
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## 核心设计决策
|
|
28
|
+
|
|
29
|
+
| 决策 | 结论 | 理由 |
|
|
30
|
+
|------|------|------|
|
|
31
|
+
| 目录结构 | `.dna/configs/*.yaml` | 独立目录,清晰分离 |
|
|
32
|
+
| IR 输出 | 合并为单一 ir.json | hooks 只读一个 IR,运行时透明 |
|
|
33
|
+
| Namespace 冲突 | 编译时报错 | 一个 namespace 只属于一个模板,静默覆盖 = 数据丢失 |
|
|
34
|
+
| Gene 冲突 | 合并 codons + 警告 | gene 是项目级共享概念,additive 合并最合理 |
|
|
35
|
+
| 向后兼容 | 暂不考虑 | 新项目直接用 configs/,老项目手动迁移 |
|
|
36
|
+
| 性能 | 无影响 | YAML parse 5 个文件 ~25ms,ir.json KB 级,hook 运行时无差异 |
|
|
37
|
+
|
|
38
|
+
## Namespace 冲突处理
|
|
39
|
+
|
|
40
|
+
编译时扫描 `configs/*.yaml`,检测 namespace 重复:
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
// sync.ts
|
|
44
|
+
const namespaces = new Map<string, string>(); // namespace → filename
|
|
45
|
+
for (const config of configs) {
|
|
46
|
+
if (namespaces.has(config.namespace)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Namespace "${config.namespace}" conflict: ` +
|
|
49
|
+
`${namespaces.get(config.namespace)} and ${config.filename}. ` +
|
|
50
|
+
`Each template must have a unique namespace.`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
namespaces.set(config.namespace, config.filename);
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Gene 冲突处理
|
|
58
|
+
|
|
59
|
+
同名 gene 来自不同模板时,合并 codons 并输出警告:
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
// cascade.ts
|
|
63
|
+
if (mergedGenes[geneName] && sourceNamespace !== existingNamespace) {
|
|
64
|
+
// Different templates define same gene — merge codons
|
|
65
|
+
mergedGenes[geneName].codons.push(...gene.codons);
|
|
66
|
+
warnings.push(
|
|
67
|
+
`Gene "${geneName}" defined in both ${existingNamespace} and ${sourceNamespace}. ` +
|
|
68
|
+
`Codons merged. Verify this is intentional.`
|
|
69
|
+
);
|
|
70
|
+
} else {
|
|
71
|
+
// Same namespace or new gene — normal cascade (higher priority wins)
|
|
72
|
+
mergedGenes[geneName] = gene;
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## 模板版本追踪
|
|
77
|
+
|
|
78
|
+
每个 config 文件增加 `_template_version` 字段:
|
|
79
|
+
|
|
80
|
+
```yaml
|
|
81
|
+
_source_template: flutter-rewrite
|
|
82
|
+
_template_version: "1.4.9" # npm 包版本时写入
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`dna sync` 时对比:
|
|
86
|
+
1. 读每个 config 的 `_source_template` + `_template_version`
|
|
87
|
+
2. 查 npm 包里对应模板的当前版本
|
|
88
|
+
3. 版本不一致 → 提示 "flutter-rewrite 有新版本 (1.4.9 → 1.5.0),是否升级?(Y/n)"
|
|
89
|
+
4. 确认 → `upgradeConfig()` 全量替换 + 回写 variables
|
|
90
|
+
|
|
91
|
+
## dna sync 流程变化
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
当前:
|
|
95
|
+
autoDetectDNA() → 找 .dna/config.yaml → 读一个文件 → 编译
|
|
96
|
+
|
|
97
|
+
新:
|
|
98
|
+
autoDetectConfigs() → 扫描 .dna/configs/*.yaml
|
|
99
|
+
→ 读所有文件
|
|
100
|
+
→ 检测 namespace 冲突(报错)
|
|
101
|
+
→ 检测 gene 冲突(合并 + 警告)
|
|
102
|
+
→ 检测模板版本(提示升级)
|
|
103
|
+
→ cascadeDNA(all) → 合并为一个 IR
|
|
104
|
+
→ 生成全部产物
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## dna init 流程变化
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
当前:
|
|
111
|
+
dna init --template flutter-rewrite
|
|
112
|
+
→ 写 .dna/config.yaml
|
|
113
|
+
|
|
114
|
+
新:
|
|
115
|
+
dna init --template flutter-rewrite
|
|
116
|
+
→ mkdir -p .dna/configs/
|
|
117
|
+
→ 写 .dna/configs/flutter-rewrite.yaml
|
|
118
|
+
|
|
119
|
+
dna init --template secure-dev (追加第二个模板)
|
|
120
|
+
→ 检测 namespace 冲突
|
|
121
|
+
→ 写 .dna/configs/secure-dev.yaml
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## dna init --upgrade 流程变化
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
当前:
|
|
128
|
+
读 .dna/config.yaml → 升级一个
|
|
129
|
+
|
|
130
|
+
新:
|
|
131
|
+
dna init --upgrade (升级所有)
|
|
132
|
+
→ 扫描 configs/*.yaml
|
|
133
|
+
→ 逐个检测版本,逐个升级
|
|
134
|
+
|
|
135
|
+
dna init --upgrade flutter-rewrite (升级指定)
|
|
136
|
+
→ 只升级 configs/flutter-rewrite.yaml
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## 对现有功能的影响
|
|
140
|
+
|
|
141
|
+
| 功能 | 改动 |
|
|
142
|
+
|------|------|
|
|
143
|
+
| **autoDetectDNA()** | 改为 `autoDetectConfigs()`,扫描 `configs/*.yaml` |
|
|
144
|
+
| **cascadeDNA()** | 无改动(已支持多 DNA 输入) |
|
|
145
|
+
| **ir.json** | 内容更大(更多 roles/workflows),格式不变 |
|
|
146
|
+
| **hooks (dna-hook)** | 无改动(只读 ir.json) |
|
|
147
|
+
| **agents/*.md** | 无改动(namespace 前缀天然隔离) |
|
|
148
|
+
| **skills/*.md** | 无改动(namespace 前缀天然隔离) |
|
|
149
|
+
| **settings.json** | 无改动(hook 注册逻辑不变) |
|
|
150
|
+
| **CLAUDE.md** | 所有模板的 directives 合并注入 |
|
|
151
|
+
| **lock 文件** | 追踪所有 configs/*.yaml 的 hash |
|
|
152
|
+
| **dna init** | 写到 `configs/` 目录 |
|
|
153
|
+
| **dna init --upgrade** | 支持全量升级和指定升级 |
|
|
154
|
+
|
|
155
|
+
## 不做
|
|
156
|
+
|
|
157
|
+
- 向后兼容自动迁移(老项目手动 `mv config.yaml configs/`)
|
|
158
|
+
- config 间的 gene 优先级控制(MVP 用合并 + 警告)
|
|
159
|
+
- 运行时动态加载 config(全部编译时处理)
|
|
160
|
+
|
|
161
|
+
## Acceptance Criteria
|
|
162
|
+
|
|
163
|
+
- [ ] `dna init --template X` 写到 `.dna/configs/X.yaml`
|
|
164
|
+
- [ ] `dna sync` 自动扫描 `configs/*.yaml`
|
|
165
|
+
- [ ] namespace 重复 → 编译报错
|
|
166
|
+
- [ ] 同名 gene 不同模板 → codons 合并 + 警告
|
|
167
|
+
- [ ] `_template_version` 写入 config,sync 时对比提示升级
|
|
168
|
+
- [ ] `dna init --upgrade` 支持全量和指定模板
|
|
169
|
+
- [ ] 多模板编译为单一 ir.json
|
|
170
|
+
- [ ] agents/skills 文件 namespace 隔离正确
|
|
171
|
+
- [ ] lock 文件追踪所有 configs
|
|
172
|
+
- [ ] 测试覆盖:namespace 冲突、gene 合并、多模板编译、升级流程
|