@haaaiawd/loom 0.1.0 → 0.7.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/cli/bin/loom.js +106 -71
- package/cli/help/concepts.md +72 -0
- package/cli/help/doctor.md +70 -0
- package/cli/help/loop.md +135 -0
- package/cli/help/version.md +60 -0
- package/cli/help/workflow.md +94 -0
- package/cli/src/activate.js +21 -12
- package/cli/src/auto.js +54 -5
- package/cli/src/diagnostics.js +63 -23
- package/cli/src/guide.js +74 -5
- 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 +2 -73
- package/cli/src/preview-prompt.md +1 -0
- package/cli/src/shared/md-utils.js +125 -0
- package/cli/src/shared/paths.js +73 -0
- package/cli/src/verify.js +62 -70
- package/cli/src/version.js +1 -1
- package/meta/INTENT_LOOP.md +159 -18
- package/package.json +2 -1
- 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
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
+
import { extractMdSection } from './shared/md-utils.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* 解析锚点字符串。
|
|
@@ -15,78 +16,6 @@ export function parseAnchor(anchor) {
|
|
|
15
16
|
return { file: file.trim(), section: section ? section.trim() : null };
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
-
* 从 heading 文本中提取显式锚点。
|
|
20
|
-
* 支持 Pandoc/MDX 风格语法: "## 核心信念 {#core-belief}"
|
|
21
|
-
* @returns {string|null} 显式锚点 slug,或 null(无显式锚点时)
|
|
22
|
-
*/
|
|
23
|
-
function extractExplicitAnchor(headingText) {
|
|
24
|
-
const match = headingText.match(/\{#([\w-]+)\}\s*$/);
|
|
25
|
-
return match ? match[1] : null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* 从 heading 文本生成 slug(fallback,无显式锚点时用)。
|
|
30
|
-
* 规则:小写、去除 \r、空格转连字符、去除非 [a-z0-9_-] 字符。
|
|
31
|
-
* 注意:\w 不匹配中文,所以中文标题的 slug 会是空的——
|
|
32
|
-
* 这就是为什么哲学文档应该用显式锚点 {#anchor} 标注中文标题。
|
|
33
|
-
*/
|
|
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();
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* 从 MD 内容中提取指定 section 的内容(到下一个同级或更高级 heading 为止)。
|
|
48
|
-
* 如果 section 为 null,返回整个文件。
|
|
49
|
-
* 支持显式锚点 {#slug} 和自动 slugify 两种方式匹配。
|
|
50
|
-
*/
|
|
51
|
-
function extractSection(content, sectionSlug) {
|
|
52
|
-
if (!sectionSlug) return content;
|
|
53
|
-
|
|
54
|
-
const lines = content.split('\n');
|
|
55
|
-
let capturing = false;
|
|
56
|
-
let targetLevel = 0;
|
|
57
|
-
const captured = [];
|
|
58
|
-
|
|
59
|
-
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;
|
|
71
|
-
}
|
|
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
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (captured.length === 0) {
|
|
85
|
-
throw new Error(`章节未找到: #${sectionSlug}`);
|
|
86
|
-
}
|
|
87
|
-
return captured.join('\n').trim();
|
|
88
|
-
}
|
|
89
|
-
|
|
90
19
|
/**
|
|
91
20
|
* 按锚点加载哲学文档内容。
|
|
92
21
|
* @param {string} philosophyDir — 00_PHILOSOPHY/ 目录路径
|
|
@@ -102,7 +31,7 @@ export function getPhilosophy(philosophyDir, anchor) {
|
|
|
102
31
|
}
|
|
103
32
|
|
|
104
33
|
const content = readFileSync(filePath, 'utf-8');
|
|
105
|
-
return
|
|
34
|
+
return extractMdSection(content, section, '哲学');
|
|
106
35
|
}
|
|
107
36
|
|
|
108
37
|
/**
|
|
@@ -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
|
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// shared/md-utils.js — MD 章节解析的公共工具
|
|
2
|
+
// 提取自 philosophy.js / intent-map.js / verify.js 三处重复实现。
|
|
3
|
+
// 统一 slugify + extractMdSection + 显式锚点逻辑,修中文标题 bug。
|
|
4
|
+
|
|
5
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 从 heading 文本中提取显式锚点。
|
|
9
|
+
* 支持 Pandoc/MDX 风格语法: "## 核心信念 {#core-belief}"
|
|
10
|
+
* @param {string} headingText — heading 文本(不含 # 前缀)
|
|
11
|
+
* @returns {string|null} 显式锚点 slug,或 null(无显式锚点时)
|
|
12
|
+
*/
|
|
13
|
+
export function extractExplicitAnchor(headingText) {
|
|
14
|
+
const match = headingText.match(/\{#([\w-]+)\}\s*$/);
|
|
15
|
+
return match ? match[1] : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 从 heading 文本生成 slug(fallback,无显式锚点时用)。
|
|
20
|
+
* 规则:小写、去除 \r、空格转连字符、去除非 [a-z0-9_-] 字符。
|
|
21
|
+
*
|
|
22
|
+
* 中文标题处理:\w 不匹配中文,所以纯中文标题 slugify 后是空字符串。
|
|
23
|
+
* 这是设计约束——中文标题必须用显式锚点 {#anchor} 标注。
|
|
24
|
+
* slugify 返回空字符串时,调用方应给出明确错误提示。
|
|
25
|
+
*
|
|
26
|
+
* @param {string} text — heading 文本
|
|
27
|
+
* @returns {string} slug(可能为空字符串——纯中文标题无显式锚点时)
|
|
28
|
+
*/
|
|
29
|
+
export function slugify(text) {
|
|
30
|
+
return text
|
|
31
|
+
.replace(/\r/g, '') // strip CRLF 的 \r
|
|
32
|
+
.replace(/\{#[\w-]+\}\s*$/, '') // 去掉显式锚点标记
|
|
33
|
+
.toLowerCase()
|
|
34
|
+
.replace(/[^\w\s-]/g, '') // \w = [a-zA-Z0-9_]
|
|
35
|
+
.replace(/\s+/g, '-')
|
|
36
|
+
.replace(/-+/g, '-')
|
|
37
|
+
.replace(/^-|-$/g, '')
|
|
38
|
+
.trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 从 MD 内容中提取指定 section 的内容(到下一个同级或更高级 heading 为止)。
|
|
43
|
+
* 如果 sectionSlug 为 null/空,返回整个文件。
|
|
44
|
+
* 支持显式锚点 {#slug} 和自动 slugify 两种方式匹配。
|
|
45
|
+
*
|
|
46
|
+
* @param {string} content — MD 文件内容
|
|
47
|
+
* @param {string} sectionSlug — 目标 section 的 slug
|
|
48
|
+
* @param {string} contextLabel — 错误信息里的上下文标签(如 "意图叙事"、"验证契约")
|
|
49
|
+
* @returns {string} 提取的章节内容
|
|
50
|
+
* @throws {Error} 章节未找到时抛错,错误信息包含 contextLabel 和 slug
|
|
51
|
+
*/
|
|
52
|
+
export function extractMdSection(content, sectionSlug, contextLabel = '章节') {
|
|
53
|
+
if (!sectionSlug) return content;
|
|
54
|
+
|
|
55
|
+
const lines = content.split('\n');
|
|
56
|
+
let capturing = false;
|
|
57
|
+
let targetLevel = 0;
|
|
58
|
+
const captured = [];
|
|
59
|
+
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
// strip \r 以兼容 Windows CRLF
|
|
62
|
+
const cleanLine = line.replace(/\r$/, '');
|
|
63
|
+
const headingMatch = cleanLine.match(/^(#{1,6})\s+(.+)$/);
|
|
64
|
+
if (headingMatch) {
|
|
65
|
+
const level = headingMatch[1].length;
|
|
66
|
+
const headingText = headingMatch[2];
|
|
67
|
+
// 优先用显式锚点,没有再 fallback 到 slugify
|
|
68
|
+
const slug = extractExplicitAnchor(headingText) || slugify(headingText);
|
|
69
|
+
|
|
70
|
+
if (capturing && level <= targetLevel) {
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
if (slug === sectionSlug) {
|
|
74
|
+
capturing = true;
|
|
75
|
+
targetLevel = level;
|
|
76
|
+
captured.push(cleanLine);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (capturing) {
|
|
81
|
+
captured.push(cleanLine);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (captured.length === 0) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`${contextLabel}章节未找到: #${sectionSlug}\n` +
|
|
88
|
+
`可能原因:\n` +
|
|
89
|
+
` 1. 章节标题用了中文但没加显式锚点 {#anchor}\n` +
|
|
90
|
+
` 2. 锚点 slug 拼写错误\n` +
|
|
91
|
+
` 3. 引用的文件不存在该章节`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return captured.join('\n').trim();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 安全读取并解析 JSON 文件。
|
|
99
|
+
* 统一错误处理——文件不存在、JSON 解析失败时给出明确错误信息。
|
|
100
|
+
*
|
|
101
|
+
* @param {string} filePath — JSON 文件绝对路径
|
|
102
|
+
* @param {string} contextLabel — 错误信息里的上下文标签(如 "Intent Map"、"验证记录")
|
|
103
|
+
* @returns {object} 解析后的 JSON 对象
|
|
104
|
+
* @throws {Error} 文件不存在或 JSON 解析失败时抛错,错误信息包含文件路径和原因
|
|
105
|
+
*/
|
|
106
|
+
export function readJsonFile(filePath, contextLabel = 'JSON 文件') {
|
|
107
|
+
if (!existsSync(filePath)) {
|
|
108
|
+
throw new Error(`${contextLabel}文件不存在: ${filePath}`);
|
|
109
|
+
}
|
|
110
|
+
let raw;
|
|
111
|
+
try {
|
|
112
|
+
raw = readFileSync(filePath, 'utf-8');
|
|
113
|
+
} catch (e) {
|
|
114
|
+
throw new Error(`${contextLabel}文件读取失败: ${filePath}\n原因: ${e.message}`);
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
return JSON.parse(raw);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`${contextLabel}文件 JSON 解析失败: ${filePath}\n` +
|
|
121
|
+
`原因: ${e.message}\n` +
|
|
122
|
+
`请检查文件内容是否为合法 JSON(多余逗号、缺少引号等)。`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// shared/paths.js — LOOM 路径解析的公共工具
|
|
2
|
+
// 统一 getLoomRoot / findLoomRoot / findVersionDir 的命名和实现。
|
|
3
|
+
|
|
4
|
+
import { resolve, join, dirname } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
7
|
+
import { argv, cwd } from 'node:process';
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 获取 LOOM 框架根目录(cli/src/shared 的上上上级)。
|
|
13
|
+
* cli/src/shared/paths.js -> cli/src/shared -> cli/src -> cli -> LOOM root
|
|
14
|
+
* @returns {string} LOOM 框架根目录绝对路径
|
|
15
|
+
*/
|
|
16
|
+
export function getLoomRoot() {
|
|
17
|
+
return resolve(__dirname, '..', '..', '..');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 从命令行参数或 cwd 推断 .loom 目录路径。
|
|
22
|
+
* 优先级:--loom-dir 参数(指向版本目录,反推 .loom root)> cwd/.loom
|
|
23
|
+
* @returns {string} .loom 目录绝对路径
|
|
24
|
+
*/
|
|
25
|
+
export function findLoomRoot() {
|
|
26
|
+
const flagIdx = argv.indexOf('--loom-dir');
|
|
27
|
+
if (flagIdx !== -1 && argv[flagIdx + 1]) {
|
|
28
|
+
// --loom-dir 直接指向版本目录,反推 .loom root
|
|
29
|
+
const dir = resolve(argv[flagIdx + 1]);
|
|
30
|
+
return resolve(dir, '..');
|
|
31
|
+
}
|
|
32
|
+
return join(cwd(), '.loom');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 从命令行参数或 .loom/current 指针推断当前版本目录。
|
|
37
|
+
* @returns {string} .loom/v{N} 目录绝对路径
|
|
38
|
+
* @throws {Error} .loom 不存在或没有版本目录时抛错
|
|
39
|
+
*/
|
|
40
|
+
export function findVersionDir() {
|
|
41
|
+
const flagIdx = argv.indexOf('--loom-dir');
|
|
42
|
+
if (flagIdx !== -1 && argv[flagIdx + 1]) {
|
|
43
|
+
return resolve(argv[flagIdx + 1]);
|
|
44
|
+
}
|
|
45
|
+
const loomRoot = join(cwd(), '.loom');
|
|
46
|
+
if (!existsSync(loomRoot)) {
|
|
47
|
+
throw new Error(`找不到 .loom 目录: ${loomRoot}`);
|
|
48
|
+
}
|
|
49
|
+
const current = readCurrentPointer(loomRoot);
|
|
50
|
+
if (!current) {
|
|
51
|
+
throw new Error(`.loom 下没有版本目录 (v1, v2, ...)`);
|
|
52
|
+
}
|
|
53
|
+
return join(loomRoot, current);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 读取当前版本指针。
|
|
58
|
+
* 优先读 .loom/current 文件;不存在则回退到自动探测最新版本。
|
|
59
|
+
* @param {string} loomRoot — .loom 目录路径
|
|
60
|
+
* @returns {string|null} 版本号如 'v1',或 null
|
|
61
|
+
*/
|
|
62
|
+
export function readCurrentPointer(loomRoot) {
|
|
63
|
+
const pointerPath = join(loomRoot, 'current');
|
|
64
|
+
if (existsSync(pointerPath)) {
|
|
65
|
+
const v = readFileSync(pointerPath, 'utf-8').trim();
|
|
66
|
+
if (/^v\d+$/.test(v) && existsSync(join(loomRoot, v))) return v;
|
|
67
|
+
}
|
|
68
|
+
if (!existsSync(loomRoot)) return null;
|
|
69
|
+
const versions = readdirSync(loomRoot)
|
|
70
|
+
.filter((d) => /^v\d+$/.test(d) && statSync(join(loomRoot, d)).isDirectory())
|
|
71
|
+
.sort((a, b) => parseInt(b.slice(1)) - parseInt(a.slice(1)));
|
|
72
|
+
return versions[0] ?? null;
|
|
73
|
+
}
|