@haaaiawd/loom 0.9.0 → 1.0.0

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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +87 -52
  3. package/cli/bin/loom.js +438 -149
  4. package/cli/help/concepts.md +93 -72
  5. package/cli/help/doctor.md +71 -121
  6. package/cli/help/loop.md +120 -135
  7. package/cli/help/patch.md +33 -0
  8. package/cli/help/preview.md +2 -1
  9. package/cli/help/version.md +92 -16
  10. package/cli/help/workflow.md +89 -100
  11. package/cli/src/activate.js +302 -73
  12. package/cli/src/auto.js +41 -18
  13. package/cli/src/diagnostics.js +223 -50
  14. package/cli/src/guide.js +127 -38
  15. package/cli/src/init.js +50 -29
  16. package/cli/src/intent-draft.js +303 -0
  17. package/cli/src/intent-map.js +540 -54
  18. package/cli/src/patch.js +214 -0
  19. package/cli/src/philosophy.js +181 -156
  20. package/cli/src/preview-prompt.md +13 -6
  21. package/cli/src/preview.js +1 -0
  22. package/cli/src/shared/intent-ref.js +38 -0
  23. package/cli/src/shared/proof-reference.js +19 -0
  24. package/cli/src/shared/verification-method.js +32 -0
  25. package/cli/src/verify.js +204 -51
  26. package/cli/src/version.js +5 -4
  27. package/dimensions/PART_DECOMPOSITION.md +42 -203
  28. package/dimensions/SEARCH_METHODOLOGY.md +101 -97
  29. package/dimensions/examples/AGENT_SYSTEM/README.md +1 -1
  30. package/dimensions/examples/CLI_TOOL/README.md +1 -1
  31. package/dimensions/universal/COLLABORATION_PHILOSOPHY.md +28 -77
  32. package/dimensions/universal/ENGINEERING_CREED.md +30 -74
  33. package/dimensions/universal/PRODUCT_PHILOSOPHY.md +32 -70
  34. package/meta/BASELINE.md +91 -276
  35. package/meta/INTENT_LOOP.md +242 -737
  36. package/meta/PHILOSOPHY_WEAVER.md +110 -343
  37. package/meta/ROLE_ACTIVATION.md +103 -267
  38. package/package.json +4 -3
  39. package/roles/architect.md +71 -111
  40. package/roles/forge.md +87 -126
  41. package/roles/keeper.md +99 -223
  42. package/roles/visionary.md +57 -86
  43. package/templates/INTENT_MAP_TEMPLATE.json +24 -10
  44. package/templates/PHILOSOPHY_TEMPLATE.md +44 -75
  45. package/templates/VISION_TEMPLATE.md +44 -67
package/cli/src/init.js CHANGED
@@ -1,10 +1,11 @@
1
1
  // init — 初始化 LOOM 项目目录结构
2
2
  // 创建 .loom/v1/ 骨架 + 复制模板文件
3
3
 
