@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.
@@ -3,85 +3,22 @@
3
3
 
4
4
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
5
5
  import { resolve, dirname, join } from 'node:path';
6
-
7
- /**
8
- * 从 heading 文本中提取显式锚点(和 philosophy.js 一致的逻辑)。
9
- */
10
- function extractExplicitAnchor(headingText) {
11
- const match = headingText.match(/\{#([\w-]+)\}\s*$/);
12
- return match ? match[1] : null;
13
- }
14
-
15
- /**
16
- * slugify — strip \r、支持显式锚点、ASCII fallback。
17
- */
18
- function slugify(text) {
19
- return text
20
- .replace(/\r/g, '')
21
- .replace(/\{#[\w-]+\}\s*$/, '')
22
- .toLowerCase()
23
- .replace(/[^\w\s-]/g, '')
24
- .replace(/\s+/g, '-')
25
- .replace(/-+/g, '-')
26
- .replace(/^-|-$/g, '')
27
- .trim();
28
- }
29
-
30
- /**
31
- * 从 MD 内容中提取指定 section(和 philosophy.js 一致的逻辑)。
32
- */
33
- function extractMdSection(content, sectionSlug) {
34
- if (!sectionSlug) return content;
35
- const lines = content.split('\n');
36
- let capturing = false;
37
- let targetLevel = 0;
38
- const captured = [];
39
- for (const line of lines) {
40
- const cleanLine = line.replace(/\r$/, '');
41
- const headingMatch = cleanLine.match(/^(#{1,6})\s+(.+)$/);
42
- if (headingMatch) {
43
- const level = headingMatch[1].length;
44
- const headingText = headingMatch[2];
45
- const slug = extractExplicitAnchor(headingText) || slugify(headingText);
46
- if (capturing && level <= targetLevel) break;
47
- if (slug === sectionSlug) {
48
- capturing = true;
49
- targetLevel = level;
50
- captured.push(cleanLine);
51
- continue;
52
- }
53
- }
54
- if (capturing) captured.push(cleanLine);
55
- }
56
- if (captured.length === 0) {
57
- throw new Error(`意图叙事章节未找到: #${sectionSlug}`);
58
- }
59
- return captured.join('\n').trim();
60
- }
6
+ import { extractMdSection, readJsonFile } from './shared/md-utils.js';
61
7
 
62
8
  /** 必填字段(INTENT_LOOP.md 底线) */
63
- const REQUIRED_FIELDS = ['id', 'narrative_ref', 'depends_on', 'acceptance', 'philosophy_anchors', 'status'];
9
+ const REQUIRED_FIELDS = ['id', 'title', 'narrative_ref', 'depends_on', 'acceptance', 'philosophy_anchors', 'status'];
64
10
 
65
11
  /** 合法 status 值 */
66
12
  const VALID_STATUS = ['pending', 'in_progress', 'completed', 'blocked', 'needs_review'];
67
13
 
68
14
  /**
69
15
  * 加载 Intent Map 文件。
70
- * @param {string} loomDir — .loom/v{N}/ 目录的绝对路径
16
+ * @param {string} versionDir — .loom/v{N}/ 目录的绝对路径
71
17
  * @returns {{ _meta: object, intents: Record<string, object>, topo_order: string[] }}
72
18
  */
73
- export function loadIntentMap(loomDir) {
74
- const filePath = join(loomDir, '04_INTENT_MAP.json');
75
- if (!existsSync(filePath)) {
76
- throw new Error(`Intent Map 不存在: ${filePath}`);
77
- }
78
- const raw = readFileSync(filePath, 'utf-8');
79
- let data;
80
- try {
81
- data = JSON.parse(raw);
82
- } catch (e) {
83
- throw new Error(`Intent Map JSON 解析失败: ${e.message}`);
84
- }
19
+ export function loadIntentMap(versionDir) {
20
+ const filePath = join(versionDir, '04_INTENT_MAP.json');
21
+ const data = readJsonFile(filePath, 'Intent Map');
85
22
  validateIntentMap(data);
86
23
  return data;
87
24
  }
@@ -126,6 +63,16 @@ export function validateIntentMap(data) {
126
63
  }
127
64
  }
128
65
  }
66
+ // acceptance 质量底线(IM-2):必须具体到可验证,不能是占位符
67
+ if (intent.acceptance && typeof intent.acceptance === 'string') {
68
+ const acc = intent.acceptance.trim();
69
+ if (acc === '...' || acc === '' || acc.length < 20) {
70
+ errors.push(
71
+ `intents["${id}"].acceptance 太短(${acc.length}字符)——必须是具体可验证的契约,不能是占位符。\n` +
72
+ ` acceptance 应包含功能承诺 + 防御承诺(见 INTENT_LOOP.md IM-2 "acceptance 承诺分层")。`
73
+ );
74
+ }
75
+ }
129
76
  }
130
77
 
131
78
  // topo_order 必须覆盖所有 Intent
@@ -148,8 +95,8 @@ export function validateIntentMap(data) {
148
95
  * status=pending 且 depends_on 全部 completed,按 topo_order 取第一个。
149
96
  * @returns {object|null} Intent 对象,或 null(没有可执行的)
150
97
  */
151
- export function getNextIntent(loomDir) {
152
- const { intents, topo_order } = loadIntentMap(loomDir);
98
+ export function getNextIntent(versionDir) {
99
+ const { intents, topo_order } = loadIntentMap(versionDir);
153
100
  for (const id of topo_order) {
154
101
  const intent = intents[id];
155
102
  if (intent.status !== 'pending') continue;
@@ -164,12 +111,14 @@ export function getNextIntent(loomDir) {
164
111
  /**
165
112
  * 返回进度概览:各状态的 Intent 数量 + ID 列表。
166
113
  */
167
- export function getStatus(loomDir) {
168
- const { intents } = loadIntentMap(loomDir);
114
+ export function getStatus(versionDir) {
115
+ const { intents } = loadIntentMap(versionDir);
169
116
  const summary = { pending: [], in_progress: [], completed: [], blocked: [] };
117
+ const titles = {};
170
118
  for (const [id, intent] of Object.entries(intents)) {
171
119
  const s = intent.status;
172
120
  if (summary[s]) summary[s].push(id);
121
+ titles[id] = intent.title || '';
173
122
  }
174
123
  return {
175
124
  counts: {
@@ -180,14 +129,15 @@ export function getStatus(loomDir) {
180
129
  total: Object.keys(intents).length,
181
130
  },
182
131
  ids: summary,
132
+ titles,
183
133
  };
184
134
  }
185
135
 
186
136
  /**
187
137
  * 输出 Mermaid 依赖图。
188
138
  */
189
- export function getDependencyGraph(loomDir) {
190
- const { intents, topo_order } = loadIntentMap(loomDir);
139
+ export function getDependencyGraph(versionDir) {
140
+ const { intents, topo_order } = loadIntentMap(versionDir);
191
141
  const lines = ['```mermaid', 'graph TD'];
192
142
  for (const id of topo_order) {
193
143
  const intent = intents[id];
@@ -209,8 +159,8 @@ export function getDependencyGraph(loomDir) {
209
159
  /**
210
160
  * 按 ID 返回单个 Intent 的完整信息。
211
161
  */
212
- export function getIntent(loomDir, intentId) {
213
- const { intents } = loadIntentMap(loomDir);
162
+ export function getIntent(versionDir, intentId) {
163
+ const { intents } = loadIntentMap(versionDir);
214
164
  if (!(intentId in intents)) {
215
165
  throw new Error(`Intent 不存在: ${intentId}`);
216
166
  }
@@ -220,18 +170,18 @@ export function getIntent(loomDir, intentId) {
220
170
  /**
221
171
  * 更新 Intent 的运行时 status。
222
172
  * 只允许合法的状态转换,防止跳变(如 completed→pending)。
223
- * @param {string} loomDir — .loom/v{N}/ 目录
173
+ * @param {string} versionDir — .loom/v{N}/ 目录
224
174
  * @param {string} intentId — Intent ID
225
175
  * @param {string} newStatus — pending | in_progress | completed | blocked
226
176
  * @returns {object} 更新后的 Intent
227
177
  */
228
- export function updateIntentStatus(loomDir, intentId, newStatus) {
178
+ export function updateIntentStatus(versionDir, intentId, newStatus) {
229
179
  if (!VALID_STATUS.includes(newStatus)) {
230
180
  throw new Error(`非法 status: "${newStatus}" (合法: ${VALID_STATUS.join('|')})`);
231
181
  }
232
182
 
233
- const filePath = join(loomDir, '04_INTENT_MAP.json');
234
- const data = JSON.parse(readFileSync(filePath, 'utf-8'));
183
+ const filePath = join(versionDir, '04_INTENT_MAP.json');
184
+ const data = readJsonFile(filePath, 'Intent Map');
235
185
 
236
186
  if (!(intentId in data.intents)) {
237
187
  throw new Error(`Intent 不存在: ${intentId}`);
@@ -253,6 +203,13 @@ export function updateIntentStatus(loomDir, intentId, newStatus) {
253
203
  );
254
204
  }
255
205
 
206
+ // 收敛趟计数:completed → needs_review 时 _meta.pass_count++
207
+ // 这是变更回流的信号——一个已完成 Intent 需要重新验证,进入新一轮收敛
208
+ if (oldStatus === 'completed' && newStatus === 'needs_review') {
209
+ if (!data._meta) data._meta = {};
210
+ data._meta.pass_count = (data._meta.pass_count || 0) + 1;
211
+ }
212
+
256
213
  data.intents[intentId].status = newStatus;
257
214
  writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
258
215
  return data.intents[intentId];
@@ -263,8 +220,8 @@ export function updateIntentStatus(loomDir, intentId, newStatus) {
263
220
  * narrative_ref 格式: "01_VISION.md#int-001" 或 "01_VISION.md#int-001"
264
221
  * @returns {string} 意图叙事内容
265
222
  */
266
- export function getNarrative(loomDir, intentId) {
267
- const { intents } = loadIntentMap(loomDir);
223
+ export function getNarrative(versionDir, intentId) {
224
+ const { intents } = loadIntentMap(versionDir);
268
225
  if (!(intentId in intents)) {
269
226
  throw new Error(`Intent 不存在: ${intentId}`);
270
227
  }
@@ -275,10 +232,10 @@ export function getNarrative(loomDir, intentId) {
275
232
 
276
233
  // 解析 "FILE.md#section" 格式
277
234
  const [file, section] = ref.split('#');
278
- const filePath = join(loomDir, file.trim());
235
+ const filePath = join(versionDir, file.trim());
279
236
  if (!existsSync(filePath)) {
280
237
  throw new Error(`愿景文档不存在: ${filePath}`);
281
238
  }
282
239
  const content = readFileSync(filePath, 'utf-8');
283
- return extractMdSection(content, section ? section.trim() : null);
240
+ return extractMdSection(content, section ? section.trim() : null, '意图叙事');
284
241
  }
@@ -1,9 +1,11 @@
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';
8
+ import { extractMdSection } from './shared/md-utils.js';
7
9
 
8
10
  /**
9
11
  * 解析锚点字符串。
@@ -16,102 +18,257 @@ export function parseAnchor(anchor) {
16
18
  }
17
19
 
18
20
  /**
19
- * 从 heading 文本中提取显式锚点。
20
- * 支持 Pandoc/MDX 风格语法: "## 核心信念 {#core-belief}"
21
- * @returns {string|null} 显式锚点 slug,或 null(无显式锚点时)
21
+ * 按锚点加载哲学文档内容。
22
+ * @param {string} philosophyDir 00_PHILOSOPHY/ 目录路径
23
+ * @param {string} anchor "PRODUCT_PHILOSOPHY.md#core-belief"
24
+ * @returns {string} MD 文本(章节或整个文件)
22
25
  */
23
- function extractExplicitAnchor(headingText) {
24
- const match = headingText.match(/\{#([\w-]+)\}\s*$/);
25
- return match ? match[1] : null;
26
+ export function getPhilosophy(philosophyDir, anchor) {
27
+ const { file, section } = parseAnchor(anchor);
28
+ const filePath = join(philosophyDir, file);
29
+
30
+ if (!existsSync(filePath)) {
31
+ throw new Error(`哲学文档不存在: ${filePath}`);
32
+ }
33
+
34
+ const content = readFileSync(filePath, 'utf-8');
35
+ return extractMdSection(content, section, '哲学');
26
36
  }
27
37
 
28
38
  /**
29
- * heading 文本生成 slug(fallback,无显式锚点时用)。
30
- * 规则:小写、去除 \r、空格转连字符、去除非 [a-z0-9_-] 字符。
31
- * 注意:\w 不匹配中文,所以中文标题的 slug 会是空的——
32
- * 这就是为什么哲学文档应该用显式锚点 {#anchor} 标注中文标题。
39
+ * 列出哲学目录下所有 .md 文件名。
33
40
  */
34
- function slugify(text) {
35
- return text
36
- .replace(/\r/g, '') // strip CRLF 的 \r
37
- .replace(/\{#[\w-]+\}\s*$/, '') // 去掉显式锚点标记
38
- .toLowerCase()
39
- .replace(/[^\w\s-]/g, '') // \w = [a-zA-Z0-9_]
40
- .replace(/\s+/g, '-')
41
- .replace(/-+/g, '-')
42
- .replace(/^-|-$/g, '')
43
- .trim();
41
+ export function listPhilosophyFiles(philosophyDir) {
42
+ if (!existsSync(philosophyDir)) {
43
+ throw new Error(`哲学目录不存在: ${philosophyDir}`);
44
+ }
45
+ const dir = readdirSync(philosophyDir);
46
+ return dir.filter((f) => f.endsWith('.md'));
44
47
  }
45
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
+
46
61
  /**
47
- * 从 MD 内容中提取指定 section 的内容(到下一个同级或更高级 heading 为止)。
48
- * 如果 section null,返回整个文件。
49
- * 支持显式锚点 {#slug} 和自动 slugify 两种方式匹配。
62
+ * 从哲学文档内容中提取"灵感来源"章节的条目。
63
+ * @param {string} content — MD 全文
64
+ * @returns {Array<{ raw: string, name: string, urls: string[], hasReason: boolean }>}
50
65
  */
51
- function extractSection(content, sectionSlug) {
52
- if (!sectionSlug) return content;
66
+ function parseInspirationSources(content) {
67
+ // 匹配 "## 灵感来源" 或 "## Inspiration" 章节
68
+ const sectionMatch = content.match(/^##\s+(?:灵感来源|Inspiration|参考来源|References)/m);
69
+ if (!sectionMatch) return [];
53
70
 
54
- const lines = content.split('\n');
55
- let capturing = false;
56
- let targetLevel = 0;
57
- const captured = [];
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;
58
82
 
59
83
  for (const line of lines) {
60
- // strip \r 以兼容 Windows CRLF
61
- const cleanLine = line.replace(/\r$/, '');
62
- const headingMatch = cleanLine.match(/^(#{1,6})\s+(.+)$/);
63
- if (headingMatch) {
64
- const level = headingMatch[1].length;
65
- const headingText = headingMatch[2];
66
- // 优先用显式锚点,没有再 fallback slugify
67
- const slug = extractExplicitAnchor(headingText) || slugify(headingText);
68
-
69
- if (capturing && level <= targetLevel) {
70
- break;
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;
71
99
  }
72
- if (slug === sectionSlug) {
73
- capturing = true;
74
- targetLevel = level;
75
- captured.push(cleanLine);
76
- continue;
77
- }
78
- }
79
- if (capturing) {
80
- captured.push(cleanLine);
81
100
  }
82
101
  }
102
+ if (currentItem) items.push(currentItem);
103
+ return items;
104
+ }
83
105
 
84
- if (captured.length === 0) {
85
- throw new Error(`章节未找到: #${sectionSlug}`);
86
- }
87
- return captured.join('\n').trim();
106
+ /**
107
+ * 判断 URL 是否为 Wikipedia 链接。
108
+ */
109
+ function isWikipediaUrl(url) {
110
+ return /wikipedia\.org/i.test(url);
88
111
  }
89
112
 
90
113
  /**
91
- * 按锚点加载哲学文档内容。
114
+ * 校验哲学文档的灵感来源质量。
92
115
  * @param {string} philosophyDir — 00_PHILOSOPHY/ 目录路径
93
- * @param {string} anchor "PRODUCT_PHILOSOPHY.md#core-belief"
94
- * @returns {string} MD 文本(章节或整个文件)
116
+ * @returns {{ passed: boolean, issues: Array<{severity: string, msg: string}>, sources: Array }}
95
117
  */
96
- export function getPhilosophy(philosophyDir, anchor) {
97
- const { file, section } = parseAnchor(anchor);
98
- const filePath = join(philosophyDir, file);
118
+ export function validateInspirationSources(philosophyDir) {
119
+ const issues = [];
120
+ const allSources = [];
99
121
 
100
- if (!existsSync(filePath)) {
101
- throw new Error(`哲学文档不存在: ${filePath}`);
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
+ }
102
162
  }
103
163
 
104
- const content = readFileSync(filePath, 'utf-8');
105
- return extractSection(content, section);
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
+ };
106
189
  }
107
190
 
191
+ // ─── 实现部分清单校验 ───────────────────────────────────
192
+ // 检查哲学文档是否包含"实现部分清单"——Weaver 是否走了拆解流程。
193
+ // PHILOSOPHY_WEAVER.md Step 2 要求产出"实现部分清单"。
194
+
108
195
  /**
109
- * 列出哲学目录下所有 .md 文件名。
196
+ * 校验哲学文档是否包含实现部分拆解清单。
197
+ * Weaver 按 PART_DECOMPOSITION.md 拆解后,必须在哲学文档里显式列出拆解出的部分。
198
+ * @param {string} philosophyDir — 00_PHILOSOPHY/ 目录路径
199
+ * @returns {{ passed: boolean, issues: Array<{severity: string, msg: string}>, parts: string[] }}
110
200
  */
111
- export function listPhilosophyFiles(philosophyDir) {
112
- if (!existsSync(philosophyDir)) {
113
- throw new Error(`哲学目录不存在: ${philosophyDir}`);
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
+ }
114
255
  }
115
- const dir = readdirSync(philosophyDir);
116
- return dir.filter((f) => f.endsWith('.md'));
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
+ };
117
274
  }
@@ -160,6 +160,7 @@ SVG 折线,横轴时间,纵轴剩余数。
160
160
  - **不可妥协的价值**:清单,每条一句话 + 一句话理由
161
161
  - **反模式**:红色警告卡,每张一句
162
162
  - **决策原则**:对比表或清单,遇到冲突时怎么取舍
163
+ - **灵感来源**:哲学文档里的"灵感来源"章节——展示参考的艺术家、机构、流派、著作。这是 LOOM 哲学织造的核心机制:从真实思想体系萃取核心思想。每个来源用卡片或标签展示:名字(如 Dieter Rams / Stripe / Unix Philosophy / Anthropic)+ 一句话说明"从这个体系萃取了什么原则" + 来源引用(著作/URL)。这些名字是"思想压缩包的索引"——人类看到名字就能激活对这套哲学的理解。不要把灵感来源藏在正文里,要作为独立视觉块展示。
163
164
 
164
165
  ### 愿景区域
165
166
 
@@ -1,15 +1,72 @@
1
- // preview — 输出提示词,让 AI 读 .loom/ 文件并生成 HTML
2
- // CLI 不收集数据、不生成 HTML。AI 自己读文件、重组信息、生成 HTML。
3
- // 因为都是同一个 Agent 负责的,它就在项目目录里,能直接读文件。
4
-
5
- import { readFileSync } from 'node:fs';
6
-
7
- const PROMPT_PATH = new URL('./preview-prompt.md', import.meta.url);
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
+ }