intentdna 1.5.13 → 1.5.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@
9
9
  {
10
10
  "name": "intentdna",
11
11
  "description": "DNA template compilation + runtime enforcement",
12
- "version": "1.5.13",
12
+ "version": "1.5.14",
13
13
  "source": "./"
14
14
  }
15
15
  ]
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "intentdna",
3
- "version": "1.5.13",
3
+ "version": "1.5.14",
4
4
  "description": "Declarative policy layer for AI agent governance"
5
5
  }
@@ -11,4 +11,8 @@ export interface InitOptions {
11
11
  register?: string;
12
12
  upgrade?: string | boolean;
13
13
  }
14
+ /**
15
+ * Calculate SHA256 hash of template content (truncated to 16 hex chars).
16
+ */
17
+ export declare function calculateTemplateHash(content: string): string;
14
18
  export declare function runInit(opts: InitOptions): Promise<number>;
@@ -6,8 +6,9 @@
6
6
  import { writeFile, readFile, readdir, mkdir, copyFile, access } from "node:fs/promises";
7
7
  import { resolve, dirname, basename } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
+ import { createHash } from "node:crypto";
9
10
  import { parseYAML } from "../../schema/yaml-parser.js";
10
- import { getPackageVersion, sanitizeTemplateName } from "../util/version.js";
11
+ import { sanitizeTemplateName } from "../util/version.js";
11
12
  import { validateDNA } from "../../schema/validate.js";
12
13
  import { askString, askChoice, askNumber, askYesNo, closePrompt } from "../util/prompt.js";
13
14
  const TEMPLATES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "templates");
@@ -65,17 +66,24 @@ async function listTemplates() {
65
66
  return results;
66
67
  }
67
68
  /**
68
- * Inject _source_template and _template_version fields after the namespace/type line in YAML content.
69
+ * Calculate SHA256 hash of template content (truncated to 16 hex chars).
69
70
  */