4
- import { mkdirSync, existsSync, copyFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
4
+ import { mkdirSync, existsSync, copyFileSync, readdirSync, statSync, writeFileSync, readFileSync } from 'node:fs';
5
5
  import { join, dirname, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
- import { getLoomRoot } from './shared/paths.js';
7
+ import { getLoomRoot } from './shared/paths.js';
8
+ import { scaffoldChangelog } from './patch.js';
8
9
 
9
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
11
 
@@ -30,9 +31,10 @@ function copyDir(src, dst) {
30
31
  * 共享给 init(v1)和 version new(v{N+1})。
31
32
  * @param {string} projectDir — 项目根目录
32
33
  * @param {string|number} version — 版本号,如 'v1' 或 'v2' 或 1
33
- * @returns {{ created: string[], skipped: string[] }}
34
- */
35
- export function createVersionStructure(projectDir, version) {
34
+ * @param {string|null} parentVersion lineage parent; null for v1
35
+ * @returns {{ created: string[], skipped: string[] }}
36
+ */
37
+ export function createVersionStructure(projectDir, version, parentVersion = null) {
36
38
  const v = typeof version === 'number' ? `v${version}` : version;
37
39
  const cwd = projectDir || process.cwd();
38
40
  const loomRoot = getLoomRoot();
@@ -70,26 +72,26 @@ export function createVersionStructure(projectDir, version) {
70
72
  const scaffoldFiles = [
71
73
  [`.loom/${v}/02_ARCHITECTURE.md`, [
72
74
  '<!-- LOOM_TEMPLATE -->',
73
- `# 02_ARCHITECTURE.md — 系统架构`,
75
+ `# 02_ARCHITECTURE.md — 系统边界与责任`,
74
76
  '',
75
77
  `> 版本: ${v}`,
76
78
  '> 产出自: Architect 角色激活',
77
79
  '',
78
- 'Architect 设计系统架构后替换本文件。',
79
- '内容要求:模块边界、接口契约、目录结构、依赖关系。',
80
+ 'Architect 设计系统边界后替换本文件。',
81
+ '内容要求:责任边界、公开契约、数据与依赖方向、失败与演进边界;目录只在影响理解时记录。',
80
82
  '设计完成后删除顶部的 `<!-- LOOM_TEMPLATE -->` 标记。',
81
83
  '',
82
84
  ].join('\n')],
83
85
  [`.loom/${v}/05_VERIFICATION.md`, [
84
86
  '<!-- LOOM_TEMPLATE -->',
85
- `# 05_VERIFICATION.md — 验证契约`,
87
+ `# 05_VERIFICATION.md — 完成与质量契约`,
86
88
  '',
87
89
  `> 版本: ${v}`,
88
90
  '> 产出自: Architect 角色激活',
89
91
  '',
90
- 'Architect 设计验证契约后替换本文件。',
91
- '内容要求:每个 Intent 的功能承诺 + 防御承诺 + Pre-Mortem 推演。',
92
- 'Intent Map 的 acceptance 字段可引用本文件章节(如 "see 05_VERIFICATION.md#int-001")。',
92
+ 'Architect 设计成功契约后替换本文件。',
93
+ '内容要求:每个 Intent Reliability Floor(结果、失败边界;若改动既有状态,还要写保留项与旧状态 操作 → 新状态的验证序列);需要高于功能正确性时,再定义 Distinctive Ceiling、基线与证据方式。',
94
+ 'Intent Map 的 acceptance quality_contract 可分别引用本文件章节。',
93
95
  '设计完成后删除顶部的 `<!-- LOOM_TEMPLATE -->` 标记。',
94
96
  '',
95
97
  ].join('\n')],
@@ -105,19 +107,30 @@ export function createVersionStructure(projectDir, version) {
105
107
  }
106
108
  }
107
109
 
108
- for (const [src, dst] of templates) {
110
+ for (const [src, dst] of templates) {
109
111
  const srcPath = join(loomRoot, src);
110
112
  const dstPath = join(cwd, dst);
111
113
  if (existsSync(dstPath)) {
112
114
  skipped.push(dst);
113
- } else if (existsSync(srcPath)) {
114
- copyFileSync(srcPath, dstPath);
115
- created.push(dst);
115
+ } else if (existsSync(srcPath)) {
116
+ copyFileSync(srcPath, dstPath);
117
+ if (dst.endsWith('04_INTENT_MAP.json')) {
118
+ const map = JSON.parse(readFileSync(dstPath, 'utf-8'));
119
+ map._meta._loom_version = v;
120
+ map._meta._parent_version = parentVersion;
121
+ writeFileSync(dstPath, JSON.stringify(map, null, 2), 'utf-8');
122
+ }
123
+ created.push(dst);
116
124
  }
117
- }
118
-
119
- return { created, skipped };
120
- }
125
+ }
126
+
127
+ const changelogFiles = [`.loom/${v}/06_CHANGELOG.json`, `.loom/${v}/06_CHANGELOG.md`];
128
+ const changelogExisted = changelogFiles.map((file) => existsSync(join(cwd, file)));
129
+ scaffoldChangelog(join(cwd, '.loom', v));
130
+ changelogFiles.forEach((file, index) => (changelogExisted[index] ? skipped : created).push(file));
131
+
132
+ return { created, skipped };
133
+ }
121
134
 
122
135
  /**
123
136
  * 初始化项目目录(创建 v1 + 写入 current 指针)。
@@ -126,7 +139,7 @@ export function createVersionStructure(projectDir, version) {
126
139
  */
127
140
  export function initProject(projectDir) {
128
141
  const cwd = projectDir || process.cwd();
129
- const result = createVersionStructure(cwd, 'v1');
142
+ const result = createVersionStructure(cwd, 'v1', null);
130
143
  // 写入 current 指针
131
144
  const currentPath = join(cwd, '.loom', 'current');
132
145
  if (!existsSync(currentPath)) {
@@ -141,14 +154,22 @@ export function initProject(projectDir) {
141
154
  writeFileSync(agentsMdPath, [
142
155
  '# AGENTS.md',
143
156
  '',
144
- '本项目使用 LOOM 框架(哲学驱动开发)。',
145
- '',
146
- '快速上手:',
147
- '- `loom --help` — 查看所有命令',
148
- '- `loom help workflow` — 工作流指南',
149
- '- `loom help concepts` 核心概念',
150
- '- `loom context` — 当前项目状态',
151
- '- `loom doctor` 项目健康检查',
157
+ '本项目使用 LOOM:按 Doctrine、Intent 与 Contract 对齐方向,再通过',
158
+ 'Expertise Compiler、Quality Arena 与 Quality Proof 把任务专业地完成并证明确实成立。',
159
+ '',
160
+ '进入项目后:',
161
+ '1. 运行 `loom guide --dry-run`,确认当前阶段和唯一下一步。',
162
+ '2. `loom activate <role>` 获取当前角色上下文;实现或验证单个 Intent 时必须加 `--intent <id>`。',
163
+ '3. 只在当前角色的权限内行动;发现目标、契约或架构需要改变时,按 LOOM 回流,不要静默扩展范围。',
164
+ '4. 完成前运行当前 Intent 的验证方法与 `loom doctor`;声称质量提升时必须提供基线相对 Quality Proof,以磁盘证据而非会话记忆判断状态。',
165
+ '5. Keeper 验证必须运行在新的 Agent thread 中;同一会话切换角色不构成独立验证。',
166
+ '',
167
+ '常用入口:',
168
+ '- `loom --help`',
169
+ '- `loom help workflow`',
170
+ '- `loom help concepts`',
171
+ '- `loom context`',
172
+ '- `loom doctor`',
152
173
  '',
153
174
  ].join('\n'), 'utf-8');
154
175
  result.created.push('AGENTS.md');
@@ -0,0 +1,303 @@
1
+ // intent-draft.js — current-version Minor Intent drafts and atomic finalization.
2
+ // Drafts isolate semantic edits from the official map until their references and DAG validate.
3
+
4
+ import {
5
+ existsSync,
6
+ mkdirSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ renameSync,
10
+ unlinkSync,
11
+ writeFileSync,
12
+ } from 'node:fs';
13
+ import { basename, join } from 'node:path';
14
+ import { extractExplicitAnchor, extractMdSection, readJsonFile, slugify } from './shared/md-utils.js';
15
+ import { applyImpactReview, computeTopoOrder, dependentClosure, VALID_STATUS, validateImpactPartition, validateIntentMap } from './intent-map.js';
16
+ import { getPhilosophy } from './philosophy.js';
17
+
18
+ const ID_RE = /^INT-(\d{3,})$/;
19
+ const REQUIRED_FIELDS = [
20
+ 'id',
21
+ 'revision',
22
+ 'title',
23
+ 'narrative_ref',
24
+ 'depends_on',
25
+ 'acceptance',
26
+ 'philosophy_anchors',
27
+ 'status',
28
+ ];
29
+ const PLACEHOLDER_RE = /(?:\.{3}|…|\b(?:todo|tbd|placeholder)\b|待填|待写|请填写|在此填写|\[必须\]|replace this)/i;
30
+
31
+ function assertIntentId(id) {
32
+ if (!ID_RE.test(id || '')) throw new Error(`Intent ID 非法: ${id || '(empty)'}`);
33
+ }
34
+
35
+ function draftDir(versionDir) {
36
+ return join(versionDir, 'drafts');
37
+ }
38
+
39
+ function draftPath(versionDir, id) {
40
+ assertIntentId(id);
41
+ return join(draftDir(versionDir), `${id}.json`);
42
+ }
43
+
44
+ function atomicWrite(filePath, content) {
45
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
46
+ writeFileSync(tempPath, content, 'utf-8');
47
+ try {
48
+ renameSync(tempPath, filePath);
49
+ } catch (error) {
50
+ try { unlinkSync(tempPath); } catch {}
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ function readOfficialMap(versionDir) {
56
+ return readJsonFile(join(versionDir, '04_INTENT_MAP.json'), 'Intent Map');
57
+ }
58
+
59
+ function getSection(versionDir, ref, expectedFile, label) {
60
+ if (typeof ref !== 'string') throw new Error(`${label}引用必须是字符串`);
61
+ const match = ref.trim().match(/^(?:see\s+)?([^#]+)#([\w-]+)$/i);
62
+ if (!match || basename(match[1]) !== match[1] || match[1] !== expectedFile) {
63
+ throw new Error(`${label}引用必须是 ${expectedFile}#<section>`);
64
+ }
65
+ const filePath = join(versionDir, expectedFile);
66
+ if (!existsSync(filePath)) throw new Error(`${label}文件不存在: ${filePath}`);
67
+ return extractMdSection(readFileSync(filePath, 'utf-8'), match[2], label);
68
+ }
69
+
70
+ function meaningfulBody(section) {
71
+ return section
72
+ .split(/\r?\n/)
73
+ .filter((line) => !/^\s*(?:#|<!--|-->|>\s*DRAFT)/i.test(line))
74
+ .join(' ')
75
+ .replace(/\s+/g, ' ')
76
+ .trim();
77
+ }
78
+
79
+ function validateProse(section, label, minimumLength) {
80
+ const body = meaningfulBody(section);
81
+ if (body.length < minimumLength || PLACEHOLDER_RE.test(body)) {
82
+ throw new Error(`${label}必须包含真实、非占位内容(当前有效内容 ${body.length} 字符)`);
83
+ }
84
+ return section;
85
+ }
86
+
87
+ function resolveAcceptance(versionDir, acceptance) {
88
+ const isReference = typeof acceptance === 'string' && /^(?:see\s+)?[^#]+#[\w-]+$/i.test(acceptance.trim());
89
+ return isReference
90
+ ? getSection(versionDir, acceptance, '05_VERIFICATION.md', '验证契约')
91
+ : String(acceptance || '');
92
+ }
93
+
94
+ function hasSection(content, sectionId) {
95
+ return content.split(/\r?\n/).some((line) => {
96
+ const match = line.match(/^#{1,6}\s+(.+)$/);
97
+ if (!match) return false;
98
+ return (extractExplicitAnchor(match[1]) || slugify(match[1])) === sectionId;
99
+ });
100
+ }
101
+
102
+ function appendDraftSection(filePath, id, title, kind) {
103
+ const anchor = id.toLowerCase();
104
+ const existing = existsSync(filePath) ? readFileSync(filePath, 'utf-8') : '';
105
+ if (hasSection(existing, anchor)) throw new Error(`${basename(filePath)} 已存在 ${id} 章节,拒绝重复追加`);
106
+
107
+ const prompt = kind === 'narrative'
108
+ ? '[TODO: replace this with the intent narrative: why this capability must exist and what user outcome it protects.]'
109
+ : '[TODO: replace this with concrete, observable acceptance criteria: result, failure and boundary behavior; if existing user or system state is changed, also name what must be preserved and one old-state → operation → new-state verification sequence.]';
110
+ const heading = kind === 'narrative' ? `${id}: ${title}` : `${id}: Acceptance`;
111
+ const section = `\n\n## [DRAFT] ${heading} {#${anchor}}\n\n<!-- LOOM INTENT DRAFT: ${id} -->\n${prompt}\n`;
112
+ atomicWrite(filePath, existing.replace(/\s*$/, '') + section);
113
+ }
114
+
115
+ function allocateId(map, versionDir) {
116
+ const numbers = Object.keys(map.intents).map((id) => Number(id.match(ID_RE)?.[1] || 0));
117
+ if (existsSync(draftDir(versionDir))) {
118
+ // Draft IDs are discovered without trusting arbitrary filenames as paths.
119
+ for (const file of readdirSync(draftDir(versionDir))) {
120
+ const match = file.match(/^INT-(\d{3,})\.json$/);
121
+ if (match) numbers.push(Number(match[1]));
122
+ }
123
+ }
124
+ return `INT-${String(Math.max(0, ...numbers) + 1).padStart(3, '0')}`;
125
+ }
126
+
127
+ export function addIntentDraft(versionDir, title, dependencies = []) {
128
+ if (!title || typeof title !== 'string' || PLACEHOLDER_RE.test(title) || title.trim().length < 2) {
129
+ throw new Error('--title 必须是非占位标题');
130
+ }
131
+ const map = readOfficialMap(versionDir);
132
+ validateIntentMap(map);
133
+ const dependsOn = [...new Set(dependencies.filter(Boolean))];
134
+ for (const dependency of dependsOn) {
135
+ assertIntentId(dependency);
136
+ if (!(dependency in map.intents)) throw new Error(`依赖不存在于官方 Intent Map: ${dependency}`);
137
+ }
138
+
139
+ const id = allocateId(map, versionDir);
140
+ const path = draftPath(versionDir, id);
141
+ if (existsSync(path) || id in map.intents) throw new Error(`Intent 已存在: ${id}`);
142
+ mkdirSync(draftDir(versionDir), { recursive: true });
143
+
144
+ const draft = {
145
+ _draft: { operation: 'add', created_at: new Date().toISOString() },
146
+ id,
147
+ revision: 1,
148
+ title: title.trim(),
149
+ narrative_ref: `01_VISION.md#${id.toLowerCase()}`,
150
+ depends_on: dependsOn,
151
+ acceptance: `see 05_VERIFICATION.md#${id.toLowerCase()}`,
152
+ philosophy_anchors: [],
153
+ status: 'pending',
154
+ };
155
+
156
+ // Check both documents before touching either, then append deterministic draft sections.
157
+ for (const file of ['01_VISION.md', '05_VERIFICATION.md']) {
158
+ const content = existsSync(join(versionDir, file)) ? readFileSync(join(versionDir, file), 'utf-8') : '';
159
+ if (hasSection(content, id.toLowerCase())) {
160
+ throw new Error(`${file} 已存在 ${id} 章节,拒绝重复追加`);
161
+ }
162
+ }
163
+ appendDraftSection(join(versionDir, '01_VISION.md'), id, draft.title, 'narrative');
164
+ appendDraftSection(join(versionDir, '05_VERIFICATION.md'), id, draft.title, 'acceptance');
165
+ atomicWrite(path, `${JSON.stringify(draft, null, 2)}\n`);
166
+ return draft;
167
+ }
168
+
169
+ export function getIntentDraft(versionDir, id) {
170
+ const path = draftPath(versionDir, id);
171
+ if (!existsSync(path)) throw new Error(`Intent draft 不存在: ${id}`);
172
+ return readJsonFile(path, 'Intent draft');
173
+ }
174
+
175
+ export function reviseIntentDraft(versionDir, id, reason) {
176
+ assertIntentId(id);
177
+ if (!reason || typeof reason !== 'string' || PLACEHOLDER_RE.test(reason) || reason.trim().length < 3) {
178
+ throw new Error('--reason 必须说明修订原因');
179
+ }
180
+ const path = draftPath(versionDir, id);
181
+ if (existsSync(path)) throw new Error(`${id} 已存在 draft;先完成或删除现有 draft`);
182
+ const map = readOfficialMap(versionDir);
183
+ validateIntentMap(map);
184
+ const current = map.intents[id];
185
+ if (!current) throw new Error(`Intent 不存在: ${id}`);
186
+
187
+ const draft = {
188
+ ...current,
189
+ _draft: { operation: 'revise', created_at: new Date().toISOString() },
190
+ revision: (current.revision ?? 1) + 1,
191
+ revision_reason: reason.trim(),
192
+ };
193
+ mkdirSync(draftDir(versionDir), { recursive: true });
194
+ atomicWrite(path, `${JSON.stringify(draft, null, 2)}\n`);
195
+
196
+ const direct = Object.values(map.intents)
197
+ .filter((intent) => intent.depends_on.includes(id))
198
+ .map((intent) => intent.id);
199
+ const seen = new Set();
200
+ const queue = [...direct];
201
+ while (queue.length) {
202
+ const dependency = queue.shift();
203
+ if (seen.has(dependency)) continue;
204
+ seen.add(dependency);
205
+ for (const intent of Object.values(map.intents)) {
206
+ if (intent.depends_on.includes(dependency)) queue.push(intent.id);
207
+ }
208
+ }
209
+ return { draft, reverse_dependencies: { direct, transitive: [...seen] } };
210
+ }
211
+
212
+ function validateDraft(versionDir, draft, map) {
213
+ for (const field of REQUIRED_FIELDS) {
214
+ if (!(field in draft)) throw new Error(`Intent draft 缺少必填字段: ${field}`);
215
+ }
216
+ assertIntentId(draft.id);
217
+ if (!draft._draft || !['add', 'revise'].includes(draft._draft.operation)) throw new Error('Intent draft 缺少合法 _draft.operation');
218
+ if (!Number.isInteger(draft.revision) || draft.revision < 1) throw new Error('Intent draft revision 必须是正整数');
219
+ if (!VALID_STATUS.includes(draft.status)) throw new Error(`Intent draft status 非法: ${draft.status}`);
220
+ if (typeof draft.title !== 'string' || draft.title.trim().length < 2 || PLACEHOLDER_RE.test(draft.title)) throw new Error('Intent draft title 非法');
221
+ if (!Array.isArray(draft.depends_on) || new Set(draft.depends_on).size !== draft.depends_on.length) throw new Error('Intent draft depends_on 必须是不重复数组');
222
+ if (!Array.isArray(draft.philosophy_anchors) || draft.philosophy_anchors.length === 0) throw new Error('Intent draft philosophy_anchors 必须非空');
223
+ if (draft.depends_on.includes(draft.id)) throw new Error(`${draft.id} 不能依赖自身`);
224
+ for (const dependency of draft.depends_on) {
225
+ assertIntentId(dependency);
226
+ if (!(dependency in map.intents)) throw new Error(`依赖不存在于官方 Intent Map: ${dependency}`);
227
+ }
228
+
229
+ validateProse(getSection(versionDir, draft.narrative_ref, '01_VISION.md', '意图叙事'), '意图叙事', 30);
230
+ validateProse(resolveAcceptance(versionDir, draft.acceptance), '验证契约', 30);
231
+ for (const anchor of draft.philosophy_anchors) {
232
+ if (typeof anchor !== 'string' || basename(anchor.split('#')[0]) !== anchor.split('#')[0]) throw new Error(`哲学锚点非法: ${anchor}`);
233
+ validateProse(getPhilosophy(join(versionDir, '00_PHILOSOPHY'), anchor), `哲学锚点 ${anchor}`, 10);
234
+ }
235
+
236
+ const current = map.intents[draft.id];
237
+ if (draft._draft.operation === 'add') {
238
+ if (current) throw new Error(`官方 Intent 已存在,不能 add: ${draft.id}`);
239
+ if (draft.revision !== 1 || draft.status !== 'pending') throw new Error('新增 Intent 必须 revision=1 且 status=pending');
240
+ } else {
241
+ if (!current) throw new Error(`待修订的官方 Intent 不存在: ${draft.id}`);
242
+ if (draft.revision !== (current.revision ?? 1) + 1) throw new Error('修订 revision 必须恰好递增 1');
243
+ if (!draft.revision_reason || PLACEHOLDER_RE.test(draft.revision_reason)) throw new Error('修订 draft 必须有真实 revision_reason');
244
+ }
245
+ }
246
+
247
+ export function finalizeIntentDraft(versionDir, id, options = {}) {
248
+ const path = draftPath(versionDir, id);
249
+ const draft = getIntentDraft(versionDir, id);
250
+ const map = readOfficialMap(versionDir);
251
+ validateIntentMap(map);
252
+ validateDraft(versionDir, draft, map);
253
+
254
+ const operation = draft._draft.operation;
255
+ const current = map.intents[id];
256
+ const review = options.review || [];
257
+ const unaffected = options.unaffected || [];
258
+ const dependents = operation === 'revise' ? dependentClosure(map.intents, id) : { all: [] };
259
+ if (operation === 'revise') validateImpactPartition(dependents.all, review, unaffected);
260
+ if (operation === 'add' && (review.length || unaffected.length)) {
261
+ throw new Error('新增 Intent 没有既有下游依赖,不接受 --review 或 --unaffected');
262
+ }
263
+ const { _draft, ...intent } = draft;
264
+ if (operation === 'add') {
265
+ intent.status = 'pending';
266
+ } else {
267
+ intent.status = current.status;
268
+ }
269
+ map.intents[id] = intent;
270
+ let reviewed = [];
271
+ if (operation === 'revise') {
272
+ const targets = current.status === 'completed' ? [id, ...review] : review;
273
+ const result = applyImpactReview(map, targets, { incrementPassOnce: true });
274
+ reviewed = result.reviewed;
275
+ intent.status = map.intents[id].status;
276
+ }
277
+ map.topo_order = computeTopoOrder(map.intents, map.topo_order);
278
+ validateIntentMap(map);
279
+
280
+ if (operation === 'add') {
281
+ for (const file of ['01_VISION.md', '05_VERIFICATION.md']) {
282
+ const content = readFileSync(join(versionDir, file), 'utf-8');
283
+ if (!new RegExp(`^#{1,6}\\s+\\[DRAFT\\]\\s+${id}(?::|\\s)`, 'mi').test(content)) {
284
+ throw new Error(`${file} 缺少 ${id} 的 draft 标记,拒绝 finalize`);
285
+ }
286
+ }
287
+ }
288
+
289
+ atomicWrite(join(versionDir, '04_INTENT_MAP.json'), `${JSON.stringify(map, null, 2)}\n`);
290
+ if (operation === 'add') {
291
+ for (const file of ['01_VISION.md', '05_VERIFICATION.md']) {
292
+ const filePath = join(versionDir, file);
293
+ const content = readFileSync(filePath, 'utf-8');
294
+ const promoted = content
295
+ .replace(new RegExp(`^(#{1,6})\\s+\\[DRAFT\\]\\s+(${id}(?::|\\s))`, 'mi'), '$1 $2')
296
+ .replace(new RegExp(`^<!-- LOOM INTENT DRAFT: ${id} -->\\r?\\n?`, 'mi'), '');
297
+ if (promoted === content) throw new Error(`${file} 未找到可提升的 ${id} draft 标记;官方 Map 已安全写入,draft 保留以便人工恢复`);
298
+ atomicWrite(filePath, promoted);
299
+ }
300
+ }
301
+ unlinkSync(path);
302
+ return { operation, intent, topo_order: map.topo_order, reviewed, unaffected };
303
+ }