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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/dist/cli/commands/init.d.ts +4 -0
- package/dist/cli/commands/init.js +24 -11
- package/dist/cli/commands/sync.d.ts +9 -4
- package/dist/cli/commands/sync.js +61 -23
- package/dist/templates/flutter-rewrite.dna.yaml +4 -0
- package/package.json +1 -1
- package/spec/template-version-namespace-fix.md +653 -0
|
@@ -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 {
|
|
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
|
-
*
|
|
69
|
+
* Calculate SHA256 hash of template content (truncated to 16 hex chars).
|
|
69
70
|
*/
|
|
70
|
-
function
|
|
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 =
|
|
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
|
|
168
|
-
const
|
|
169
|
-
|
|
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
|
-
|
|
294
|
-
const
|
|
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
|
|
74
|
-
* Returns upgrade info for
|
|
79
|
+
* Check template versions against current template content hashes.
|
|
80
|
+
* Returns upgrade info for changed configs and prints human-readable suggestions.
|
|
75
81
|
*
|
|
76
|
-
*
|
|
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
|
-
*
|
|
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
|
|
280
|
-
* Returns upgrade info for
|
|
311
|
+
* Check template versions against current template content hashes.
|
|
312
|
+
* Returns upgrade info for changed configs and prints human-readable suggestions.
|
|
281
313
|
*
|
|
282
|
-
*
|
|
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
|
-
|
|
297
|
-
|
|
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:
|
|
336
|
+
currentVersion: tmplVersion ?? "unknown",
|
|
337
|
+
availableVersion: "content-changed",
|
|
303
338
|
});
|
|
304
|
-
process.stderr.write(`Upgrade available: "${tmplName}" has no
|
|
339
|
+
process.stderr.write(`Upgrade available: "${tmplName}" has no content hash. Run: dna init --upgrade ${tmplName}\n`);
|
|
305
340
|
}
|
|
306
|
-
else if (
|
|
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:
|
|
345
|
+
currentVersion: tmplVersion ?? "unknown",
|
|
346
|
+
availableVersion: "content-changed",
|
|
313
347
|
});
|
|
314
|
-
process.stderr.write(`
|
|
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
|
@@ -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 发布
|