@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
@@ -0,0 +1,214 @@
1
+ // patch — authoritative Patch ledger and deterministic Markdown projection.
2
+ // Validation is structural only: verification commands are recorded, never executed.
3
+
4
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { isAbsolute, join } from 'node:path';
6
+ import { loadIntentMap } from './intent-map.js';
7
+
8
+ const JSON_FILE = '06_CHANGELOG.json';
9
+ const MARKDOWN_FILE = '06_CHANGELOG.md';
10
+ const PATCH_ID = /^PATCH-(\d{3,})$/;
11
+ const VERIFICATION_RESULTS = new Set(['passed', 'failed', 'skipped']);
12
+
13
+ export function createEmptyChangelog() {
14
+ return {
15
+ _meta: { schema_version: '1.0', source: JSON_FILE },
16
+ patches: [],
17
+ };
18
+ }
19
+
20
+ function requireText(value, field) {
21
+ if (typeof value !== 'string' || value.trim() === '') {
22
+ throw new Error(`Patch ${field} 必须是非空字符串`);
23
+ }
24
+ }
25
+
26
+ function validateSafeFile(file, index) {
27
+ requireText(file, `files[${index}]`);
28
+ const normalized = file.replace(/\\/g, '/');
29
+ if (isAbsolute(file) || /^[A-Za-z]:/.test(normalized) || normalized === '.' || normalized.startsWith('/') || normalized.split('/').includes('..')) {
30
+ throw new Error(`Patch files[${index}] 必须是安全的项目相对路径: ${file}`);
31
+ }
32
+ }
33
+
34
+ function validateRecord(record, intents, { stored = false } = {}) {
35
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
36
+ throw new Error('Patch 记录必须是 JSON object');
37
+ }
38
+ requireText(record.summary, 'summary');
39
+ requireText(record.reason, 'reason');
40
+
41
+ if (!Array.isArray(record.files) || record.files.length === 0) {
42
+ throw new Error('Patch files 必须是非空数组');
43
+ }
44
+ record.files.forEach(validateSafeFile);
45
+
46
+ if (record.affects !== undefined) {
47
+ if (!Array.isArray(record.affects)) throw new Error('Patch affects 必须是 Intent ID 数组');
48
+ for (const id of record.affects) {
49
+ requireText(id, 'affects[]');
50
+ if (!intents.has(id)) throw new Error(`Patch affects 引用了不存在的 Intent: ${id}`);
51
+ }
52
+ }
53
+
54
+ if (!Array.isArray(record.verification) || record.verification.length === 0) {
55
+ throw new Error('Patch verification 必须是非空数组');
56
+ }
57
+ let hasPassed = false;
58
+ record.verification.forEach((item, index) => {
59
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
60
+ throw new Error(`Patch verification[${index}] 必须是 object`);
61
+ }
62
+ if (item.command === undefined && item.method === undefined) {
63
+ throw new Error(`Patch verification[${index}] 必须提供 command 或 method`);
64
+ }
65
+ if (item.command !== undefined) requireText(item.command, `verification[${index}].command`);
66
+ if (item.method !== undefined) requireText(item.method, `verification[${index}].method`);
67
+ if (item.evidence !== undefined) requireText(item.evidence, `verification[${index}].evidence`);
68
+ if (!VERIFICATION_RESULTS.has(item.result)) {
69
+ throw new Error(`Patch verification[${index}].result 必须是 passed | failed | skipped`);
70
+ }
71
+ if (item.result === 'passed') hasPassed = true;
72
+ });
73
+ if (!hasPassed) throw new Error('Patch verification 至少需要一个 passed 结果');
74
+
75
+ if (stored) {
76
+ if (!PATCH_ID.test(record.id)) throw new Error(`Patch id 格式无效: ${record.id ?? '(missing)'}`);
77
+ if (typeof record.timestamp !== 'string' || Number.isNaN(Date.parse(record.timestamp))) {
78
+ throw new Error(`Patch timestamp 无效: ${record.timestamp ?? '(missing)'}`);
79
+ }
80
+ } else if (record.id !== undefined || record.timestamp !== undefined) {
81
+ throw new Error('Patch id 和 timestamp 由 CLI 分配,请勿在输入中提供');
82
+ }
83
+ }
84
+
85
+ function loadIntents(versionDir) {
86
+ return loadIntentMap(versionDir).intents;
87
+ }
88
+
89
+ function loadIntentIds(versionDir) {
90
+ return new Set(Object.keys(loadIntents(versionDir)));
91
+ }
92
+
93
+ export function renderChangelogMarkdown(changelog) {
94
+ const lines = [
95
+ '<!-- GENERATED FILE. DO NOT EDIT. Source: 06_CHANGELOG.json -->',
96
+ '# Patch Changelog',
97
+ '',
98
+ '> Generated deterministically from `06_CHANGELOG.json`. Edit the JSON through `loom patch record`.',
99
+ '',
100
+ ];
101
+ if (changelog.patches.length === 0) {
102
+ lines.push('_No patches recorded._', '');
103
+ return lines.join('\n');
104
+ }
105
+ for (const patch of changelog.patches) {
106
+ lines.push(`## ${patch.id} - ${patch.summary}`, '', `- Timestamp: ${patch.timestamp}`, `- Reason: ${patch.reason}`);
107
+ lines.push(`- Affects: ${(patch.affects || []).map((id) => `\`${id}\``).join(', ') || 'none'}`);
108
+ lines.push('- Files:', ...patch.files.map((file) => ` - \`${file}\``), '- Verification:');
109
+ for (const item of patch.verification) {
110
+ const verification = item.command ? `\`${item.command}\`` : item.method;
111
+ lines.push(` - ${item.result}: ${verification}`);
112
+ if (item.evidence) lines.push(` - Evidence: ${item.evidence}`);
113
+ }
114
+ lines.push('');
115
+ }
116
+ return lines.join('\n');
117
+ }
118
+
119
+ function loadChangelog(versionDir) {
120
+ const path = join(versionDir, JSON_FILE);
121
+ if (!existsSync(path)) throw new Error(`Patch changelog 不存在: ${path}`);
122
+ try {
123
+ return JSON.parse(readFileSync(path, 'utf-8'));
124
+ } catch (error) {
125
+ throw new Error(`Patch changelog JSON 解析失败: ${path}\n原因: ${error.message}`);
126
+ }
127
+ }
128
+
129
+ function validateChangelog(versionDir, changelog) {
130
+ if (!changelog || typeof changelog !== 'object' || Array.isArray(changelog)) {
131
+ throw new Error('Patch changelog 必须是 JSON object');
132
+ }
133
+ if (changelog._meta?.schema_version !== '1.0' || changelog._meta?.source !== JSON_FILE) {
134
+ throw new Error('Patch changelog _meta 无效:schema_version 必须为 1.0,source 必须为 06_CHANGELOG.json');
135
+ }
136
+ if (!Array.isArray(changelog.patches)) throw new Error('Patch changelog patches 必须是数组');
137
+ const intents = loadIntentIds(versionDir);
138
+ const ids = new Set();
139
+ changelog.patches.forEach((record, index) => {
140
+ validateRecord(record, intents, { stored: true });
141
+ const expected = `PATCH-${String(index + 1).padStart(3, '0')}`;
142
+ if (record.id !== expected) throw new Error(`Patch id 序列无效: 期望 ${expected},实际 ${record.id}`);
143
+ if (ids.has(record.id)) throw new Error(`Patch id 重复: ${record.id}`);
144
+ ids.add(record.id);
145
+ });
146
+ }
147
+
148
+ function writeChangelog(versionDir, changelog) {
149
+ writeFileSync(join(versionDir, JSON_FILE), `${JSON.stringify(changelog, null, 2)}\n`, 'utf-8');
150
+ writeFileSync(join(versionDir, MARKDOWN_FILE), renderChangelogMarkdown(changelog), 'utf-8');
151
+ }
152
+
153
+ export function scaffoldChangelog(versionDir) {
154
+ const jsonPath = join(versionDir, JSON_FILE);
155
+ const markdownPath = join(versionDir, MARKDOWN_FILE);
156
+ if (!existsSync(jsonPath) && existsSync(markdownPath)) {
157
+ const legacyMarkdown = readFileSync(markdownPath, 'utf-8');
158
+ if (!legacyMarkdown.startsWith('<!-- GENERATED FILE. DO NOT EDIT. Source: 06_CHANGELOG.json -->')) {
159
+ throw new Error(
160
+ `检测到手工维护的旧版 ${MARKDOWN_FILE},拒绝用空 Patch ledger 覆盖。\n` +
161
+ `请先把历史条目迁移到 ${JSON_FILE},或备份并删除旧 Markdown 后重试。`
162
+ );
163
+ }
164
+ }
165
+ const changelog = existsSync(jsonPath) ? loadChangelog(versionDir) : createEmptyChangelog();
166
+ if (!existsSync(jsonPath)) writeFileSync(jsonPath, `${JSON.stringify(changelog, null, 2)}\n`, 'utf-8');
167
+ writeFileSync(markdownPath, renderChangelogMarkdown(changelog), 'utf-8');
168
+ }
169
+
170
+ export function recordPatch(versionDir, input, now = new Date()) {
171
+ const changelog = loadChangelog(versionDir);
172
+ validateChangelog(versionDir, changelog);
173
+ const intents = loadIntents(versionDir);
174
+ const unfinished = Object.values(intents).filter((intent) => intent.status !== 'completed');
175
+ if (unfinished.length > 0) {
176
+ throw new Error(`Patch 只能在全部 Intent 完成后记录;尚未完成: ${unfinished.map((intent) => intent.id).join(', ')}`);
177
+ }
178
+ validateRecord(input, new Set(Object.keys(intents)));
179
+ const record = {
180
+ id: `PATCH-${String(changelog.patches.length + 1).padStart(3, '0')}`,
181
+ timestamp: now.toISOString(),
182
+ summary: input.summary.trim(),
183
+ reason: input.reason.trim(),
184
+ affects: input.affects || [],
185
+ files: input.files.map((file) => file.replace(/\\/g, '/')),
186
+ verification: input.verification,
187
+ };
188
+ changelog.patches.push(record);
189
+ writeChangelog(versionDir, changelog);
190
+ return record;
191
+ }
192
+
193
+ export function listPatches(versionDir) {
194
+ const changelog = loadChangelog(versionDir);
195
+ validateChangelog(versionDir, changelog);
196
+ return changelog.patches;
197
+ }
198
+
199
+ export function getPatch(versionDir, id) {
200
+ const patch = listPatches(versionDir).find((record) => record.id === id);
201
+ if (!patch) throw new Error(`Patch 不存在: ${id}`);
202
+ return patch;
203
+ }
204
+
205
+ export function validatePatches(versionDir) {
206
+ const changelog = loadChangelog(versionDir);
207
+ validateChangelog(versionDir, changelog);
208
+ const markdownPath = join(versionDir, MARKDOWN_FILE);
209
+ if (!existsSync(markdownPath)) throw new Error(`Patch Markdown 投影不存在: ${markdownPath}`);
210
+ if (readFileSync(markdownPath, 'utf-8') !== renderChangelogMarkdown(changelog)) {
211
+ throw new Error('06_CHANGELOG.md 不是 06_CHANGELOG.json 的最新确定性投影');
212
+ }
213
+ return { valid: true, patches: changelog.patches.length };
214
+ }
@@ -3,18 +3,23 @@
3
3
  // 这个库按锚点提取对应章节,不返回整个文件。