70
- function injectSourceTemplate(content, templateName, version) {
71
+ export function calculateTemplateHash(content) {
72
+ return createHash("sha256").update(content, "utf-8").digest("hex").slice(0, 16);
73
+ }
74
+ /**
75
+ * Inject _source_template, _template_version, and _template_content_hash fields after the namespace/type line in YAML content.
76
+ */
77
+ function injectSourceTemplate(content, templateName, templateVersion, templateHash) {
71
78
  const marker = /^(namespace:\s*.+)$/m;
72
79
  const typeMarker = /^(type:\s*.+)$/m;
73
80
  const match = content.match(marker) || content.match(typeMarker);
74
- const versionLine = version ? `\n_template_version: "${version}"` : "";
81
+ const versionLine = templateVersion ? `\n_template_version: "${templateVersion}"` : "";
82
+ const hashLine = templateHash ? `\n_template_content_hash: "${templateHash}"` : "";
75
83
  if (match) {
76
- return content.replace(match[0], `${match[0]}\n_source_template: ${templateName}${versionLine}`);
84
+ return content.replace(match[0], `${match[0]}\n_source_template: ${templateName}${versionLine}${hashLine}`);
77
85
  }
78
- return `_source_template: ${templateName}${versionLine}\n${content}`;
86
+ return `_source_template: ${templateName}${versionLine}${hashLine}\n${content}`;
79
87
  }
80
88
  /**
81
89
  * Check namespace collision against existing configs in .dna/configs/.
@@ -164,9 +172,11 @@ async function upgradeConfig(configPath, templateName) {
164
172
  const srcDir = found.source === "project" ? PROJECT_TEMPLATES_DIR : TEMPLATES_DIR;
165
173
  const srcPath = resolve(srcDir, `${found.name}.dna.yaml`);
166
174
  let newContent = await readFile(srcPath, "utf-8");
167
- // 4. Inject _source_template and _template_version
168
- const pkgVersion = await getPackageVersion();
169
- newContent = injectSourceTemplate(newContent, found.name, pkgVersion ?? undefined);
175
+ // 4. Inject _source_template, _template_version, and _template_content_hash
176
+ const templateData = parseYAML(newContent);
177
+ const templateVersion = typeof templateData.version === "string" ? templateData.version : undefined;
178
+ const templateHash = calculateTemplateHash(newContent);
179
+ newContent = injectSourceTemplate(newContent, found.name, templateVersion, templateHash);
170
180
  // 5. Replace VariableDef entries with user values where they exist
171
181
  for (const [key, value] of Object.entries(userVars)) {
172
182
  // Match multi-line VariableDef: "key:\n description: ...\n default: ..."
@@ -290,8 +300,11 @@ export async function runInit(opts) {
290
300
  const srcPath = resolve(srcDir, `${found.name}.dna.yaml`);
291
301
  const content = await readFile(srcPath, "utf-8");
292
302
  // Inject _source_template and _template_version metadata for upgrade tracking
293
- const pkgVersion = await getPackageVersion();
294
- const contentWithSource = injectSourceTemplate(content, found.name, pkgVersion ?? undefined);
303
+ // Inject _source_template, _template_version, and _template_content_hash for upgrade tracking
304
+ const templateData = parseYAML(content);
305
+ const templateVersion = typeof templateData.version === "string" ? templateData.version : undefined;
306
+ const templateHash = calculateTemplateHash(content);
307
+ const contentWithSource = injectSourceTemplate(content, found.name, templateVersion, templateHash);
295
308
  // Check namespace collision against existing configs
296
309
  const srcData = parseYAML(content);
297
310
  const srcNs = typeof srcData.namespace === "string" ? srcData.namespace : undefined;
@@ -40,6 +40,12 @@ export interface SyncOptions {
40
40
  * → ALSO write settings.json to register dna-hook events
41
41
  */
42
42
  export declare function detectMode(): Promise<"plugin" | "bin">;
43
+ /**
44
+ * Remove stale DNA-managed files from a directory.
45
+ * Only removes files matching activeNamespaces (dna-{ns}-*).
46
+ * Returns list of removed paths.
47
+ */
48
+ export declare function cleanStaleDNAFiles(dir: string, type: "agents" | "skills", activeNamespaces: string[]): Promise<string[]>;
43
49
  /**
44
50
  * Auto-detect DNA configs: multi-config (.dna/configs/*.yaml) or legacy single-config.
45
51
  * Returns array of absolute paths.
@@ -70,11 +76,10 @@ export interface TemplateUpgradeInfo {
70
76
  availableVersion: string;
71
77
  }
72
78
  /**
73
- * Check template versions against current package version.
74
- * Returns upgrade info for outdated configs and prints human-readable suggestions.
79
+ * Check template versions against current template content hashes.
80
+ * Returns upgrade info for changed configs and prints human-readable suggestions.
75
81
  *
76
- * U1: Semver-aware comparison only suggests upgrades when package is strictly newer.
77
- * Handles missing _template_version (old configs created before versioning).
82
+ * Uses content hash comparison instead of semver detects any template content change.
78
83
  */
79
84
  export declare function checkTemplateVersions(configPaths: string[]): Promise<TemplateUpgradeInfo[]>;
80
85
  export declare function runSync(opts: SyncOptions): Promise<number>;
@@ -17,7 +17,6 @@ import { fileURLToPath } from "node:url";
17
17
  import { createInterface } from "node:readline";
18
18
  import { loadDNA, compileFromFiles } from "../../compiler/index.js";
19
19
  import { parseYAML } from "../../schema/yaml-parser.js";
20
- import { getPackageVersion } from "../util/version.js";
21
20
  import { compileToMarkdown, injectIntoFile, removeFromFile } from "../../runtime/markdown.js";
22
21
  import { compileAllRolesToAgentMD, writeAgentMDFiles, removeAgentMDFiles } from "../../runtime/agent-md.js";
23
22
  import { removeWorkflowScripts } from "../../runtime/workflow-runner.js";
@@ -72,6 +71,32 @@ const LEGACY_BASH_HOOKS = [
72
71
  "dna-pre-compact.sh",
73
72
  "dna-notification.sh",
74
73
  ];
74
+ const TEMPLATES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "templates");
75
+ function calculateTemplateHash(content) {
76
+ return createHash("sha256").update(content, "utf-8").digest("hex").slice(0, 16);
77
+ }
78
+ async function findTemplateFile(templateName) {
79
+ // Try direct filename match first
80
+ const directPath = resolve(TEMPLATES_DIR, `${templateName}.dna.yaml`);
81
+ if (await fileExists(directPath))
82
+ return directPath;
83
+ // Fallback: search by id or name field
84
+ try {
85
+ const files = await readdir(TEMPLATES_DIR);
86
+ for (const file of files) {
87
+ if (!file.endsWith(".dna.yaml") && !file.endsWith(".dna.yml"))
88
+ continue;
89
+ const fullPath = resolve(TEMPLATES_DIR, file);
90
+ const content = await readFile(fullPath, "utf-8");
91
+ const data = parseYAML(content);
92
+ if (data.id === templateName || data.name === templateName) {
93
+ return fullPath;
94
+ }
95
+ }
96
+ }
97
+ catch { /* fail-open */ }
98
+ return null;
99
+ }
75
100
  /**
76
101
  * Remove legacy bash hook scripts written by previous Intent DNA versions.
77
102
  * Only removes files that contain the sentinel comment.
@@ -93,15 +118,22 @@ async function removeLegacyBashHooks(hooksDir) {
93
118
  }
94
119
  /**
95
120
  * Remove stale DNA-managed files from a directory.
96
- * Scans for files/dirs containing the sentinel marker and deletes them.
121
+ * Only removes files matching activeNamespaces (dna-{ns}-*).
97
122
  * Returns list of removed paths.
98
123
  */
