@haaaiawd/loom 0.7.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.
- package/README.md +31 -19
- package/cli/bin/loom.js +89 -27
- package/cli/help/doctor.md +56 -5
- package/cli/help/preview.md +59 -0
- package/cli/help/workflow.md +18 -12
- package/cli/src/diagnostics.js +184 -43
- package/cli/src/guide.js +17 -15
- package/cli/src/philosophy.js +228 -0
- package/cli/src/preview.js +67 -10
- package/cli/src/verify.js +8 -4
- package/dimensions/PART_DECOMPOSITION.md +203 -0
- package/dimensions/examples/AGENT_SYSTEM/README.md +219 -0
- package/dimensions/examples/CLI_TOOL/README.md +163 -0
- package/dimensions/universal/COLLABORATION_PHILOSOPHY.md +77 -0
- package/dimensions/universal/ENGINEERING_CREED.md +74 -0
- package/dimensions/universal/PRODUCT_PHILOSOPHY.md +70 -0
- package/meta/PHILOSOPHY_WEAVER.md +104 -50
- package/package.json +4 -4
package/cli/src/diagnostics.js
CHANGED
|
@@ -2,11 +2,102 @@
|
|
|
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
|
|
@@ -18,9 +109,16 @@ import { getVerificationHistory, getPendingVerifications, getVerificationContrac
|
|
|
18
109
|
* @param {string} philosophyDir — 哲学目录
|
|
19
110
|
* @returns {{ issues: object[], summary: object }}
|
|
20
111
|
*/
|
|
21
|
-
export function doctor(versionDir, verificationsDir, philosophyDir) {
|
|
22
|
-
const
|
|
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)) {
|
|
@@ -90,13 +188,13 @@ export function doctor(versionDir, verificationsDir, philosophyDir) {
|
|
|
90
188
|
}
|
|
91
189
|
}
|
|
92
190
|
|
|
93
|
-
// 7. 验证脚本可执行性:检查 verification_method 引用的脚本/目录是否存在
|
|
94
|
-
const projectDir = join(versionDir, '..', '..');
|
|
95
|
-
for (const [id, intent] of Object.entries(intents)) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
// 检测 npm test 引用
|
|
99
|
-
if (vm.includes('npm test') || vm.includes('npm run test')) {
|
|
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')) {
|
|
100
198
|
const pkgPath = join(projectDir, 'package.json');
|
|
101
199
|
if (!existsSync(pkgPath)) {
|
|
102
200
|
issues.push({ id, type: 'test_script_missing', severity: 'medium', msg: `${id} verification_method 要求 npm test 但项目根没有 package.json` });
|
|
@@ -120,20 +218,61 @@ export function doctor(versionDir, verificationsDir, philosophyDir) {
|
|
|
120
218
|
} catch {
|
|
121
219
|
// package.json 解析失败,不报——不是 doctor 的职责
|
|
122
220
|
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
+
}
|
|
137
276
|
|
|
138
277
|
/**
|
|
139
278
|
* DFS 检测循环依赖。
|
|
@@ -182,24 +321,26 @@ function detectCycles(intents) {
|
|
|
182
321
|
* @param {string} philosophyDir
|
|
183
322
|
* @returns {object}
|
|
184
323
|
*/
|
|
185
|
-
export function contextSummary(versionDir, verificationsDir, philosophyDir) {
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
const
|
|
189
|
-
const
|
|
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);
|
|
190
330
|
|
|
191
331
|
const risks = [];
|
|
192
332
|
const fatalCount = issues.filter((i) => i.severity === 'fatal').length;
|
|
193
333
|
const highCount = issues.filter((i) => i.severity === 'high').length;
|
|
194
|
-
if (fatalCount > 0) risks.push(`${fatalCount}
|
|
195
|
-
if (highCount > 0) risks.push(`${highCount} 个高严重度问题(状态不一致/孤儿引用)`);
|
|
196
|
-
if (status.counts.blocked > 0) risks.push(`${status.counts.blocked} 个阻塞 Intent`);
|
|
197
|
-
|
|
198
|
-
return {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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}`,
|
|
203
344
|
},
|
|
204
345
|
next_intent: next ? next.id : null,
|
|
205
346
|
pending_verifications: pending,
|
package/cli/src/guide.js
CHANGED
|
@@ -36,17 +36,17 @@ function isTemplate(filePath) {
|
|
|
36
36
|
/**
|
|
37
37
|
* 诊断项目当前阶段。
|
|
38
38
|
* @param {string} projectDir — 项目根目录
|
|
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) {
|
|
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)) {
|
|
48
|
-
try {
|
|
49
|
-
writeHeartbeat(loomRoot, {
|
|
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
50
|
stage: result.stage,
|
|
51
51
|
stage_num: result.stage_num,
|
|
52
52
|
next_command: result.next_command,
|
|
@@ -145,9 +145,11 @@ function diagnoseStage(cwd, loomRoot, auto) {
|
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
// 状态 4-7: Intent Map 已设计,根据 Intent 状态判断
|
|
148
|
-
let
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
let intentMap;
|
|
149
|
+
let intents;
|
|
150
|
+
try {
|
|
151
|
+
intentMap = loadIntentMap(versionDir);
|
|
152
|
+
intents = intentMap.intents;
|
|
151
153
|
} catch (e) {
|
|
152
154
|
return {
|
|
153
155
|
stage: 'intent_map_broken',
|
|
@@ -218,7 +220,7 @@ function diagnoseStage(cwd, loomRoot, auto) {
|
|
|
218
220
|
if (counts.needs_review > 0) {
|
|
219
221
|
const reviewIds = allIntents.filter((i) => i.status === 'needs_review').map((i) => i.id);
|
|
220
222
|
// 读 _meta.pass_count 收敛趟计数(最大 3 趟)
|
|
221
|
-
const passCount =
|
|
223
|
+
const passCount = intentMap._meta?.pass_count || 1;
|
|
222
224
|
const MAX_PASSES = 3;
|
|
223
225
|
const isOverLimit = passCount > MAX_PASSES;
|
|
224
226
|
const passMsg = ` [Pass ${passCount}/${MAX_PASSES}]`;
|
package/cli/src/philosophy.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// philosophy.js — 按锚点加载哲学文档的特定章节
|
|
2
2
|
// 哲学文档是 MD,锚点格式: "PRODUCT_PHILOSOPHY.md#core-belief"
|
|
3
3
|
// 这个库按锚点提取对应章节,不返回整个文件。
|
|
4
|
+
// 另含灵感来源校验——防止 Weaver 从训练数据"背"几个名字就交差。
|
|
4
5
|
|
|
5
6
|
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
6
7
|
import { join } from 'node:path';
|
|
@@ -44,3 +45,230 @@ export function listPhilosophyFiles(philosophyDir) {
|
|
|
44
45
|
const dir = readdirSync(philosophyDir);
|
|
45
46
|
return dir.filter((f) => f.endsWith('.md'));
|
|
46
47
|
}
|
|
48
|
+
|
|
49
|
+
// ─── 灵感来源校验 ───────────────────────────────────────
|
|
50
|
+
// 防止 Weaver 从训练数据"背"几个名字就交差。
|
|
51
|
+
// 校验规则:
|
|
52
|
+
// 1. 至少 3 个独立源
|
|
53
|
+
// 2. 至少 2 个非 Wikipedia 链接(Wikipedia 是常识入口,不是深度源)
|
|
54
|
+
// 3. 每个源必须有"为什么选它"的理由(萃取/理由/为什么 等关键词)
|
|
55
|
+
// 4. 源不能全是同一类型(如全是 Wikipedia、全是博客)
|
|
56
|
+
|
|
57
|
+
const MIN_SOURCES = 3;
|
|
58
|
+
const MIN_NON_WIKI = 2;
|
|
59
|
+
const REASON_KEYWORDS = ['萃取', '理由', '为什么', '因为', '启发', '借鉴', '参考理由', '选取理由', '转译'];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 从哲学文档内容中提取"灵感来源"章节的条目。
|
|
63
|
+
* @param {string} content — MD 全文
|
|
64
|
+
* @returns {Array<{ raw: string, name: string, urls: string[], hasReason: boolean }>}
|
|
65
|
+
*/
|
|
66
|
+
function parseInspirationSources(content) {
|
|
67
|
+
// 匹配 "## 灵感来源" 或 "## Inspiration" 章节
|
|
68
|
+
const sectionMatch = content.match(/^##\s+(?:灵感来源|Inspiration|参考来源|References)/m);
|
|
69
|
+
if (!sectionMatch) return [];
|
|
70
|
+
|
|
71
|
+
const startIdx = sectionMatch.index + sectionMatch[0].length;
|
|
72
|
+
// 找到下一个 ## 或文件末尾
|
|
73
|
+
const nextSection = content.slice(startIdx).match(/\n##\s/m);
|
|
74
|
+
const sectionText = nextSection
|
|
75
|
+
? content.slice(startIdx, startIdx + nextSection.index)
|
|
76
|
+
: content.slice(startIdx);
|
|
77
|
+
|
|
78
|
+
// 解析每个 list item(- 或 * 开头)
|
|
79
|
+
const items = [];
|
|
80
|
+
const lines = sectionText.split('\n');
|
|
81
|
+
let currentItem = null;
|
|
82
|
+
|
|
83
|
+
for (const line of lines) {
|
|
84
|
+
if (/^\s*[-*]\s/.test(line)) {
|
|
85
|
+
// 新条目
|
|
86
|
+
if (currentItem) items.push(currentItem);
|
|
87
|
+
const raw = line.replace(/^\s*[-*]\s/, '').trim();
|
|
88
|
+
const urls = [...raw.matchAll(/https?:\/\/[^\s))]+/g)].map((m) => m[0]);
|
|
89
|
+
const name = raw.replace(/\*\*/g, '').split(/[((——]/)[0].trim();
|
|
90
|
+
const hasReason = REASON_KEYWORDS.some((kw) => raw.includes(kw));
|
|
91
|
+
currentItem = { raw, name, urls, hasReason };
|
|
92
|
+
} else if (currentItem && line.trim()) {
|
|
93
|
+
// 多行条目的续行
|
|
94
|
+
currentItem.raw += ' ' + line.trim();
|
|
95
|
+
const newUrls = [...line.matchAll(/https?:\/\/[^\s))]+/g)].map((m) => m[0]);
|
|
96
|
+
currentItem.urls.push(...newUrls);
|
|
97
|
+
if (REASON_KEYWORDS.some((kw) => line.includes(kw))) {
|
|
98
|
+
currentItem.hasReason = true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (currentItem) items.push(currentItem);
|
|
103
|
+
return items;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 判断 URL 是否为 Wikipedia 链接。
|
|
108
|
+
*/
|
|
109
|
+
function isWikipediaUrl(url) {
|
|
110
|
+
return /wikipedia\.org/i.test(url);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 校验哲学文档的灵感来源质量。
|
|
115
|
+
* @param {string} philosophyDir — 00_PHILOSOPHY/ 目录路径
|
|
116
|
+
* @returns {{ passed: boolean, issues: Array<{severity: string, msg: string}>, sources: Array }}
|
|
117
|
+
*/
|
|
118
|
+
export function validateInspirationSources(philosophyDir) {
|
|
119
|
+
const issues = [];
|
|
120
|
+
const allSources = [];
|
|
121
|
+
|
|
122
|
+
// 扫描目录下所有 .md 文件,找"灵感来源"章节
|
|
123
|
+
const files = listPhilosophyFiles(philosophyDir);
|
|
124
|
+
|
|
125
|
+
for (const file of files) {
|
|
126
|
+
const content = readFileSync(join(philosophyDir, file), 'utf-8');
|
|
127
|
+
const sources = parseInspirationSources(content);
|
|
128
|
+
if (sources.length === 0) continue;
|
|
129
|
+
|
|
130
|
+
allSources.push({ file, sources });
|
|
131
|
+
|
|
132
|
+
// 校验每个文件的灵感来源
|
|
133
|
+
if (sources.length < MIN_SOURCES) {
|
|
134
|
+
issues.push({
|
|
135
|
+
severity: 'high',
|
|
136
|
+
msg: `${file}: 灵感来源仅 ${sources.length} 条,要求至少 ${MIN_SOURCES} 条。可能从训练数据"背"了几个名字就交差。`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const nonWikiUrls = sources.filter((s) => s.urls.length > 0 && !s.urls.every(isWikipediaUrl));
|
|
141
|
+
if (nonWikiUrls.length < MIN_NON_WIKI) {
|
|
142
|
+
issues.push({
|
|
143
|
+
severity: 'high',
|
|
144
|
+
msg: `${file}: 非 Wikipedia 链接仅 ${nonWikiUrls.length} 个,要求至少 ${MIN_NON_WIKI} 个。Wikipedia 是常识入口,不是深度源——需要原著、论文、工程博客、标准文档等。`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const src of sources) {
|
|
149
|
+
if (!src.hasReason) {
|
|
150
|
+
issues.push({
|
|
151
|
+
severity: 'medium',
|
|
152
|
+
msg: `${file}: 灵感来源 "${src.name}" 缺乏选取理由。必须说明"为什么选这个源"——萃取/转译/启发关系。`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (src.urls.length === 0) {
|
|
156
|
+
issues.push({
|
|
157
|
+
severity: 'medium',
|
|
158
|
+
msg: `${file}: 灵感来源 "${src.name}" 没有 URL。必须附可验证的来源链接。`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 全局校验:所有文件的灵感来源加起来,源类型不能单一
|
|
165
|
+
const totalUrls = allSources.flatMap((s) => s.sources).flatMap((s) => s.urls);
|
|
166
|
+
if (totalUrls.length > 0) {
|
|
167
|
+
const wikiCount = totalUrls.filter(isWikipediaUrl).length;
|
|
168
|
+
if (wikiCount / totalUrls.length > 0.7) {
|
|
169
|
+
issues.push({
|
|
170
|
+
severity: 'medium',
|
|
171
|
+
msg: `全部灵感来源中 Wikipedia 占比 ${Math.round((wikiCount / totalUrls.length) * 100)}%——源类型过于单一。需要原著、论文、标准文档、工程博客等多元源。`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 如果没有任何文件包含灵感来源章节
|
|
177
|
+
if (allSources.length === 0) {
|
|
178
|
+
issues.push({
|
|
179
|
+
severity: 'high',
|
|
180
|
+
msg: '所有哲学文档都没有"灵感来源"章节。PHILOSOPHY_WEAVER.md 要求哲学文档必须包含灵感来源(参考了哪些机构、人物、流派——附 URL 和理由)。',
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
passed: issues.length === 0,
|
|
186
|
+
issues,
|
|
187
|
+
sources: allSources,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ─── 实现部分清单校验 ───────────────────────────────────
|
|
192
|
+
// 检查哲学文档是否包含"实现部分清单"——Weaver 是否走了拆解流程。
|
|
193
|
+
// PHILOSOPHY_WEAVER.md Step 2 要求产出"实现部分清单"。
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 校验哲学文档是否包含实现部分拆解清单。
|
|
197
|
+
* Weaver 按 PART_DECOMPOSITION.md 拆解后,必须在哲学文档里显式列出拆解出的部分。
|
|
198
|
+
* @param {string} philosophyDir — 00_PHILOSOPHY/ 目录路径
|
|
199
|
+
* @returns {{ passed: boolean, issues: Array<{severity: string, msg: string}>, parts: string[] }}
|
|
200
|
+
*/
|
|
201
|
+
export function validatePartDecomposition(philosophyDir) {
|
|
202
|
+
const issues = [];
|
|
203
|
+
const parts = [];
|
|
204
|
+
|
|
205
|
+
const files = listPhilosophyFiles(philosophyDir);
|
|
206
|
+
|
|
207
|
+
// 搜索"实现部分"相关章节——可能叫"实现部分清单""部分拆解""Part Decomposition"等
|
|
208
|
+
const PART_SECTION_PATTERNS = [
|
|
209
|
+
/^##\s+实现部分清单/m,
|
|
210
|
+
/^##\s+部分拆解/m,
|
|
211
|
+
/^##\s+实现部分/m,
|
|
212
|
+
/^##\s+Part Decomposition/m,
|
|
213
|
+
/^##\s+Implementation Parts/m,
|
|
214
|
+
/^##\s+拆解出的部分/m,
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
// 搜索部分条目——通常是 "- **部分名**" 或 "├── 部分名" 或 "| 部分名 |"
|
|
218
|
+
const PART_ITEM_PATTERNS = [
|
|
219
|
+
/^\s*[-*]\s+\*\*(.+?)\*\*/gm, // - **CLI 交互设计**
|
|
220
|
+
/^\s*├──\s+(.+)/gm, // ├── CLI 交互设计
|
|
221
|
+
/^\s*└──\s+(.+)/gm, // └── 产物设计
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
let foundSection = false;
|
|
225
|
+
|
|
226
|
+
for (const file of files) {
|
|
227
|
+
const content = readFileSync(join(philosophyDir, file), 'utf-8');
|
|
228
|
+
|
|
229
|
+
// 检查是否有实现部分章节
|
|
230
|
+
for (const pattern of PART_SECTION_PATTERNS) {
|
|
231
|
+
if (pattern.test(content)) {
|
|
232
|
+
foundSection = true;
|
|
233
|
+
// 提取该章节的部分条目
|
|
234
|
+
const sectionMatch = content.match(pattern);
|
|
235
|
+
if (sectionMatch) {
|
|
236
|
+
const startIdx = sectionMatch.index + sectionMatch[0].length;
|
|
237
|
+
const nextSection = content.slice(startIdx).match(/\n##\s/m);
|
|
238
|
+
const sectionText = nextSection
|
|
239
|
+
? content.slice(startIdx, startIdx + nextSection.index)
|
|
240
|
+
: content.slice(startIdx);
|
|
241
|
+
|
|
242
|
+
for (const itemPattern of PART_ITEM_PATTERNS) {
|
|
243
|
+
const matches = [...sectionText.matchAll(itemPattern)];
|
|
244
|
+
for (const m of matches) {
|
|
245
|
+
const partName = m[1].trim().replace(/[—\-–].*$/, '').trim();
|
|
246
|
+
if (partName && !parts.includes(partName)) {
|
|
247
|
+
parts.push(partName);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!foundSection) {
|
|
258
|
+
issues.push({
|
|
259
|
+
severity: 'high',
|
|
260
|
+
msg: '哲学文档没有"实现部分清单"章节。PHILOSOPHY_WEAVER.md Step 2 要求按 PART_DECOMPOSITION.md 拆解实现部分,并在哲学文档中显式列出。可能 Weaver 跳过了拆解步骤。',
|
|
261
|
+
});
|
|
262
|
+
} else if (parts.length < 2) {
|
|
263
|
+
issues.push({
|
|
264
|
+
severity: 'medium',
|
|
265
|
+
msg: `实现部分清单仅识别到 ${parts.length} 个部分。PART_DECOMPOSITION.md 建议小项目 3-5 个部分,大项目 6-10 个。可能拆解不充分。`,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
passed: issues.length === 0,
|
|
271
|
+
issues,
|
|
272
|
+
parts,
|
|
273
|
+
};
|
|
274
|
+
}
|
package/cli/src/preview.js
CHANGED
|
@@ -1,15 +1,72 @@
|
|
|
1
|
-
// preview — 输出提示词,让 AI 读 .loom/ 文件并生成 HTML
|
|
2
|
-
// CLI
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
// preview — 输出提示词,让 AI 读 .loom/ 文件并生成 HTML,并检查投影新鲜度。
|
|
2
|
+
// CLI 不生成 HTML。AI 自己读文件、重组信息、生成 HTML。
|
|
3
|
+
|
|
4
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
5
|
+
import { join, relative } from 'node:path';
|
|
6
|
+
import { readCurrentPointer } from './shared/paths.js';
|
|
7
|
+
|
|
8
|
+
const PROMPT_PATH = new URL('./preview-prompt.md', import.meta.url);
|
|
9
|
+
const SOURCE_FILE_NAMES = new Set([
|
|
10
|
+
'01_VISION.md',
|
|
11
|
+
'02_ARCHITECTURE.md',
|
|
12
|
+
'04_INTENT_MAP.json',
|
|
13
|
+
'05_VERIFICATION.md',
|
|
14
|
+
'06_CHANGELOG.md',
|
|
15
|
+
]);
|
|
8
16
|
|
|
9
17
|
/**
|
|
10
18
|
* 输出 preview 提示词。
|
|
11
19
|
* @returns {string}
|
|
12
20
|
*/
|
|
13
|
-
export function generatePreviewPrompt() {
|
|
14
|
-
return readFileSync(PROMPT_PATH, 'utf-8');
|
|
15
|
-
}
|
|
21
|
+
export function generatePreviewPrompt() {
|
|
22
|
+
return readFileSync(PROMPT_PATH, 'utf-8');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function shouldIncludeSource(filePath) {
|
|
26
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
27
|
+
const fileName = normalized.split('/').pop();
|
|
28
|
+
if (SOURCE_FILE_NAMES.has(fileName)) return true;
|
|
29
|
+
return normalized.includes('/00_PHILOSOPHY/')
|
|
30
|
+
|| normalized.includes('/03_DECISIONS/')
|
|
31
|
+
|| normalized.includes('/verifications/');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function collectSourceFiles(dir, files = []) {
|
|
35
|
+
if (!existsSync(dir)) return files;
|
|
36
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
37
|
+
const fullPath = join(dir, entry.name);
|
|
38
|
+
if (entry.isDirectory()) {
|
|
39
|
+
collectSourceFiles(fullPath, files);
|
|
40
|
+
} else if (shouldIncludeSource(fullPath)) {
|
|
41
|
+
files.push(fullPath);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return files;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getPreviewStatus(projectDir) {
|
|
48
|
+
const previewPath = join(projectDir, 'loom-preview.html');
|
|
49
|
+
const loomRoot = join(projectDir, '.loom');
|
|
50
|
+
const current = readCurrentPointer(loomRoot);
|
|
51
|
+
const versionDir = current ? join(loomRoot, current) : null;
|
|
52
|
+
const sourceFiles = versionDir ? collectSourceFiles(versionDir) : [];
|
|
53
|
+
const latestSource = sourceFiles
|
|
54
|
+
.map((filePath) => ({ filePath, mtimeMs: statSync(filePath).mtimeMs }))
|
|
55
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)[0] || null;
|
|
56
|
+
|
|
57
|
+
const exists = existsSync(previewPath);
|
|
58
|
+
const previewMtimeMs = exists ? statSync(previewPath).mtimeMs : null;
|
|
59
|
+
const sourceLatestMtimeMs = latestSource?.mtimeMs ?? null;
|
|
60
|
+
const fresh = exists && sourceLatestMtimeMs !== null && previewMtimeMs >= sourceLatestMtimeMs;
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
exists,
|
|
64
|
+
fresh,
|
|
65
|
+
version: current,
|
|
66
|
+
preview_path: previewPath,
|
|
67
|
+
preview_mtime: previewMtimeMs ? new Date(previewMtimeMs).toISOString() : null,
|
|
68
|
+
source_latest_mtime: sourceLatestMtimeMs ? new Date(sourceLatestMtimeMs).toISOString() : null,
|
|
69
|
+
latest_source_file: latestSource ? relative(projectDir, latestSource.filePath).replace(/\\/g, '/') : null,
|
|
70
|
+
next_command: fresh ? 'loom preview' : 'loom preview --regen',
|
|
71
|
+
};
|
|
72
|
+
}
|
package/cli/src/verify.js
CHANGED
|
@@ -92,10 +92,14 @@ export function writeVerification(verificationsDir, record) {
|
|
|
92
92
|
data = { intent_id: record.intent_id, records: [] };
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
//
|
|
96
|
-
const round = data.records.length + 1;
|
|
97
|
-
const
|
|
98
|
-
|
|
95
|
+
// 计算轮次和连续 deviated 计数。规范要求中间出现 passed/blocked 后重置。
|
|
96
|
+
const round = data.records.length + 1;
|
|
97
|
+
const recordsWithCurrent = [...data.records, record];
|
|
98
|
+
let deviatedCount = 0;
|
|
99
|
+
for (let i = recordsWithCurrent.length - 1; i >= 0; i--) {
|
|
100
|
+
if (recordsWithCurrent[i].verdict !== 'deviated') break;
|
|
101
|
+
deviatedCount++;
|
|
102
|
+
}
|
|
99
103
|
|
|
100
104
|
// 追加新记录
|
|
101
105
|
data.records.push({
|