@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.
@@ -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
  }
@@ -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 extractSection(content, section);
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
+ }