99
- async function cleanStaleDNAFiles(dir, type) {
124
+ export async function cleanStaleDNAFiles(dir, type, activeNamespaces) {
100
125
  const removed = [];
101
126
  try {
102
127
  const entries = await readdir(dir, { withFileTypes: true });
103
128
  for (const entry of entries) {
104
129
  const fullPath = resolve(dir, entry.name);
130
+ // Extract namespace from filename: dna-{namespace}-*
131
+ const nsMatch = entry.name.match(/^dna-([a-z0-9_-]+)-/);
132
+ const fileNamespace = nsMatch ? nsMatch[1] : null;
133
+ // Skip if namespace doesn't match active namespaces
134
+ if (!fileNamespace || !activeNamespaces.includes(fileNamespace)) {
135
+ continue;
136
+ }
105
137
  if (type === "agents" && entry.isFile() && entry.name.endsWith(".md")) {
106
138
  const content = await readFile(fullPath, "utf-8").catch(() => "");
107
139
  if (content.includes(DNA_SENTINEL)) {
@@ -276,42 +308,44 @@ export function compareSemver(a, b) {
276
308
  return 0;
277
309
  }
278
310
  /**
279
- * Check template versions against current package version.
280
- * Returns upgrade info for outdated configs and prints human-readable suggestions.
311
+ * Check template versions against current template content hashes.
312
+ * Returns upgrade info for changed configs and prints human-readable suggestions.
281
313
  *
282
- * U1: Semver-aware comparison only suggests upgrades when package is strictly newer.
283
- * Handles missing _template_version (old configs created before versioning).
314
+ * Uses content hash comparison instead of semver detects any template content change.
284
315
  */
285
316
  export async function checkTemplateVersions(configPaths) {
286
- const pkgVersion = await getPackageVersion();
287
- if (!pkgVersion)
288
- return [];
289
317
  const upgrades = [];
290
318
  for (const configPath of configPaths) {
291
319
  try {
292
320
  const content = await readFile(configPath, "utf-8");
293
321
  const data = parseYAML(content);
294
- const tmplVersion = typeof data._template_version === "string" ? data._template_version : undefined;
295
322
  const tmplName = typeof data._source_template === "string" ? data._source_template : undefined;
296
- if (tmplName && !tmplVersion) {
297
- // Old config without version tracking suggest upgrade
323
+ const tmplHash = typeof data._template_content_hash === "string" ? data._template_content_hash : undefined;
324
+ const tmplVersion = typeof data._template_version === "string" ? data._template_version : undefined;
325
+ if (!tmplName)
326
+ continue;
327
+ const templatePath = await findTemplateFile(tmplName);
328
+ if (!templatePath)
329
+ continue;
330
+ const currentTemplateContent = await readFile(templatePath, "utf-8");
331
+ const currentHash = calculateTemplateHash(currentTemplateContent);
332
+ if (!tmplHash) {
298
333
  upgrades.push({
299
334
  configPath,
300
335
  templateName: tmplName,
301
- currentVersion: "unknown",
302
- availableVersion: pkgVersion,
336
+ currentVersion: tmplVersion ?? "unknown",
337
+ availableVersion: "content-changed",
303
338
  });
304
- process.stderr.write(`Upgrade available: "${tmplName}" has no version tag. Run: dna init --upgrade ${tmplName}\n`);
339
+ process.stderr.write(`Upgrade available: "${tmplName}" has no content hash. Run: dna init --upgrade ${tmplName}\n`);
305
340
  }
306
- else if (tmplVersion && tmplName && compareSemver(pkgVersion, tmplVersion) > 0) {
307
- // Package is strictly newer than template — suggest upgrade
341
+ else if (currentHash !== tmplHash) {
308
342
  upgrades.push({
309
343
  configPath,
310
344
  templateName: tmplName,
311
- currentVersion: tmplVersion,
312
- availableVersion: pkgVersion,
345
+ currentVersion: tmplVersion ?? "unknown",
346
+ availableVersion: "content-changed",
313
347
  });
314
- process.stderr.write(`Upgrade available: "${tmplName}" ${tmplVersion} → ${pkgVersion}. Run: dna init --upgrade ${tmplName}\n`);
348
+ process.stderr.write(`Template content changed: "${tmplName}". Run: dna init --upgrade ${tmplName}\n`);
315
349
  }
316
350
  }
317
351
  catch {
@@ -544,10 +578,14 @@ export async function runSync(opts) {
544
578
  else if (opts.plugin && opts.settingsPath) {
545
579
  process.stderr.write(`Plugin mode: hooks managed by plugin framework (skipping settings.json)\n`);
546
580
  }
581
+ // Extract active namespaces from loaded DNAs for namespace-scoped cleanup
582
+ const activeNamespaces = loadedDNAs
583
+ .map(d => d.namespace)
584
+ .filter((ns) => typeof ns === "string");
547
585
  // Step 5: Generate agent MD files
548
586
  if (opts.agentsDir) {
549
587
  // Clean stale DNA-managed agents before regenerating
550
- const staleAgents = await cleanStaleDNAFiles(opts.agentsDir, "agents");
588
+ const staleAgents = await cleanStaleDNAFiles(opts.agentsDir, "agents", activeNamespaces);
551
589
  if (staleAgents.length > 0) {
552
590
  process.stderr.write(`Cleaned ${staleAgents.length} stale agent file(s)\n`);
553
591
  }
@@ -608,7 +646,7 @@ export async function runSync(opts) {
608
646
  // Step 7: Generate skill files from workflows
609
647
  if (opts.skillsDir) {
610
648
  // Clean stale DNA-managed skills before regenerating
611
- const staleSkills = await cleanStaleDNAFiles(opts.skillsDir, "skills");
649
+ const staleSkills = await cleanStaleDNAFiles(opts.skillsDir, "skills", activeNamespaces);
612
650
  if (staleSkills.length > 0) {
613
651
  process.stderr.write(`Cleaned ${staleSkills.length} stale skill dir(s)\n`);
614
652
  }
@@ -324,6 +324,10 @@ workflows:
324
324
  - Do NOT use `sleep N && check` polling — run commands in foreground
325
325
  - Do NOT use `timeout Nm flutter test` — let tests run to completion
326
326
  - Do NOT rewrite existing test files from scratch — update incrementally
327
+
328
+ Report results and STOP.
329
+
330
+ Next step for user: Run /dna-frw-diagnosis $ARGUMENTS to analyze failing tests.
327
331
  handoff:
328
332
  consumes:
329
333
  - type: file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "intentdna",
3
- "version": "1.5.13",
3
+ "version": "1.5.14",
4
4
  "description": "Intent DNA — Declarative policy layer for AI agent behavior",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,653 @@
1
+ # Template Version + Namespace Cleanup Fix
2
+
3
+ ## Metadata
4
+ - Generated: 2026-04-19
5
+ - Status: APPROVED
6
+ - 预计工期: 2-3 小时
7
+ - 发版类型: patch
8
+
9
+ ## 背景
10
+
11
+ 两个独立 bug 需要一起修:
12
+
13
+ ### Bug 1: _template_version 跟 package 版本走
14
+ **问题**: 改了 flutter-rewrite 模板 → 发版 1.5.13 → 所有模板的 _template_version 都变成 1.5.13,但只有 flutter-rewrite 真正改了内容。
15
+
16
+ **根因**: `injectSourceTemplate()` 用 `getPackageVersion()` 而不是模板自身的 `version` 字段。
17
+
18
+ ### Bug 2: cleanStaleDNAFiles 不区分 namespace
19
+ **问题**: 项目用 frw + be 两个模板,`dna sync frw` 会把 be 的 agent/skill 也删掉。
20
+
21
+ **根因**: `cleanStaleDNAFiles()` 只看 `intentdna:managed` 标记,不看 namespace。
22
+
23
+ ---
24
+
25
+ ## 方案
26
+
27
+ ### 方案 A: Template Content Hash Tracking
28
+
29
+ 每个模板用 content hash 追踪版本,不依赖手动 bump version 字段。
30
+
31
+ **流程**:
32
+ 1. `dna init <template>` 时:
33
+ - 计算模板文件内容的 SHA256 hash
34
+ - 写入项目 config: `_template_content_hash: "abc123..."`
35
+ - 保留 `_template_version` 字段(从模板的 `version` 读取,不是 pkg 版本)
36
+
37
+ 2. `dna sync` 时:
38
+ - 读当前模板文件,计算 hash
39
+ - 对比项目 config 的 `_template_content_hash`
40
+ - 不同 → 提示 "Template content changed. Run: dna init --upgrade <template>"
41
+
42
+ 3. `dna init --upgrade <template>` 时:
43
+ - 更新 `_template_content_hash` 为新 hash
44
+ - 更新 `_template_version` 为模板的 `version` 字段
45
+
46
+ **优点**:
47
+ - 不依赖手动 bump version
48
+ - 任何模板内容变化都能检测到
49
+ - 不同模板独立追踪
50
+
51
+ **缺点**:
52
+ - 用户手动改了项目 config(加自定义 gene)也会触发"模板变化"提示
53
+ - 需要明确告知用户:项目 config 是"模板实例",改动后不再追踪上游模板更新
54
+
55
+ ### 方案 B: Namespace-aware Cleanup
56
+
57
+ `cleanStaleDNAFiles()` 只删当前 sync 的 namespace 对应的文件。
58
+
59
+ **实现**:
60
+ ```typescript
61
+ // 当前
62
+ async function cleanStaleDNAFiles(dir: string, type: "agent" | "skill"): Promise<number>
63
+
64
+ // 修改后
65
+ async function cleanStaleDNAFiles(
66
+ dir: string,
67
+ type: "agent" | "skill",
68
+ activeNamespaces: string[] // 当前 sync 的模板的 namespace 列表
69
+ ): Promise<number>
70
+ ```
71
+
72
+ **逻辑**:
73
+ 1. 扫描 `.claude/agents/` 或 `.claude/skills/`
74
+ 2. 对每个文件/目录:
75
+ - 读内容检查 `intentdna:managed` 标记
76
+ - 如果有标记 → 提取文件名前缀(`dna-frw-*` → `frw`)
77
+ - 如果前缀在 `activeNamespaces` 中 → 删除
78
+ - 否则保留
79
+
80
+ **文件名规则**:
81
+ - Agent MD: `dna-{namespace}-{role}.md`
82
+ - Skill dir: `dna-{namespace}-{workflow}/`
83
+
84
+ **边界情况**:
85
+ - 文件名不符合 `dna-{ns}-*` 格式 → 保留(可能是用户手写的)
86
+ - 没有 `intentdna:managed` 标记 → 保留
87
+ - namespace 不在 activeNamespaces → 保留
88
+
89
+ ---
90
+
91
+ ## 实施步骤
92
+
93
+ ### Step 1: Template Content Hash (Bug 1)
94
+
95
+ #### 1.1 新增 hash 计算函数
96
+
97
+ 文件:`src/cli/commands/init.ts`
98
+
99
+ ```typescript
100
+ import { createHash } from "crypto";
101
+
102
+ /**
103
+ * Calculate SHA256 hash of template content.
104
+ * Used for tracking template changes independent of version field.
105
+ */
106
+ function calculateTemplateHash(content: string): string {
107
+ return createHash("sha256").update(content, "utf-8").digest("hex").slice(0, 16);
108
+ }
109
+ ```
110
+
111
+ #### 1.2 修改 injectSourceTemplate
112
+
113
+ 文件:`src/cli/commands/init.ts:91-100`
114
+
115
+ ```typescript
116
+ // 之前
117
+ function injectSourceTemplate(content: string, templateName: string, version?: string): string {
118
+ const marker = /^(namespace:\s*.+)$/m;
119
+ const typeMarker = /^(type:\s*.+)$/m;
120
+ const match = content.match(marker) || content.match(typeMarker);
121
+ const versionLine = version ? `\n_template_version: "${version}"` : "";
122
+ if (match) {
123
+ return content.replace(match[0], `${match[0]}\n_source_template: ${templateName}${versionLine}`);
124
+ }
125
+ return `_source_template: ${templateName}${versionLine}\n${content}`;
126
+ }
127
+
128
+ // 之后
129
+ function injectSourceTemplate(
130
+ content: string,
131
+ templateName: string,
132
+ templateVersion?: string,
133
+ templateHash?: string
134
+ ): string {
135
+ const marker = /^(namespace:\s*.+)$/m;
136
+ const typeMarker = /^(type:\s*.+)$/m;
137
+ const match = content.match(marker) || content.match(typeMarker);
138
+
139
+ const versionLine = templateVersion ? `\n_template_version: "${templateVersion}"` : "";
140
+ const hashLine = templateHash ? `\n_template_content_hash: "${templateHash}"` : "";
141
+ const metadata = `\n_source_template: ${templateName}${versionLine}${hashLine}`;
142
+
143
+ if (match) {
144
+ return content.replace(match[0], `${match[0]}${metadata}`);
145
+ }
146
+ return `_source_template: ${templateName}${versionLine}${hashLine}\n${content}`;
147
+ }
148
+ ```
149
+
150
+ #### 1.3 修改 init 命令调用点
151
+
152
+ 文件:`src/cli/commands/init.ts`
153
+
154
+ 找到两处 `injectSourceTemplate()` 调用:
155
+
156
+ **调用点 1(约 194 行,--upgrade 路径)**:
157
+ ```typescript
158
+ // 之前
159
+ const pkgVersion = await getPackageVersion();
160
+ newContent = injectSourceTemplate(newContent, found.name, pkgVersion ?? undefined);
161
+
162
+ // 之后
163
+ const templateData = parseYAML(newContent) as Record<string, unknown>;
164
+ const templateVersion = typeof templateData.version === "string" ? templateData.version : undefined;
165
+ const templateHash = calculateTemplateHash(newContent);
166
+ newContent = injectSourceTemplate(newContent, found.name, templateVersion, templateHash);
167
+ ```
168
+
169
+ **调用点 2(约 340 行,新建路径)**:
170
+ ```typescript
171
+ // 之前
172
+ const pkgVersion = await getPackageVersion();
173
+ const contentWithSource = injectSourceTemplate(content, found.name, pkgVersion ?? undefined);
174
+
175
+ // 之后
176
+ const templateData = parseYAML(content) as Record<string, unknown>;
177
+ const templateVersion = typeof templateData.version === "string" ? templateData.version : undefined;
178
+ const templateHash = calculateTemplateHash(content);
179
+ const contentWithSource = injectSourceTemplate(content, found.name, templateVersion, templateHash);
180
+ ```
181
+
182
+ #### 1.4 修改 checkTemplateVersions
183
+
184
+ 文件:`src/cli/commands/sync.ts:334-370`
185
+
186
+ ```typescript
187
+ // 之前
188
+ export async function checkTemplateVersions(configPaths: string[]): Promise<TemplateUpgradeInfo[]> {
189
+ const pkgVersion = await getPackageVersion();
190
+ if (!pkgVersion) return [];
191
+
192
+ const upgrades: TemplateUpgradeInfo[] = [];
193
+
194
+ for (const configPath of configPaths) {
195
+ try {
196
+ const content = await readFile(configPath, "utf-8");
197
+ const data = parseYAML(content) as Record<string, unknown>;
198
+ const tmplVersion = typeof data._template_version === "string" ? data._template_version : undefined;
199
+ const tmplName = typeof data._source_template === "string" ? data._source_template : undefined;
200
+
201
+ if (tmplName && !tmplVersion) {
202
+ // Old config without version tracking — suggest upgrade
203
+ upgrades.push({
204
+ configPath,
205
+ templateName: tmplName,
206
+ currentVersion: "unknown",
207
+ availableVersion: pkgVersion,
208
+ });
209
+ process.stderr.write(
210
+ `Upgrade available: "${tmplName}" has no version tag. Run: dna init --upgrade ${tmplName}\n`,
211
+ );
212
+ } else if (tmplVersion && tmplName && compareSemver(pkgVersion, tmplVersion) > 0) {
213
+ // Package is strictly newer than template — suggest upgrade
214
+ upgrades.push({
215
+ configPath,
216
+ templateName: tmplName,
217
+ currentVersion: tmplVersion,
218
+ availableVersion: pkgVersion,
219
+ });
220
+ process.stderr.write(
221
+ `Upgrade available: "${tmplName}" ${tmplVersion} → ${pkgVersion}. Run: dna init --upgrade ${tmplName}\n`,
222
+ );
223
+ }
224
+ } catch {
225
+ // Fail-open
226
+ }
227
+ }
228
+
229
+ return upgrades;
230
+ }
231
+
232
+ // 之后
233
+ export async function checkTemplateVersions(configPaths: string[]): Promise<TemplateUpgradeInfo[]> {
234
+ const upgrades: TemplateUpgradeInfo[] = [];
235
+
236
+ for (const configPath of configPaths) {
237
+ try {
238
+ const content = await readFile(configPath, "utf-8");
239
+ const data = parseYAML(content) as Record<string, unknown>;
240
+ const tmplName = typeof data._source_template === "string" ? data._source_template : undefined;
241
+ const tmplHash = typeof data._template_content_hash === "string" ? data._template_content_hash : undefined;
242
+ const tmplVersion = typeof data._template_version === "string" ? data._template_version : undefined;
243
+
244
+ if (!tmplName) continue;
245
+
246
+ // Find current template file
247
+ const templatePath = await findTemplateFile(tmplName);
248
+ if (!templatePath) continue;
249
+
250
+ const currentTemplateContent = await readFile(templatePath, "utf-8");
251
+ const currentHash = calculateTemplateHash(currentTemplateContent);
252
+
253
+ if (!tmplHash) {
254
+ // Old config without hash tracking — suggest upgrade
255
+ upgrades.push({
256
+ configPath,
257
+ templateName: tmplName,
258
+ currentVersion: tmplVersion ?? "unknown",
259
+ availableVersion: "content-changed",
260
+ });
261
+ process.stderr.write(
262
+ `Upgrade available: "${tmplName}" has no content hash. Run: dna init --upgrade ${tmplName}\n`,
263
+ );
264
+ } else if (currentHash !== tmplHash) {
265
+ // Template content changed
266
+ upgrades.push({
267
+ configPath,
268
+ templateName: tmplName,
269
+ currentVersion: tmplVersion ?? "unknown",
270
+ availableVersion: "content-changed",
271
+ });
272
+ process.stderr.write(
273
+ `Template content changed: "${tmplName}". Run: dna init --upgrade ${tmplName}\n`,
274
+ );
275
+ }
276
+ } catch {
277
+ // Fail-open
278
+ }
279
+ }
280
+
281
+ return upgrades;
282
+ }
283
+
284
+ /**
285
+ * Find template file path by template name.
286
+ * Searches in src/templates/ directory.
287
+ */
288
+ async function findTemplateFile(templateName: string): Promise<string | null> {
289
+ const templatesDir = resolve(__dirname, "..", "..", "templates");
290
+ try {
291
+ const files = await readdir(templatesDir);
292
+ for (const file of files) {
293
+ if (file.endsWith(".dna.yaml") || file.endsWith(".dna.yml")) {
294
+ const fullPath = resolve(templatesDir, file);
295
+ const content = await readFile(fullPath, "utf-8");
296
+ const data = parseYAML(content) as Record<string, unknown>;
297
+ const id = typeof data.id === "string" ? data.id : undefined;
298
+ const name = typeof data.name === "string" ? data.name : undefined;
299
+ if (id === templateName || name === templateName) {
300
+ return fullPath;
301
+ }
302
+ }
303
+ }
304
+ } catch {
305
+ // Fail-open
306
+ }
307
+ return null;
308
+ }
309
+ ```
310
+
311
+ 需要在文件顶部 import:
312
+ ```typescript
313
+ import { createHash } from "crypto";
314
+ ```
315
+
316
+ 并把 `calculateTemplateHash` 函数从 init.ts 移到共享位置或复制一份。
317
+
318
+ ---
319
+
320
+ ### Step 2: Namespace-aware Cleanup (Bug 2)
321
+
322
+ #### 2.1 修改 cleanStaleDNAFiles 签名
323
+
324
+ 文件:`src/cli/commands/sync.ts:126-141`
325
+
326
+ ```typescript
327
+ // 之前
328
+ async function cleanStaleDNAFiles(dir: string, type: "agent" | "skill"): Promise<number>
329
+
330
+ // 之后
331
+ async function cleanStaleDNAFiles(
332
+ dir: string,
333
+ type: "agent" | "skill",
334
+ activeNamespaces: string[]
335
+ ): Promise<number>
336
+ ```
337
+
338
+ #### 2.2 修改 cleanStaleDNAFiles 实现
339
+
340
+ ```typescript
341
+ async function cleanStaleDNAFiles(
342
+ dir: string,
343
+ type: "agent" | "skill",
344
+ activeNamespaces: string[]
345
+ ): Promise<number> {
346
+ let removed = 0;
347
+ try {
348
+ const entries = await readdir(dir, { withFileTypes: true });
349
+ for (const entry of entries) {
350
+ const fullPath = resolve(dir, entry.name);
351
+
352
+ // Extract namespace from filename
353
+ // Agent: dna-{ns}-{role}.md
354
+ // Skill: dna-{ns}-{workflow}/
355
+ const match = entry.name.match(/^dna-([a-z0-9_-]+)-/);
356
+ if (!match) continue; // Not DNA-managed naming pattern
357
+
358
+ const namespace = match[1];
359
+ if (!activeNamespaces.includes(namespace)) {
360
+ // Not in current sync scope, skip
361
+ continue;
362
+ }
363
+
364
+ // Check for intentdna:managed sentinel
365
+ let hasManaged = false;
366
+ if (entry.isFile()) {
367
+ const content = await readFile(fullPath, "utf-8");
368
+ hasManaged = content.includes("intentdna:managed");
369
+ } else if (entry.isDirectory()) {
370
+ // Check SKILL.md inside skill directory
371
+ const skillMd = resolve(fullPath, "SKILL.md");
372
+ try {
373
+ const content = await readFile(skillMd, "utf-8");
374
+ hasManaged = content.includes("intentdna:managed");
375
+ } catch {
376
+ // No SKILL.md or unreadable
377
+ }
378
+ }
379
+
380
+ if (hasManaged) {
381
+ if (entry.isDirectory()) {
382
+ await rm(fullPath, { recursive: true, force: true });
383
+ } else {
384
+ await unlink(fullPath);
385
+ }
386
+ removed++;
387
+ }
388
+ }
389
+ } catch {
390
+ // Fail-open
391
+ }
392
+ return removed;
393
+ }
394
+ ```
395
+
396
+ #### 2.3 修改调用点
397
+
398
+ 文件:`src/cli/commands/sync.ts`
399
+
400
+ 找到 `cleanStaleDNAFiles` 的调用点(约 600-650 行),传入 activeNamespaces:
401
+
402
+ ```typescript
403
+ // 之前
404
+ if (opts.agentsDir) {
405
+ const removed = await cleanStaleDNAFiles(opts.agentsDir, "agent");
406
+ if (removed > 0) process.stderr.write(`Cleaned ${removed} stale agent file(s)\n`);
407
+ }
408
+ if (opts.skillsDir) {
409
+ const removed = await cleanStaleDNAFiles(opts.skillsDir, "skill");
410
+ if (removed > 0) process.stderr.write(`Cleaned ${removed} stale skill dir(s)\n`);
411
+ }
412
+
413
+ // 之后
414
+ // Extract namespaces from all loaded DNAs
415
+ const activeNamespaces = dnas
416
+ .map(d => typeof d.namespace === "string" ? d.namespace : null)
417
+ .filter((ns): ns is string => ns !== null);
418
+
419
+ if (opts.agentsDir) {
420
+ const removed = await cleanStaleDNAFiles(opts.agentsDir, "agent", activeNamespaces);
421
+ if (removed > 0) process.stderr.write(`Cleaned ${removed} stale agent file(s)\n`);
422
+ }
423
+ if (opts.skillsDir) {
424
+ const removed = await cleanStaleDNAFiles(opts.skillsDir, "skill", activeNamespaces);
425
+ if (removed > 0) process.stderr.write(`Cleaned ${removed} stale skill dir(s)\n`);
426
+ }
427
+ ```
428
+
429
+ ---
430
+
431
+ ### Step 3: 测试
432
+
433
+ #### 3.1 单元测试
434
+
435
+ 新建 `test/template-version-hash.test.ts`:
436
+
437
+ ```typescript
438
+ import { describe, it, expect } from "vitest";
439
+ import { calculateTemplateHash } from "../src/cli/commands/init.js";
440
+
441
+ describe("calculateTemplateHash", () => {
442
+ it("returns consistent hash for same content", () => {
443
+ const content = "namespace: test\nversion: 0.1.0";
444
+ const hash1 = calculateTemplateHash(content);
445
+ const hash2 = calculateTemplateHash(content);
446
+ expect(hash1).toBe(hash2);
447
+ });
448
+
449
+ it("returns different hash for different content", () => {
450
+ const content1 = "namespace: test\nversion: 0.1.0";
451
+ const content2 = "namespace: test\nversion: 0.2.0";
452
+ const hash1 = calculateTemplateHash(content1);
453
+ const hash2 = calculateTemplateHash(content2);
454
+ expect(hash1).not.toBe(hash2);
455
+ });
456
+
457
+ it("returns 16-char hex string", () => {
458
+ const hash = calculateTemplateHash("test");
459
+ expect(hash).toMatch(/^[a-f0-9]{16}$/);
460
+ });
461
+ });
462
+ ```
463
+
464
+ 新建 `test/namespace-cleanup.test.ts`:
465
+
466
+ ```typescript
467
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
468
+ import { mkdir, writeFile, readdir, rm } from "fs/promises";
469
+ import { resolve } from "path";
470
+ import { cleanStaleDNAFiles } from "../src/cli/commands/sync.js";
471
+
472
+ describe("cleanStaleDNAFiles namespace filtering", () => {
473
+ const testDir = resolve(__dirname, ".test-cleanup");
474
+ const agentsDir = resolve(testDir, "agents");
475
+
476
+ beforeEach(async () => {
477
+ await mkdir(agentsDir, { recursive: true });
478
+ });
479
+
480
+ afterEach(async () => {
481
+ await rm(testDir, { recursive: true, force: true });
482
+ });
483
+
484
+ it("only removes files matching active namespaces", async () => {
485
+ // Create files for two namespaces
486
+ await writeFile(
487
+ resolve(agentsDir, "dna-frw-surgeon.md"),
488
+ "<!-- intentdna:managed -->\nFRW surgeon"
489
+ );
490
+ await writeFile(
491
+ resolve(agentsDir, "dna-be-handler.md"),
492
+ "<!-- intentdna:managed -->\nBE handler"
493
+ );
494
+
495
+ // Clean only frw namespace
496
+ const removed = await cleanStaleDNAFiles(agentsDir, "agent", ["frw"]);
497
+
498
+ expect(removed).toBe(1);
499
+ const remaining = await readdir(agentsDir);
500
+ expect(remaining).toEqual(["dna-be-handler.md"]);
501
+ });
502
+
503
+ it("preserves files without intentdna:managed", async () => {
504
+ await writeFile(
505
+ resolve(agentsDir, "dna-frw-custom.md"),
506
+ "User-written agent"
507
+ );
508
+
509
+ const removed = await cleanStaleDNAFiles(agentsDir, "agent", ["frw"]);
510
+
511
+ expect(removed).toBe(0);
512
+ const remaining = await readdir(agentsDir);
513
+ expect(remaining).toEqual(["dna-frw-custom.md"]);
514
+ });
515
+
516
+ it("preserves files not matching dna-{ns}- pattern", async () => {
517
+ await writeFile(
518
+ resolve(agentsDir, "custom-agent.md"),
519
+ "<!-- intentdna:managed -->\nCustom"
520
+ );
521
+
522
+ const removed = await cleanStaleDNAFiles(agentsDir, "agent", ["frw"]);
523
+
524
+ expect(removed).toBe(0);
525
+ const remaining = await readdir(agentsDir);
526
+ expect(remaining).toEqual(["custom-agent.md"]);
527
+ });
528
+ });
529
+ ```
530
+
531
+ #### 3.2 集成测试
532
+
533
+ 在 intentdna 项目:
534
+ ```bash
535
+ npm test # 全绿
536
+ ```
537
+
538
+ 在 lwk_flutter_v2-rewrite 项目(单模板场景):
539
+ ```bash
540
+ dna sync
541
+ # 验证:不报错,agent/skill 正常生成
542
+ ```
543
+
544
+ 创建测试项目(多模板场景):
545
+ ```bash
546
+ mkdir /tmp/multi-template-test && cd /tmp/multi-template-test
547
+ dna init flutter-rewrite
548
+ dna init backend-api # 假设有这个模板,或用另一个
549
+ dna sync
550
+ # 验证:两个模板的 agent/skill 都保留
551
+ ```
552
+
553
+ ---
554
+
555
+ ### Step 4: 文档更新
556
+
557
+ #### 4.1 CHANGELOG.md
558
+
559
+ ```markdown
560
+ ## [1.5.14] - 2026-04-19
561
+
562
+ ### Fixed
563
+ - Template version tracking now uses template's own `version` field instead of package version
564
+ - Added `_template_content_hash` to detect template changes independent of version bumps
565
+ - `dna sync` now only cleans stale files from active namespaces, fixing multi-template projects
566
+ - Namespace-aware cleanup prevents accidental deletion of other templates' generated files
567
+
568
+ ### Changed
569
+ - `dna init` now injects `_template_content_hash` alongside `_template_version`
570
+ - `dna sync` detects template content changes via hash comparison
571
+ ```
572
+
573
+ #### 4.2 docs/multi-template.md(新建)
574
+
575
+ ```markdown
576
+ # Multi-Template Projects
577
+
578
+ Intent DNA supports using multiple templates in a single project.
579
+
580
+ ## Setup
581
+
582
+ ```bash
583
+ dna init flutter-rewrite
584
+ dna init backend-api
585
+ ```
586
+
587
+ Each template must have a unique `namespace` field. Namespace collisions are detected and blocked.
588
+
589
+ ## Sync Behavior
590
+
591
+ `dna sync` processes all templates in `.dna/configs/*.yaml` and only cleans files belonging to active namespaces.
592
+
593
+ Example:
594
+ - `dna-frw-*` files belong to `frw` namespace
595
+ - `dna-be-*` files belong to `be` namespace
596
+ - Syncing `frw` template will not delete `be` files
597
+
598
+ ## Template Updates
599
+
600
+ Each template tracks its content independently via `_template_content_hash`. When template content changes:
601
+
602
+ ```bash
603
+ dna sync
604
+ # Output: Template content changed: "flutter-rewrite". Run: dna init --upgrade flutter-rewrite
605
+ ```
606
+
607
+ Run upgrade to update:
608
+ ```bash
609
+ dna init --upgrade flutter-rewrite
610
+ ```
611
+ ```
612
+
613
+ ---
614
+
615
+ ### Step 5: 发版
616
+
617
+ ```bash
618
+ cd /Users/samuel/lawark/intentdna
619
+ npm test # 全绿
620
+ /dna-release patch "fix: template version tracking + namespace-aware cleanup for multi-template projects"
621
+ ```
622
+
623
+ ---
624
+
625
+ ## 验收清单
626
+
627
+ ### Bug 1: Template Version
628
+ - [ ] `calculateTemplateHash()` 实现
629
+ - [ ] `injectSourceTemplate()` 接受 templateVersion + templateHash 参数
630
+ - [ ] init 命令两处调用点更新(读模板 version 字段,不读 pkg 版本)
631
+ - [ ] `checkTemplateVersions()` 改为 hash 对比
632
+ - [ ] `findTemplateFile()` 辅助函数实现
633
+ - [ ] 单元测试通过
634
+
635
+ ### Bug 2: Namespace Cleanup
636
+ - [ ] `cleanStaleDNAFiles()` 接受 activeNamespaces 参数
637
+ - [ ] 文件名 namespace 提取逻辑(`dna-{ns}-*` 正则)
638
+ - [ ] 只删匹配 namespace 的文件
639
+ - [ ] sync 调用点传入 activeNamespaces
640
+ - [ ] 单元测试通过
641
+
642
+ ### 集成验证
643
+ - [ ] intentdna 项目 npm test 全绿
644
+ - [ ] lwk_flutter_v2-rewrite dna sync 正常
645
+ - [ ] 多模板测试项目验证(如果有第二个模板)
646
+
647
+ ### 文档
648
+ - [ ] CHANGELOG.md 更新
649
+ - [ ] docs/multi-template.md 新建(可选)
650
+
651
+ ### 发版
652
+ - [ ] /dna-release patch 执行
653
+ - [ ] v1.5.14 发布