@haaaiawd/loom 0.1.0 → 0.8.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.
@@ -2,25 +2,123 @@
2
2
  // 提供 doctor / context / trace / reverse-dep / reverse-ref 五个聚合命令。
3
3
  // 全部是只读的数据聚合,不做决策、不修改文件。
4
4
 
5
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
6
- import { join } from 'node:path';
7
- import { loadIntentMap, getStatus, getNextIntent, getNarrative, getIntent } from './intent-map.js';
8
- import { getPhilosophy } from './philosophy.js';
9
- import { getVerificationHistory, getPendingVerifications, getVerificationContract } from './verify.js';
5
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { loadIntentMap, getStatus, getNextIntent, getNarrative, getIntent } from './intent-map.js';
8
+ import { getPhilosophy, validateInspirationSources, validatePartDecomposition } from './philosophy.js';
9
+ import { getVerificationHistory, getPendingVerifications, getVerificationContract } from './verify.js';
10
+
11
+ function readIntentMapRaw(versionDir) {
12
+ const filePath = join(versionDir, '04_INTENT_MAP.json');
13
+ if (!existsSync(filePath)) return null;
14
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
15
+ }
16
+
17
+ function summarizeRawIntentMap(data) {
18
+ const intents = data?.intents && typeof data.intents === 'object' ? data.intents : {};
19
+ const ids = { pending: [], in_progress: [], completed: [], blocked: [], needs_review: [] };
20
+ const titles = {};
21
+
22
+ for (const [id, intent] of Object.entries(intents)) {
23
+ const status = intent?.status;
24
+ if (ids[status]) ids[status].push(id);
25
+ titles[id] = intent?.title || '';
26
+ }
27
+
28
+ return {
29
+ counts: {
30
+ pending: ids.pending.length,
31
+ in_progress: ids.in_progress.length,
32
+ completed: ids.completed.length,
33
+ blocked: ids.blocked.length,
34
+ needs_review: ids.needs_review.length,
35
+ total: Object.keys(intents).length,
36
+ },
37
+ ids,
38
+ titles,
39
+ };
40
+ }
41
+
42
+ function intentMapDiagnostics(versionDir) {
43
+ const issues = [];
44
+ let raw = null;
45
+ let valid = null;
46
+ let validMap = null;
47
+
48
+ try {
49
+ raw = readIntentMapRaw(versionDir);
50
+ } catch (e) {
51
+ issues.push({ id: 'intent_map', type: 'intent_map_unreadable', severity: 'fatal', msg: `Intent Map 无法读取或 JSON 损坏: ${e.message}` });
52
+ return { raw, valid, issues, validMap: null };
53
+ }
54
+
55
+ if (!raw) {
56
+ issues.push({ id: 'intent_map', type: 'intent_map_missing', severity: 'fatal', msg: '缺少 04_INTENT_MAP.json' });
57
+ return { raw, valid, issues, validMap: null };
58
+ }
59
+
60
+ if (raw._meta?._template === true) {
61
+ issues.push({ id: 'intent_map', type: 'intent_map_template', severity: 'high', msg: 'Intent Map 仍是模板,尚未由 Architect 产出真实意图图' });
62
+ }
63
+
64
+ try {
65
+ validMap = loadIntentMap(versionDir);
66
+ valid = true;
67
+ } catch (e) {
68
+ valid = false;
69
+ issues.push({ id: 'intent_map', type: 'intent_map_invalid', severity: 'fatal', msg: e.message });
70
+ }
71
+
72
+ return { raw, valid, issues, validMap };
73
+ }
74
+
75
+ function getIntentVerificationMethod(intent) {
76
+ return intent.verification_method || intent._optional?.verification_method || null;
77
+ }
78
+
79
+ function normalizeVerificationCommand(command) {
80
+ return String(command || '')
81
+ .replace(/^\s*run\s+/i, '')
82
+ .replace(/^\s*exec\s+/i, '')
83
+ .replace(/\s+/g, ' ')
84
+ .trim();
85
+ }
86
+
87
+ function commandCoversMethod(actualCommand, expectedMethod) {
88
+ const actual = normalizeVerificationCommand(actualCommand);
89
+ const expected = normalizeVerificationCommand(expectedMethod);
90
+ if (!actual || !expected) return false;
91
+
92
+ return expected.split('&&').every((part) => {
93
+ const expectedPart = normalizeVerificationCommand(part);
94
+ if (!expectedPart) return true;
95
+ if (actual.includes(expectedPart)) return true;
96
+ // npm test is an acceptable broader reproduction for node --test based methods.
97
+ if (expectedPart.startsWith('node --test') && actual.includes('npm test')) return true;
98
+ return false;
99
+ });
100
+ }
10
101
 
