@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.
- package/README.md +31 -19
- package/cli/bin/loom.js +182 -85
- package/cli/help/concepts.md +72 -0
- package/cli/help/doctor.md +121 -0
- package/cli/help/loop.md +135 -0
- package/cli/help/preview.md +59 -0
- package/cli/help/version.md +60 -0
- package/cli/help/workflow.md +100 -0
- package/cli/src/activate.js +21 -12
- package/cli/src/auto.js +54 -5
- package/cli/src/diagnostics.js +229 -48
- package/cli/src/guide.js +83 -12
- package/cli/src/help.js +29 -398
- package/cli/src/init.js +42 -8
- package/cli/src/intent-map.js +42 -85
- package/cli/src/philosophy.js +226 -69
- package/cli/src/preview-prompt.md +1 -0
- package/cli/src/preview.js +67 -10
- package/cli/src/shared/md-utils.js +125 -0
- package/cli/src/shared/paths.js +73 -0
- package/cli/src/verify.js +70 -74
- package/cli/src/version.js +1 -1
- 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/INTENT_LOOP.md +159 -18
- package/meta/PHILOSOPHY_WEAVER.md +104 -50
- package/package.json +5 -4
- package/roles/architect.md +12 -0
- package/roles/keeper.md +28 -1
- package/templates/INTENT_MAP_TEMPLATE.json +4 -1
- package/templates/VISION_TEMPLATE.md +4 -2
package/cli/src/intent-map.js
CHANGED
|
@@ -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}
|
|
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(
|
|
74
|
-
const filePath = join(
|
|
75
|
-
|
|
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(
|
|
152
|
-
const { intents, topo_order } = loadIntentMap(
|
|
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(
|
|
168
|
-
const { intents } = loadIntentMap(
|
|
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(
|
|
190
|
-
const { intents, topo_order } = loadIntentMap(
|
|
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(
|
|
213
|
-
const { intents } = loadIntentMap(
|
|
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}
|
|
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(
|
|
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(
|
|
234
|
-
const data =
|
|
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(
|
|
267
|
-
const { intents } = loadIntentMap(
|
|
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(
|
|
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
|
}
|
package/cli/src/philosophy.js
CHANGED
|
@@ -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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* @
|
|
21
|
+
* 按锚点加载哲学文档内容。
|
|
22
|
+
* @param {string} philosophyDir — 00_PHILOSOPHY/ 目录路径
|
|
23
|
+
* @param {string} anchor — "PRODUCT_PHILOSOPHY.md#core-belief"
|
|
24
|
+
* @returns {string} MD 文本(章节或整个文件)
|
|
22
25
|
*/
|
|
23
|
-
function
|
|
24
|
-
const
|
|
25
|
-
|
|
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
|
-
*
|
|
30
|
-
* 规则:小写、去除 \r、空格转连字符、去除非 [a-z0-9_-] 字符。
|
|
31
|
-
* 注意:\w 不匹配中文,所以中文标题的 slug 会是空的——
|
|
32
|
-
* 这就是为什么哲学文档应该用显式锚点 {#anchor} 标注中文标题。
|
|
39
|
+
* 列出哲学目录下所有 .md 文件名。
|
|
33
40
|
*/
|
|
34
|
-
function
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
62
|
+
* 从哲学文档内容中提取"灵感来源"章节的条目。
|
|
63
|
+
* @param {string} content — MD 全文
|
|
64
|
+
* @returns {Array<{ raw: string, name: string, urls: string[], hasReason: boolean }>}
|
|
50
65
|
*/
|
|
51
|
-
function
|
|
52
|
-
|
|
66
|
+
function parseInspirationSources(content) {
|
|
67
|
+
// 匹配 "## 灵感来源" 或 "## Inspiration" 章节
|
|
68
|
+
const sectionMatch = content.match(/^##\s+(?:灵感来源|Inspiration|参考来源|References)/m);
|
|
69
|
+
if (!sectionMatch) return [];
|
|
53
70
|
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
* @
|
|
94
|
-
* @returns {string} MD 文本(章节或整个文件)
|
|
116
|
+
* @returns {{ passed: boolean, issues: Array<{severity: string, msg: string}>, sources: Array }}
|
|
95
117
|
*/
|
|
96
|
-
export function
|
|
97
|
-
const
|
|
98
|
-
const
|
|
118
|
+
export function validateInspirationSources(philosophyDir) {
|
|
119
|
+
const issues = [];
|
|
120
|
+
const allSources = [];
|
|
99
121
|
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
*
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
|
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
|
+
}
|