4
4
  // 另含灵感来源校验——防止 Weaver 从训练数据"背"几个名字就交差。
5
5
 
6
- import { readFileSync, existsSync, readdirSync } from 'node:fs';
7
- import { join } from 'node:path';
8
- import { extractMdSection } from './shared/md-utils.js';
6
+ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, renameSync, unlinkSync } from 'node:fs';
7
+ import { basename, join } from 'node:path';
8
+ import { extractMdSection, readJsonFile } from './shared/md-utils.js';
9
+ import { applyImpactReview, dependentClosure, validateImpactPartition, validateIntentMap } from './intent-map.js';
9
10
 
10
11
  /**
11
12
  * 解析锚点字符串。
12
13
  * @param {string} anchor — "FILE.md#section" 或 "FILE.md"
13
14
  * @returns {{ file: string, section: string|null }}
14
15
  */
15
- export function parseAnchor(anchor) {
16
- const [file, section] = anchor.split('#');
17
- return { file: file.trim(), section: section ? section.trim() : null };
16
+ export function parseAnchor(anchor) {
17
+ const [file, section] = anchor.split('#');
18
+ const normalizedFile = file.trim();
19
+ if (!normalizedFile || basename(normalizedFile) !== normalizedFile || !normalizedFile.endsWith('.md')) {
20
+ throw new Error(`哲学锚点文件名非法: ${normalizedFile || '(empty)'}`);
21
+ }
22
+ return { file: normalizedFile, section: section ? section.trim() : null };
18
23
  }
19
24
 
20
25
  /**
@@ -38,25 +43,167 @@ export function getPhilosophy(philosophyDir, anchor) {
38
43
  /**
39
44
  * 列出哲学目录下所有 .md 文件名。
40
45
  */
41
- export function listPhilosophyFiles(philosophyDir) {
46
+ export function listPhilosophyFiles(philosophyDir) {
42
47
  if (!existsSync(philosophyDir)) {
43
48
  throw new Error(`哲学目录不存在: ${philosophyDir}`);
44
49
  }
45
50
  const dir = readdirSync(philosophyDir);
46
51
  return dir.filter((f) => f.endsWith('.md'));
47
- }
52
+ }
53
+
54
+ const REVISION_CLASSIFICATIONS = ['clarification', 'minor', 'major'];
55
+
56
+ function normalizeAnchor(anchor) {
57
+ if (typeof anchor !== 'string' || anchor.trim() === '') throw new Error('哲学锚点必须是非空文本');
58
+ const parts = anchor.trim().split('#');
59
+ if (parts.length > 2 || !parts[0].trim()) throw new Error(`哲学锚点格式非法: ${anchor}`);
60
+ const { file, section } = parseAnchor(anchor.trim());
61
+ return section ? `${file}#${section}` : file;
62
+ }
63
+
64
+ function impactEntry(intent) {
65
+ return {
66
+ id: intent.id,
67
+ title: intent.title || '',
68
+ status: intent.status,
69
+ revision: intent.revision ?? 1,
70
+ acceptance: intent.acceptance,
71
+ };
72
+ }
73
+
74
+ /** Resolve an anchor and report direct references plus their dependent closure without mutation. */
75
+ export function assessPhilosophyImpact(versionDir, anchor) {
76
+ const resolvedAnchor = normalizeAnchor(anchor);
77
+ getPhilosophy(join(versionDir, '00_PHILOSOPHY'), resolvedAnchor);
78
+ const data = readJsonFile(join(versionDir, '04_INTENT_MAP.json'), 'Intent Map');
79
+ validateIntentMap(data);
80
+ const directIds = Object.values(data.intents)
81
+ .filter((intent) => (intent.philosophy_anchors || []).some((item) => normalizeAnchor(item) === resolvedAnchor))
82
+ .map((intent) => intent.id);
83
+ const directSet = new Set(directIds);
84
+ const impacted = new Set(directIds);
85
+ for (const id of directIds) {
86
+ for (const dependent of dependentClosure(data.intents, id).all) impacted.add(dependent);
87
+ }
88
+ const ordered = (data.topo_order || Object.keys(data.intents)).filter((id) => impacted.has(id));
89
+ const transitiveIds = ordered.filter((id) => !directSet.has(id));
90
+ const describe = (ids) => ids.map((id) => impactEntry(data.intents[id]));
91
+ return {
92
+ anchor: resolvedAnchor,
93
+ direct: describe(directIds),
94
+ transitive: describe(transitiveIds),
95
+ impacted: describe(ordered),
96
+ impacted_ids: ordered,
97
+ };
98
+ }
99
+
100
+ function nextRevisionAdr(decisionsDir) {
101
+ if (!existsSync(decisionsDir)) return 'PHIL-REV-001.md';
102
+ const numbers = readdirSync(decisionsDir)
103
+ .map((name) => name.match(/^PHIL-REV-(\d{3,})\.md$/)?.[1])
104
+ .filter(Boolean)
105
+ .map(Number);
106
+ return `PHIL-REV-${String((numbers.length ? Math.max(...numbers) : 0) + 1).padStart(3, '0')}.md`;
107
+ }
108
+
109
+ function revisionGuidance(anchor, classification, reason, impactedIds) {
110
+ const escapedReason = reason.replace(/"/g, '\\"');
111
+ const base = `loom philosophy revise ${anchor} --classification ${classification} --reason "${escapedReason}" --confirm`;
112
+ if (classification === 'clarification') {
113
+ return impactedIds.length ? `${base} --unaffected ${impactedIds.join(',')}` : base;
114
+ }
115
+ if (!impactedIds.length) return base;
116
+ return `${base} --review <review IDs from ${impactedIds.join(',')}> --unaffected <remaining IDs from ${impactedIds.join(',')}>`;
117
+ }
118
+
119
+ /** Assess or confirm a philosophy revision while leaving philosophy prose to Weaver/the user. */
120
+ export function revisePhilosophy(versionDir, anchor, options) {
121
+ if (!REVISION_CLASSIFICATIONS.includes(options.classification)) {
122
+ throw new Error(`--classification 非法: ${options.classification || '(missing)'}(合法: ${REVISION_CLASSIFICATIONS.join('|')})`);
123
+ }
124
+ if (typeof options.reason !== 'string' || options.reason.trim() === '') throw new Error('--reason 必须是非空文本');
125
+ const reason = options.reason.trim();
126
+ const impact = assessPhilosophyImpact(versionDir, anchor);
127
+
128
+ if (options.classification === 'major') {
129
+ return {
130
+ mode: 'assessment',
131
+ mutated: false,
132
+ classification: 'major',
133
+ reason,
134
+ ...impact,
135
+ follow_up: { command: 'loom version new', guidance: 'Major philosophy revisions never mutate the current version. Create a new version, then have Weaver weave its philosophy.' },
136
+ };
137
+ }
138
+ if (!options.confirm) {
139
+ return {
140
+ mode: 'assessment',
141
+ mutated: false,
142
+ classification: options.classification,
143
+ reason,
144
+ ...impact,
145
+ required_partition: impact.impacted_ids,
146
+ follow_up: {
147
+ command: revisionGuidance(impact.anchor, options.classification, reason, impact.impacted_ids),
148
+ guidance: options.classification === 'clarification'
149
+ ? 'Clarification requires an empty --review set; classify every impacted Intent as --unaffected because all acceptance remains valid.'
150
+ : 'Classify every impacted Intent exactly once between --review and --unaffected; omit an empty group.',
151
+ },
152
+ };
153
+ }
154
+
155
+ const review = options.review || [];
156
+ const unaffected = options.unaffected || [];
157
+ if (options.classification === 'clarification' && review.length) throw new Error('clarification 的 --review 必须为空;所有 acceptance 必须仍然有效');
158
+ validateImpactPartition(impact.impacted_ids, review, unaffected);
159
+ const reviewSet = new Set(review);
160
+ const unaffectedSet = new Set(unaffected);
161
+ const orderedReview = impact.impacted_ids.filter((id) => reviewSet.has(id));
162
+ const orderedUnaffected = impact.impacted_ids.filter((id) => unaffectedSet.has(id));
163
+
164
+ const mapPath = join(versionDir, '04_INTENT_MAP.json');
165
+ const data = readJsonFile(mapPath, 'Intent Map');
166
+ validateIntentMap(data);
167
+ const { reviewed } = applyImpactReview(data, orderedReview, { incrementPassOnce: options.classification === 'minor' });
168
+ const unchanged = orderedUnaffected.map((id) => ({ id, status_before: data.intents[id].status, status_after: data.intents[id].status }));
169
+ validateIntentMap(data);
170
+
171
+ const decisionsDir = join(versionDir, '03_DECISIONS');
172
+ mkdirSync(decisionsDir, { recursive: true });
173
+ const adrName = nextRevisionAdr(decisionsDir);
174
+ const adrPath = join(decisionsDir, adrName);
175
+ const timestamp = new Date().toISOString();
176
+ const list = (ids) => ids.length ? ids.map((id) => `- ${id}`).join('\n') : '- None';
177
+ const adr = `# Philosophy Revision ${adrName.slice(9, -3)}\n\n` +
178
+ `- Timestamp: ${timestamp}\n- Anchor: ${impact.anchor}\n- Classification: ${options.classification}\n- Reason: ${reason}\n\n` +
179
+ `## Reviewed\n\n${list(orderedReview)}\n\n## Unaffected\n\n${list(orderedUnaffected)}\n\n` +
180
+ '## Prose Ownership\n\nPhilosophy prose is edited by Weaver/user separately. This ADR is an impact audit record, not a second philosophy truth source.\n';
181
+ const token = `${process.pid}-${Date.now()}`;
182
+ const mapTemp = `${mapPath}.tmp-${token}`;
183
+ const adrTemp = `${adrPath}.tmp-${token}`;
184
+ writeFileSync(mapTemp, `${JSON.stringify(data, null, 2)}\n`, 'utf-8');
185
+ writeFileSync(adrTemp, adr, 'utf-8');
186
+ try {
187
+ renameSync(adrTemp, adrPath);
188
+ try {
189
+ renameSync(mapTemp, mapPath);
190
+ } catch (error) {
191
+ try { unlinkSync(adrPath); } catch {}
192
+ throw error;
193
+ }
194
+ } catch (error) {
195
+ try { unlinkSync(mapTemp); } catch {}
196
+ try { unlinkSync(adrTemp); } catch {}
197
+ throw error;
198
+ }
199
+ return { mode: 'confirmed', mutated: true, classification: options.classification, reason, ...impact, reviewed, unaffected: unchanged, audit_adr: `03_DECISIONS/${adrName}`, timestamp };
200
+ }
48
201
 
49
202
  // ─── 灵感来源校验 ───────────────────────────────────────
50
203
  // 防止 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 = ['萃取', '理由', '为什么', '因为', '启发', '借鉴', '参考理由', '选取理由', '转译'];
204
+ // 校验原则:只检查证据能否追溯,以及为何影响了项目判断。
205
+ // 来源数量与类型不设固定配额;一个直接证据可以胜过十个装饰性链接。
206
+ const REASON_KEYWORDS = ['萃取', '理由', '为什么', '因为', '启发', '借鉴', '参考理由', '选取理由', '转译'];
60
207
 
61
208
  /**
62
209
  * 从哲学文档内容中提取"灵感来源"章节的条目。
@@ -68,7 +215,7 @@ const REASON_KEYWORDS = ['萃取', '理由', '为什么', '因为', '启发', '
68
215
  function parseInspirationSources(content) {
69
216
  // 匹配 "## 灵感来源" 或 "## Inspiration Sources" 等章节
70
217
  // 支持 {#anchor} 后缀和多种标题变体
71
- const sectionMatch = content.match(/^##\s+(?:灵感来源|Inspiration|参考来源|References?|参考文献|参考资料|Sources|Bibliography)/m);
218
+ const sectionMatch = content.match(/^##\s+(?:灵感来源|Inspiration|参考来源|References?|参考文献|参考资料|Sources|Bibliography)/m);
72
219
  if (!sectionMatch) return [];
73
220
 
74
221
  const startIdx = sectionMatch.index + sectionMatch[0].length;
@@ -85,20 +232,22 @@ function parseInspirationSources(content) {
85
232
 
86
233
  // 匹配 - xxx / * xxx / 1. xxx / 2. xxx 等
87
234
  const ITEM_RE = /^\s*(?:[-*]|\d+\.)\s+/;
235
+ // URL 匹配:https:// / file:// / local:./path / local:/abs/path
236
+ const URL_RE = /(?:https?:|file:)[\/]+[^\s))]+|local:[^\s))]+/g;
88
237
 
89
238
  for (const line of lines) {
90
239
  if (ITEM_RE.test(line)) {
91
240
  // 新条目
92
241
  if (currentItem) items.push(currentItem);
93
242
  const raw = line.replace(ITEM_RE, '').trim();
94
- const urls = [...raw.matchAll(/https?:\/\/[^\s))]+/g)].map((m) => m[0]);
243
+ const urls = [...raw.matchAll(URL_RE)].map((m) => m[0]);
95
244
  const name = raw.replace(/\*\*/g, '').split(/[((——]/)[0].trim();
96
245
  const hasReason = REASON_KEYWORDS.some((kw) => raw.includes(kw));
97
246
  currentItem = { raw, name, urls, hasReason };
98
247
  } else if (currentItem && line.trim()) {
99
248
  // 多行条目的续行
100
249
  currentItem.raw += ' ' + line.trim();
101
- const newUrls = [...line.matchAll(/https?:\/\/[^\s))]+/g)].map((m) => m[0]);
250
+ const newUrls = [...line.matchAll(URL_RE)].map((m) => m[0]);
102
251
  currentItem.urls.push(...newUrls);
103
252
  if (REASON_KEYWORDS.some((kw) => line.includes(kw))) {
104
253
  currentItem.hasReason = true;
@@ -113,16 +262,9 @@ function parseInspirationSources(content) {
113
262
  * 检查哲学文档是否有"灵感来源"章节(不管有没有条目)。
114
263
  * 用来区分"没有章节"和"有章节但没条目"两种情况。
115
264
  */
116
- function hasInspirationSection(content) {
117
- return /^##\s+(?:灵感来源|Inspiration|参考来源|References?|参考文献|参考资料|Sources|Bibliography)/m.test(content);
118
- }
119
-
120
- /**
121
- * 判断 URL 是否为 Wikipedia 链接。
122
- */
123
- function isWikipediaUrl(url) {
124
- return /wikipedia\.org/i.test(url);
125
- }
265
+ function hasInspirationSection(content) {
266
+ return /^##\s+(?:证据地图|Evidence Map|灵感来源|Inspiration|参考来源|References?|参考文献|参考资料|Sources|Bibliography)/m.test(content);
267
+ }
126
268
 
127
269
  /**
128
270
  * 校验哲学文档的灵感来源质量。
@@ -143,23 +285,7 @@ export function validateInspirationSources(philosophyDir) {
143
285
 
144
286
  allSources.push({ file, sources });
145
287
 
146
- // 校验每个文件的灵感来源
147
- if (sources.length < MIN_SOURCES) {
148
- issues.push({
149
- severity: 'high',
150
- msg: `${file}: 灵感来源仅 ${sources.length} 条,要求至少 ${MIN_SOURCES} 条。可能从训练数据"背"了几个名字就交差。`,
151
- });
152
- }
153
-
154
- const nonWikiUrls = sources.filter((s) => s.urls.length > 0 && !s.urls.every(isWikipediaUrl));
155
- if (nonWikiUrls.length < MIN_NON_WIKI) {
156
- issues.push({
157
- severity: 'high',
158
- msg: `${file}: 非 Wikipedia 链接仅 ${nonWikiUrls.length} 个,要求至少 ${MIN_NON_WIKI} 个。Wikipedia 是常识入口,不是深度源——需要原著、论文、工程博客、标准文档等。`,
159
- });
160
- }
161
-
162
- for (const src of sources) {
288
+ for (const src of sources) {
163
289
  if (!src.hasReason) {
164
290
  issues.push({
165
291
  severity: 'medium',
@@ -175,18 +301,6 @@ export function validateInspirationSources(philosophyDir) {
175
301
  }
176
302
  }
177
303
 
178
- // 全局校验:所有文件的灵感来源加起来,源类型不能单一
179
- const totalUrls = allSources.flatMap((s) => s.sources).flatMap((s) => s.urls);
180
- if (totalUrls.length > 0) {
181
- const wikiCount = totalUrls.filter(isWikipediaUrl).length;
182
- if (wikiCount / totalUrls.length > 0.7) {
183
- issues.push({
184
- severity: 'medium',
185
- msg: `全部灵感来源中 Wikipedia 占比 ${Math.round((wikiCount / totalUrls.length) * 100)}%——源类型过于单一。需要原著、论文、标准文档、工程博客等多元源。`,
186
- });
187
- }
188
- }
189
-
190
304
  // 如果没有任何文件提取到灵感来源条目
191
305
  if (allSources.length === 0) {
192
306
  // 区分两种情况:完全没有章节 vs 有章节但没条目
@@ -199,108 +313,19 @@ export function validateInspirationSources(philosophyDir) {
199
313
  if (filesWithSection.length > 0) {
200
314
  issues.push({
201
315
  severity: 'high',
202
- msg: `${filesWithSection.join(', ')} 有"灵感来源"章节但没有可识别的条目。章节里可能是模板占位符。需要 Weaver 真正走搜索漏斗,填入至少 ${MIN_SOURCES} 个源(- **源名** — 理由。来源:URL 格式)。`,
316
+ msg: `${filesWithSection.join(', ')} 有证据章节但没有可识别的条目。请只填入实际改变了原则或取舍的来源,并写明选择理由与可追溯位置。`,
203
317
  });
204
318
  } else {
205
319
  issues.push({
206
320
  severity: 'high',
207
- msg: '所有哲学文档都没有"灵感来源"章节。PHILOSOPHY_WEAVER.md 要求哲学文档必须包含灵感来源(参考了哪些机构、人物、流派——附 URL 和理由)。支持的标题:灵感来源 / Inspiration / 参考来源 / References / 参考文献 / 参考资料 / Sources / Bibliography。',
321
+ msg: '所有哲学文档都没有证据地图或灵感来源。Doctrine 必须说明哪些项目事实或外部资料实际改变了原则与取舍,并提供理由和可追溯位置。',
208
322
  });
209
323
  }
210
324
  }
211
325
 
212
- return {
213
- passed: issues.length === 0,
214
- issues,
215
- sources: allSources,
216
- };
217
- }
218
-
219
- // ─── 实现部分清单校验 ───────────────────────────────────
220
- // 检查哲学文档是否包含"实现部分清单"——Weaver 是否走了拆解流程。
221
- // PHILOSOPHY_WEAVER.md Step 2 要求产出"实现部分清单"。
222
-
223
- /**
224
- * 校验哲学文档是否包含实现部分拆解清单。
225
- * Weaver 按 PART_DECOMPOSITION.md 拆解后,必须在哲学文档里显式列出拆解出的部分。
226
- * @param {string} philosophyDir — 00_PHILOSOPHY/ 目录路径
227
- * @returns {{ passed: boolean, issues: Array<{severity: string, msg: string}>, parts: string[] }}
228
- */
229
- export function validatePartDecomposition(philosophyDir) {
230
- const issues = [];
231
- const parts = [];
232
-
233
- const files = listPhilosophyFiles(philosophyDir);
234
-
235
- // 搜索"实现部分"相关章节——支持 {#anchor} 后缀和多种标题变体
236
- const PART_SECTION_PATTERNS = [
237
- /^##\s+实现部分清单/m,
238
- /^##\s+部分拆解/m,
239
- /^##\s+实现部分/m,
240
- /^##\s+Part Decomposition/m,
241
- /^##\s+Implementation Parts/m,
242
- /^##\s+拆解出的部分/m,
243
- /^##\s+Implementation Decomposition/m,
244
- /^##\s+Parts? /m,
245
- ];
246
-
247
- // 搜索部分条目——支持无序列表、有序列表、树形符号
248
- const PART_ITEM_PATTERNS = [
249
- /^\s*[-*]\s+\*\*(.+?)\*\*/gm, // - **CLI 交互设计**
250
- /^\s*\d+\.\s+\*\*(.+?)\*\*/gm, // 1. **CLI 交互设计**
251
- /^\s*\d+\.\s+(.+)/gm, // 1. CLI 交互设计
252
- /^\s*├──\s+(.+)/gm, // ├── CLI 交互设计
253
- /^\s*└──\s+(.+)/gm, // └── 产物设计
254
- ];
255
-
256
- let foundSection = false;
257
-
258
- for (const file of files) {
259
- const content = readFileSync(join(philosophyDir, file), 'utf-8');
260
-
261
- // 检查是否有实现部分章节
262
- for (const pattern of PART_SECTION_PATTERNS) {
263
- if (pattern.test(content)) {
264
- foundSection = true;
265
- // 提取该章节的部分条目
266
- const sectionMatch = content.match(pattern);
267
- if (sectionMatch) {
268
- const startIdx = sectionMatch.index + sectionMatch[0].length;
269
- const nextSection = content.slice(startIdx).match(/\n##\s/m);
270
- const sectionText = nextSection
271
- ? content.slice(startIdx, startIdx + nextSection.index)
272
- : content.slice(startIdx);
273
-
274
- for (const itemPattern of PART_ITEM_PATTERNS) {
275
- const matches = [...sectionText.matchAll(itemPattern)];
276
- for (const m of matches) {
277
- const partName = m[1].trim().replace(/[—\-–].*$/, '').trim();
278
- if (partName && !parts.includes(partName)) {
279
- parts.push(partName);
280
- }
281
- }
282
- }
283
- }
284
- break;
285
- }
286
- }
287
- }
288
-
289
- if (!foundSection) {
290
- issues.push({
291
- severity: 'high',
292
- msg: '哲学文档没有"实现部分清单"章节。PHILOSOPHY_WEAVER.md Step 2 要求按 PART_DECOMPOSITION.md 拆解实现部分,并在哲学文档中显式列出。支持的标题:实现部分清单 / 部分拆解 / 实现部分 / Part Decomposition / Implementation Parts / 拆解出的部分。',
293
- });
294
- } else if (parts.length < 2) {
295
- issues.push({
296
- severity: 'medium',
297
- msg: `找到"实现部分清单"章节但仅识别到 ${parts.length} 个部分。可能章节里是模板占位符,或条目格式不被识别(用 - **部分名** 或 ├── 部分名 格式)。PART_DECOMPOSITION.md 建议小项目 3-5 个部分,大项目 6-10 个。`,
298
- });
299
- }
300
-
301
- return {
302
- passed: issues.length === 0,
303
- issues,
304
- parts,
305
- };
306
- }
326
+ return {
327
+ passed: issues.length === 0,
328
+ issues,
329
+ sources: allSources,
330
+ };
331
+ }