11
102
  // ─── doctor ────────────────────────────────────────────
12
103
  // 全面健康检查:一致性 + 孤儿引用 + 循环依赖 + 僵尸 Intent
13
104
 
14
105
  /**
15
106
  * 项目健康检查。
16
- * @param {string} loomDir — 当前版本目录
107
+ * @param {string} versionDir — 当前版本目录
17
108
  * @param {string} verificationsDir — 验证记录目录
18
109
  * @param {string} philosophyDir — 哲学目录
19
110
  * @returns {{ issues: object[], summary: object }}
20
111
  */
21
- export function doctor(loomDir, verificationsDir, philosophyDir) {
22
- const { intents, topo_order } = loadIntentMap(loomDir);
23
- const issues = [];
112
+ export function doctor(versionDir, verificationsDir, philosophyDir) {
113
+ const mapState = intentMapDiagnostics(versionDir);
114
+ const issues = [...mapState.issues];
115
+
116
+ if (!mapState.validMap) {
117
+ appendPhilosophyDiagnostics(issues, philosophyDir);
118
+ return { issues, summary: summarizeIssues(issues) };
119
+ }
120
+
121
+ const { intents } = mapState.validMap;
24
122
 
25
123
  // 1. 状态一致性:in_progress/completed 但无验证记录
26
124
  for (const [id, intent] of Object.entries(intents)) {
@@ -67,8 +165,8 @@ export function doctor(loomDir, verificationsDir, philosophyDir) {
67
165
  for (const [id, intent] of Object.entries(intents)) {
68
166
  if (intent.status !== 'in_progress' && intent.status !== 'blocked') continue;
69
167
  const recordPath = join(verificationsDir, `${id}.json`);
70
- let lastActivity = existsSync(join(loomDir, '04_INTENT_MAP.json'))
71
- ? statSync(join(loomDir, '04_INTENT_MAP.json')).mtimeMs
168
+ let lastActivity = existsSync(join(versionDir, '04_INTENT_MAP.json'))
169
+ ? statSync(join(versionDir, '04_INTENT_MAP.json')).mtimeMs
72
170
  : now;
73
171
  if (existsSync(recordPath)) {
74
172
  lastActivity = Math.max(lastActivity, statSync(recordPath).mtimeMs);
@@ -90,16 +188,91 @@ export function doctor(loomDir, verificationsDir, philosophyDir) {
90
188
  }
91
189
  }
92
190
 
93
- const summary = {
94
- total_issues: issues.length,
95
- fatal: issues.filter((i) => i.severity === 'fatal').length,
96
- high: issues.filter((i) => i.severity === 'high').length,
97
- medium: issues.filter((i) => i.severity === 'medium').length,
98
- healthy: issues.length === 0,
99
- };
100
-
101
- return { issues, summary };
102
- }
191
+ // 7. 验证脚本可执行性:检查 verification_method 引用的脚本/目录是否存在
192
+ const projectDir = join(versionDir, '..', '..');
193
+ for (const [id, intent] of Object.entries(intents)) {
194
+ const vm = getIntentVerificationMethod(intent);
195
+ if (!vm) continue;
196
+ // 检测 npm test 引用
197
+ if (vm.includes('npm test') || vm.includes('npm run test')) {
198
+ const pkgPath = join(projectDir, 'package.json');
199
+ if (!existsSync(pkgPath)) {
200
+ issues.push({ id, type: 'test_script_missing', severity: 'medium', msg: `${id} verification_method 要求 npm test 但项目根没有 package.json` });
201
+ } else {
202
+ try {
203
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
204
+ const testScript = pkg.scripts && pkg.scripts.test;
205
+ if (!testScript) {
206
+ issues.push({ id, type: 'test_script_missing', severity: 'medium', msg: `${id} verification_method 要求 npm test 但 package.json 没有 test 脚本` });
207
+ } else {
208
+ // 检查 test 脚本引用的目录/文件是否存在
209
+ // 常见模式: "node --test test/" / "mocha test/" / "jest" 等
210
+ const testDirMatch = testScript.match(/(?:--test|test)\s+(\S+)/);
211
+ if (testDirMatch) {
212
+ const testTarget = testDirMatch[1].replace(/['"]/g, '');
213
+ if (!existsSync(join(projectDir, testTarget))) {
214
+ issues.push({ id, type: 'test_script_missing', severity: 'medium', msg: `${id} verification_method 要求 npm test 但 test 脚本引用的 ${testTarget} 不存在` });
215
+ }
216
+ }
217
+ }
218
+ } catch {
219
+ // package.json 解析失败,不报——不是 doctor 的职责
220
+ }
221
+ }
222
+ }
223
+ }
224
+
225
+ // 8. completed Intent 的 verification_method 必须被最新验证记录覆盖,防止契约命令漂移。
226
+ for (const [id, intent] of Object.entries(intents)) {
227
+ if (intent.status !== 'completed') continue;
228
+ const method = getIntentVerificationMethod(intent);
229
+ if (!method) continue;
230
+
231
+ const expected = normalizeVerificationCommand(method);
232
+ if (!expected || expected === 'human_review') continue;
233
+
234
+ const history = getVerificationHistory(verificationsDir, id);
235
+ const latest = history?.records?.[history.records.length - 1];
236
+ const actual = normalizeVerificationCommand(latest?.reproduction_command);
237
+
238
+ if (!latest) {
239
+ issues.push({ id, type: 'verification_method_unverified', severity: 'high', msg: `${id} 声明了 verification_method 但没有验证记录覆盖: ${method}` });
240
+ } else if (!actual) {
241
+ issues.push({ id, type: 'verification_method_unverified', severity: 'high', msg: `${id} 最新验证记录缺少 reproduction_command,无法复现 verification_method: ${method}` });
242
+ } else if (!commandCoversMethod(actual, expected)) {
243
+ issues.push({ id, type: 'verification_method_drift', severity: 'high', msg: `${id} verification_method 未被最新 reproduction_command 覆盖。method="${method}" reproduction_command="${latest.reproduction_command}"` });
244
+ }
245
+ }
246
+
247
+ appendPhilosophyDiagnostics(issues, philosophyDir);
248
+ return { issues, summary: summarizeIssues(issues) };
249
+ }
250
+
251
+ function appendPhilosophyDiagnostics(issues, philosophyDir) {
252
+ // 哲学灵感来源校验(防止 Weaver 从训练数据"背"几个名字就交差)
253
+ if (!existsSync(philosophyDir)) return;
254
+
255
+ const inspirationCheck = validateInspirationSources(philosophyDir);
256
+ for (const issue of inspirationCheck.issues) {
257
+ issues.push({ id: 'philosophy', type: 'inspiration_source', severity: issue.severity, msg: issue.msg });
258
+ }
259
+
260
+ // 实现部分拆解校验(防止 Weaver 跳过拆解步骤)
261
+ const decompositionCheck = validatePartDecomposition(philosophyDir);
262
+ for (const issue of decompositionCheck.issues) {
263
+ issues.push({ id: 'philosophy', type: 'part_decomposition', severity: issue.severity, msg: issue.msg });
264
+ }
265
+ }
266
+
267
+ function summarizeIssues(issues) {
268
+ return {
269
+ total_issues: issues.length,
270
+ fatal: issues.filter((i) => i.severity === 'fatal').length,
271
+ high: issues.filter((i) => i.severity === 'high').length,
272
+ medium: issues.filter((i) => i.severity === 'medium').length,
273
+ healthy: issues.length === 0,
274
+ };
275
+ }
103
276
 
104
277
  /**
105
278
  * DFS 检测循环依赖。
@@ -143,29 +316,31 @@ function detectCycles(intents) {
143
316
 
144
317
  /**
145
318
  * 项目上下文摘要——Agent 重启后一条命令获取"我在哪"。
146
- * @param {string} loomDir
319
+ * @param {string} versionDir
147
320
  * @param {string} verificationsDir
148
321
  * @param {string} philosophyDir
149
322
  * @returns {object}
150
323
  */
151
- export function contextSummary(loomDir, verificationsDir, philosophyDir) {
152
- const status = getStatus(loomDir);
153
- const next = getNextIntent(loomDir);
154
- const pending = getPendingVerifications(loomDir, verificationsDir);
155
- const { issues } = doctor(loomDir, verificationsDir, philosophyDir);
324
+ export function contextSummary(versionDir, verificationsDir, philosophyDir) {
325
+ const mapState = intentMapDiagnostics(versionDir);
326
+ const status = mapState.valid ? getStatus(versionDir) : summarizeRawIntentMap(mapState.raw);
327
+ const next = mapState.valid ? getNextIntent(versionDir) : null;
328
+ const pending = mapState.valid ? getPendingVerifications(versionDir, verificationsDir) : [];
329
+ const { issues } = doctor(versionDir, verificationsDir, philosophyDir);
156
330
 
157
331
  const risks = [];
158
332
  const fatalCount = issues.filter((i) => i.severity === 'fatal').length;
159
333
  const highCount = issues.filter((i) => i.severity === 'high').length;
160
- if (fatalCount > 0) risks.push(`${fatalCount} 个致命问题(循环依赖)`);
161
- if (highCount > 0) risks.push(`${highCount} 个高严重度问题(状态不一致/孤儿引用)`);
162
- if (status.counts.blocked > 0) risks.push(`${status.counts.blocked} 个阻塞 Intent`);
163
-
164
- return {
165
- progress: {
166
- completed: status.counts.completed,
167
- total: status.counts.total,
168
- rate: `${status.counts.completed}/${status.counts.total}`,
334
+ if (fatalCount > 0) risks.push(`${fatalCount} 个致命问题(Intent Map 损坏/循环依赖)`);
335
+ if (highCount > 0) risks.push(`${highCount} 个高严重度问题(状态不一致/孤儿引用)`);
336
+ if (status.counts.blocked > 0) risks.push(`${status.counts.blocked} 个阻塞 Intent`);
337
+
338
+ return {
339
+ intent_map_valid: mapState.valid === true,
340
+ progress: {
341
+ completed: status.counts.completed,
342
+ total: status.counts.total,
343
+ rate: `${status.counts.completed}/${status.counts.total}`,
169
344
  },
170
345
  next_intent: next ? next.id : null,
171
346
  pending_verifications: pending,
@@ -180,23 +355,27 @@ export function contextSummary(loomDir, verificationsDir, philosophyDir) {
180
355
 
181
356
  /**
182
357
  * 返回某个 Intent 的完整追溯链。
183
- * @param {string} loomDir
358
+ * @param {string} versionDir
184
359
  * @param {string} verificationsDir
185
360
  * @param {string} philosophyDir
186
361
  * @param {string} intentId
187
362
  * @returns {object}
188
363
  */
189
- export function traceIntent(loomDir, verificationsDir, philosophyDir, intentId) {
190
- const intent = getIntent(loomDir, intentId);
364
+ export function traceIntent(versionDir, verificationsDir, philosophyDir, intentId) {
365
+ const intent = getIntent(versionDir, intentId);
191
366
  if (!intent) throw new Error(`Intent 不存在: ${intentId}`);
192
367
 
193
368
  // 意图叙事
194
369
  let narrative = null;
195
- try { narrative = getNarrative(loomDir, intentId); } catch { /* narrative_ref 可能缺失 */ }
370
+ let narrativeError = null;
371
+ try { narrative = getNarrative(versionDir, intentId); }
372
+ catch (e) { narrativeError = e.message; /* narrative_ref 可能缺失或解析失败 */ }
196
373
 
197
374
  // 验收契约
198
375
  let acceptance = null;
199
- try { acceptance = getVerificationContract(loomDir, intentId); } catch { /* 可能缺失 */ }
376
+ let acceptanceError = null;
377
+ try { acceptance = getVerificationContract(versionDir, intentId); }
378
+ catch (e) { acceptanceError = e.message; /* 引用可能缺失或解析失败 */ }
200
379
 
201
380
  // 验证历史
202
381
  const verificationHistory = getVerificationHistory(verificationsDir, intentId);
@@ -214,7 +393,7 @@ export function traceIntent(loomDir, verificationsDir, philosophyDir, intentId)
214
393
  }
215
394
 
216
395
  // 依赖链(递归向上)
217
- const { intents } = loadIntentMap(loomDir);
396
+ const { intents } = loadIntentMap(versionDir);
218
397
  const dependencyChain = [];
219
398
  function walkDeps(id, depth) {
220
399
  const node = intents[id];
@@ -229,7 +408,9 @@ export function traceIntent(loomDir, verificationsDir, philosophyDir, intentId)
229
408
  return {
230
409
  intent,
231
410
  narrative,
411
+ narrative_error: narrativeError,
232
412
  acceptance,
413
+ acceptance_error: acceptanceError,
233
414
  verification_history: verificationHistory,
234
415
  philosophy_anchors_content: philosophyContent,
235
416
  dependency_chain: dependencyChain,
@@ -241,12 +422,12 @@ export function traceIntent(loomDir, verificationsDir, philosophyDir, intentId)
241
422
 
242
423
  /**
243
424
  * 返回依赖指定 Intent 的所有 Intent。
244
- * @param {string} loomDir
425
+ * @param {string} versionDir
245
426
  * @param {string} intentId
246
427
  * @returns {string[]}
247
428
  */
248
- export function reverseDep(loomDir, intentId) {
249
- const { intents } = loadIntentMap(loomDir);
429
+ export function reverseDep(versionDir, intentId) {
430
+ const { intents } = loadIntentMap(versionDir);
250
431
  const result = [];
251
432
  for (const [id, intent] of Object.entries(intents)) {
252
433
  if (intent.depends_on?.includes(intentId)) {
@@ -261,12 +442,12 @@ export function reverseDep(loomDir, intentId) {
261
442
 
262
443
  /**
263
444
  * 返回引用指定哲学锚点的所有 Intent。
264
- * @param {string} loomDir
445
+ * @param {string} versionDir
265
446
  * @param {string} anchor — 如 "PRODUCT_PHILOSOPHY.md#core-belief"
266
447
  * @returns {string[]}
267
448
  */
268
- export function reverseRef(loomDir, anchor) {
269
- const { intents } = loadIntentMap(loomDir);
449
+ export function reverseRef(versionDir, anchor) {
450
+ const { intents } = loadIntentMap(versionDir);
270
451
  const result = [];
271
452
  for (const [id, intent] of Object.entries(intents)) {
272
453
  if (intent.philosophy_anchors?.includes(anchor)) {
package/cli/src/guide.js CHANGED
@@ -6,11 +6,12 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  import { readCurrentPointer } from './version.js';
8
8
  import { loadIntentMap } from './intent-map.js';
9
- import { isAutoOn } from './auto.js';
9
+ import { isAutoOn, writeHeartbeat, needsHumanReview } from './auto.js';
10
10
 
11
11
  /**
12
12
  * 检测文件是否还是模板(未填充真实内容)。
13
- * MD 文件检查 <!-- LOOM_TEMPLATE --> 标记。
13
+ * MD 文件检查 <!-- LOOM_TEMPLATE --> 标记——但如果文件已经超过模板大小(>2KB),
14
+ * 认为用户已经填充了真实内容只是忘了删标记,忽略标记。
14
15
  * JSON 文件检查 _meta._template 字段。
15
16
  */
16
17
  function isTemplate(filePath) {
@@ -24,18 +25,53 @@ function isTemplate(filePath) {
24
25
  return false; // 损坏文件不算模板
25
26
  }
26
27
  }
27
- return content.includes('<!-- LOOM_TEMPLATE -->');
28
+ // MD 文件:有 LOOM_TEMPLATE 标记 且 文件较小(<2KB)才算模板。
29
+ // 如果文件已经 >2KB,说明用户填充了内容只是忘了删标记——忽略标记。
30
+ if (content.includes('<!-- LOOM_TEMPLATE -->')) {
31
+ return content.length < 2048;
32
+ }
33
+ return false;
28
34
  }
29
35
 
30
36
  /**
31
37
  * 诊断项目当前阶段。
32
38
  * @param {string} projectDir — 项目根目录
33
- * @returns {{ stage: string, stage_num: number, details: object, auto: boolean, next_action: string, next_command: string, message: string }}
39
+ * @returns {{ stage: string, stage_num: number, details: object, auto: boolean, next_action: string, next_command: string, message: string, needs_human_review: boolean }}
40
+ */
41
+ export function guideProject(projectDir, options = {}) {
42
+ const cwd = projectDir || process.cwd();
43
+ const loomRoot = join(cwd, '.loom');
44
+ const auto = isAutoOn(loomRoot);
45
+ const result = diagnoseStage(cwd, loomRoot, auto);
46
+ // 统一后处理:写心跳 + 加 AUTO 提示词 + 判断是否需要人类 review
47
+ if (existsSync(loomRoot) && !options.dryRun) {
48
+ try {
49
+ writeHeartbeat(loomRoot, {
50
+ stage: result.stage,
51
+ stage_num: result.stage_num,
52
+ next_command: result.next_command,
53
+ next_action: result.next_action,
54
+ });
55
+ } catch { /* 心跳写入失败不阻塞 guide */ }
56
+ }
57
+ result.needs_human_review = needsHumanReview(loomRoot, result.stage_num);
58
+ // 在 message 末尾加 AUTO 提示
59
+ if (result.stage_num > 0 && result.stage_num < 4) {
60
+ result.message += auto
61
+ ? '\n\n> ⚠ 这是设计阶段(哲学/愿景/架构),即使 AUTO 模式开启也需要人类 review 后再继续。'
62
+ : '\n\n> ⚠ AUTO 模式关闭——每步都需要人类确认后再继续。';
63
+ } else if (result.stage_num >= 4 && !auto) {
64
+ result.message += '\n\n> ⚠ AUTO 模式关闭——执行 next_command 后等人类确认再继续。';
65
+ } else if (result.stage_num >= 4 && auto) {
66
+ result.message += '\n\n> AUTO 模式开启——直接执行 next_command,无需人类确认。';
67
+ }
68
+ return result;
69
+ }
70
+
71
+ /**
72
+ * 内部函数:诊断阶段(不含心跳和 AUTO 提示词)。
34
73
  */
35
- export function guideProject(projectDir) {
36
- const cwd = projectDir || process.cwd();
37
- const loomRoot = join(cwd, '.loom');
38
- const auto = isAutoOn(loomRoot);
74
+ function diagnoseStage(cwd, loomRoot, auto) {
39
75
 
40
76
  // 状态 0: 没有 .loom/
41
77
  if (!existsSync(loomRoot)) {
@@ -109,9 +145,11 @@ export function guideProject(projectDir) {
109
145
  }
110
146
 
111
147
  // 状态 4-7: Intent Map 已设计,根据 Intent 状态判断
112
- let intents;
113
- try {
114
- intents = loadIntentMap(versionDir).intents;
148
+ let intentMap;
149
+ let intents;
150
+ try {
151
+ intentMap = loadIntentMap(versionDir);
152
+ intents = intentMap.intents;
115
153
  } catch (e) {
116
154
  return {
117
155
  stage: 'intent_map_broken',
@@ -137,6 +175,9 @@ export function guideProject(projectDir) {
137
175
  // 状态 7: 有 blocked(优先报告)
138
176
  if (counts.blocked > 0) {
139
177
  const blockedIds = allIntents.filter((i) => i.status === 'blocked').map((i) => i.id);
178
+ const msg = auto
179
+ ? `有 ${counts.blocked} 个 Intent 阻塞: ${blockedIds.join(', ')}。AUTO 模式下这是唯一允许停下的情况——需要人工介入解决阻塞后才能继续。`
180
+ : `有 ${counts.blocked} 个 Intent 阻塞: ${blockedIds.join(', ')}。需要人工介入。`;
140
181
  return {
141
182
  stage: 'blocked',
142
183
  stage_num: 7,
@@ -144,7 +185,7 @@ export function guideProject(projectDir) {
144
185
  auto,
145
186
  next_action: '人工介入解决阻塞',
146
187
  next_command: 'loom intent get ' + blockedIds[0],
147
- message: `有 ${counts.blocked} 个 Intent 阻塞: ${blockedIds.join(', ')}。需要人工介入。`,
188
+ message: msg,
148
189
  };
149
190
  }
150
191
 
@@ -175,6 +216,36 @@ export function guideProject(projectDir) {
175
216
  };
176
217
  }
177
218
 
219
+ // 状态 5.5: 有 needs_review(收敛趟)——已完成但需要重新验证的 Intent
220
+ if (counts.needs_review > 0) {
221
+ const reviewIds = allIntents.filter((i) => i.status === 'needs_review').map((i) => i.id);
222
+ // 读 _meta.pass_count 收敛趟计数(最大 3 趟)
223
+ const passCount = intentMap._meta?.pass_count || 1;
224
+ const MAX_PASSES = 3;
225
+ const isOverLimit = passCount > MAX_PASSES;
226
+ const passMsg = ` [Pass ${passCount}/${MAX_PASSES}]`;
227
+ if (isOverLimit) {
228
+ return {
229
+ stage: 'cannot_converge',
230
+ stage_num: 7,
231
+ details: { version: current, counts, needs_review_ids: reviewIds, pass_count: passCount },
232
+ auto,
233
+ next_action: '收敛失败——超过最大趟数,需 Architect 介入',
234
+ next_command: 'loom intent update ' + reviewIds[0] + ' --status blocked',
235
+ message: `当前版本 ${current}:收敛失败,已超过最大 ${MAX_PASSES} 趟限制(当前 Pass ${passCount})。${counts.needs_review} 个 Intent 仍需重新验证(${reviewIds.join(', ')})。这是系统性问题——需 Architect 介入重新设计。`,
236
+ };
237
+ }
238
+ return {
239
+ stage: 'converging',
240
+ stage_num: 5.5,
241
+ details: { version: current, counts, needs_review_ids: reviewIds, pass_count: passCount },
242
+ auto,
243
+ next_action: `进入收敛趟 Pass ${passCount}——重验 needs_review 的 Intent`,
244
+ next_command: 'loom intent update ' + reviewIds[0] + ' --status in_progress',
245
+ message: `当前版本 ${current}${passMsg}:${counts.needs_review} 个 Intent 需要重新验证(${reviewIds.join(', ')})。这是不动点收敛的第 ${passCount} 趟——重验这些 Intent,通过则 completed,偏离则修正。一趟无新 needs_review 即收敛达成。最大 ${MAX_PASSES} 趟,超过判定为系统性问题。`,
246
+ };
247
+ }
248
+
178
249
  // 状态 4: 有 pending,进入 Intent Loop
179
250
  if (counts.pending > 0) {
180
251
  return {