@haaaiawd/loom 0.1.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 +371 -0
- package/cli/bin/loom.js +465 -0
- package/cli/src/activate.js +64 -0
- package/cli/src/auto.js +44 -0
- package/cli/src/diagnostics.js +277 -0
- package/cli/src/guide.js +201 -0
- package/cli/src/help.js +410 -0
- package/cli/src/init.js +125 -0
- package/cli/src/intent-map.js +284 -0
- package/cli/src/philosophy.js +117 -0
- package/cli/src/preview-prompt.md +329 -0
- package/cli/src/preview.js +15 -0
- package/cli/src/verify.js +199 -0
- package/cli/src/version.js +133 -0
- package/dimensions/SEARCH_METHODOLOGY.md +97 -0
- package/meta/BASELINE.md +276 -0
- package/meta/INTENT_LOOP.md +596 -0
- package/meta/PHILOSOPHY_WEAVER.md +289 -0
- package/meta/ROLE_ACTIVATION.md +267 -0
- package/package.json +41 -0
- package/roles/architect.md +99 -0
- package/roles/forge.md +126 -0
- package/roles/keeper.md +196 -0
- package/roles/visionary.md +86 -0
- package/templates/INTENT_MAP_TEMPLATE.json +65 -0
- package/templates/PHILOSOPHY_TEMPLATE.md +75 -0
- package/templates/VISION_TEMPLATE.md +65 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# LOOM Preview 生成提示词
|
|
2
|
+
|
|
3
|
+
你的任务:读 `.loom/` 目录下当前版本的所有文件,把信息**拆解成关键要点**,用最合适的视觉和交互手段呈现成一份 HTML 页面,写到 `loom-preview.html`。
|
|
4
|
+
|
|
5
|
+
## 核心原则:信息密度靠视觉手段,不靠文字堆砌
|
|
6
|
+
|
|
7
|
+
类比 PPT——PPT 不是把报告文字排成幻灯片,是用图表、图示、对比、交互把信息密度压缩到人类一眼能消化的程度。能用图就不表,能交互就不静态,能一句话就不写一段。
|
|
8
|
+
|
|
9
|
+
### 错误 vs 正确
|
|
10
|
+
|
|
11
|
+
| 错误(文字堆砌) | 正确(视觉手段) |
|
|
12
|
+
|---|---|
|
|
13
|
+
| 哲学全文渲染成 HTML | 北极星一句话 + 信念用图标网格 + 反模式用红色警告卡 |
|
|
14
|
+
| Intent 列表表格 | SVG 依赖图,节点颜色=状态,一眼看出阻塞链 |
|
|
15
|
+
| 验证记录 JSON 列表 | 时间轴,通过绿色偏离红色,点击节点看证据 |
|
|
16
|
+
| 进度用文字描述 | 环形进度图 + 大数字仪表盘 |
|
|
17
|
+
| 状态分布用表格 | 堆叠条形图,一段横条按状态颜色分段 |
|
|
18
|
+
| 决策原则用列表 | 对比表,两列并排突出差异 |
|
|
19
|
+
| 意图叙事全文展示 | 卡片网格,每张卡片一句话,点击展开详情 |
|
|
20
|
+
|
|
21
|
+
### 拆解粒度
|
|
22
|
+
|
|
23
|
+
每个信息块回答一个问题,人类 3 秒内能消化:
|
|
24
|
+
- 产品为什么存在?→ 北极星一句话
|
|
25
|
+
- 产品信什么?→ 3-5 个图标 + 关键句
|
|
26
|
+
- 什么绝对不做?→ 红色警告卡,每张一句
|
|
27
|
+
- 项目推进到哪了?→ 环形进度图 + 数字仪表盘
|
|
28
|
+
- 谁阻塞谁?→ SVG 依赖图
|
|
29
|
+
- 这个 Intent 为什么存在?→ 一句话叙事
|
|
30
|
+
- 验证历史怎么样?→ 时间轴
|
|
31
|
+
|
|
32
|
+
如果一个块需要读超过 3 行文字才能理解,它太长了——换一种视觉形式。
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 去读哪些文件
|
|
37
|
+
|
|
38
|
+
当前版本目录在 `.loom/current` 指针里(通常是 `.loom/v1/`)。读:
|
|
39
|
+
|
|
40
|
+
| 文件 | 拆解出什么 |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `00_PHILOSOPHY/*.md` | 北极星、核心信念、反模式、决策原则 |
|
|
43
|
+
| `01_VISION.md` | 北极星、问题空间、意图叙事、不做什么 |
|
|
44
|
+
| `02_ARCHITECTURE.md` | 关键决策、trade-offs |
|
|
45
|
+
| `04_INTENT_MAP.json` | 依赖关系、状态分布、验收契约 |
|
|
46
|
+
| `verifications/*.json` | 验证时间轴、通过/偏离、证据 |
|
|
47
|
+
|
|
48
|
+
文件还是模板(含 `<!-- LOOM_TEMPLATE -->` 标记)时,对应区域显示提示:"等待 {角色} 填充"。
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 一、视觉手段库——什么信息用什么呈现
|
|
53
|
+
|
|
54
|
+
不同信息适合不同视觉手段。不要什么都用卡片或列表——选最能压缩信息密度的方式。
|
|
55
|
+
|
|
56
|
+
### 图表类(数据可视化)
|
|
57
|
+
|
|
58
|
+
**环形进度图** — 适合:整体完成度
|
|
59
|
+
一个圆环,填充比例 = completed/total。中心显示百分比。比横条进度条更紧凑、更视觉化。纯 SVG 实现。
|
|
60
|
+
|
|
61
|
+
**堆叠条形图** — 适合:状态分布
|
|
62
|
+
一段横条,按状态颜色分段(完成绿/进行中黄/待执行灰/阻塞红)。一段条形展示所有状态比例,比 4 个数字卡片更紧凑。
|
|
63
|
+
|
|
64
|
+
**柱状图** — 适合:各 Intent 的验证轮数、各系统的 Intent 数量
|
|
65
|
+
SVG 矩形,高度=数值。适合比较多个项的数量。
|
|
66
|
+
|
|
67
|
+
**燃尽图风格折线** — 适合:剩余 Intent 随时间变化(如果有历史数据)
|
|
68
|
+
SVG 折线,横轴时间,纵轴剩余数。
|
|
69
|
+
|
|
70
|
+
### 图示类(关系和结构)
|
|
71
|
+
|
|
72
|
+
**SVG 依赖图** — 适合:Intent 之间的依赖关系
|
|
73
|
+
节点是 Intent,边是 depends_on。拓扑排序布局,无依赖的在左,依赖链向右。节点颜色按状态编码。节点可点击,点击后侧边面板显示详情。
|
|
74
|
+
|
|
75
|
+
**热力图网格** — 适合:Intent × 验证轮次的通过/偏离矩阵
|
|
76
|
+
行是 Intent,列是验证轮次,格子颜色=通过(绿)/偏离(红)/未验证(灰)。一眼看出哪个 Intent 反复偏离。
|
|
77
|
+
|
|
78
|
+
**树状图** — 适合:架构的系统层级、Intent 的依赖树
|
|
79
|
+
展开/折叠的树形结构,点击节点展开子项。
|
|
80
|
+
|
|
81
|
+
### 交互类(探索和筛选)
|
|
82
|
+
|
|
83
|
+
**过滤面板** — 适合:Intent 列表、验证记录
|
|
84
|
+
复选框按状态过滤(全部/待执行/进行中/完成/阻塞),输入框模糊搜索。过滤后实时更新视图。
|
|
85
|
+
|
|
86
|
+
**点击下钻** — 适合:从概览到详情
|
|
87
|
+
概览页点击某个 Intent → 展开详情面板或跳到详情区。不在一页摊开所有详情,按需展开。
|
|
88
|
+
|
|
89
|
+
**Hover 工具提示** — 适合:图表上的额外信息
|
|
90
|
+
鼠标悬停在依赖图节点上 → 显示 tooltip(ID + 状态 + 一句话叙事)。不点击就能预览。
|
|
91
|
+
|
|
92
|
+
**Tab 切换** — 适合:大板块切换
|
|
93
|
+
概览 / Intent Map / 哲学 / 愿景 / 架构。所有内容先渲染堆叠,JS 切换显示。
|
|
94
|
+
|
|
95
|
+
**可折叠面板** — 适合:分层信息
|
|
96
|
+
默认折叠显示标题,点击展开内容。用 `max-height` 过渡,不要 `display: none`。
|
|
97
|
+
|
|
98
|
+
### 布局类(组织和对比)
|
|
99
|
+
|
|
100
|
+
**仪表盘** — 适合:关键指标概览
|
|
101
|
+
大数字 + 颜色编码。数字 24-32px,标签 12-13px。一眼看到状态。
|
|
102
|
+
|
|
103
|
+
**对比表** — 适合:trade-offs、做什么 vs 不做什么
|
|
104
|
+
两列或三列并排,突出差异。每列一个方向,行是对比维度。
|
|
105
|
+
|
|
106
|
+
**卡片网格** — 适合:核心信念、意图叙事
|
|
107
|
+
每张卡片一个要点,标题 + 一句话。网格排列,大小一致。卡片内不超过 3 行。
|
|
108
|
+
|
|
109
|
+
**时间轴** — 适合:验证历史、项目演进
|
|
110
|
+
横向或纵向,每个节点一个事件。通过绿色偏离红色,附证据摘要。
|
|
111
|
+
|
|
112
|
+
**看板列** — 适合:按状态分组的 Intent
|
|
113
|
+
四列:待执行 / 进行中 / 完成 / 阻塞。每列下面是该状态的 Intent 卡片。一眼看出分布。
|
|
114
|
+
|
|
115
|
+
**清单** — 适合:反模式、不做什么、决策原则
|
|
116
|
+
每条一行 + 一句话理由。反模式用红色警告样式。
|
|
117
|
+
|
|
118
|
+
### 标记类(状态和强调)
|
|
119
|
+
|
|
120
|
+
**状态徽章** — 适合:Intent 状态
|
|
121
|
+
颜色 + 图标 + 文字(不只靠颜色,色盲友好):
|
|
122
|
+
- 完成 = 绿色 + ✓
|
|
123
|
+
- 进行中 = 黄色 + ◐
|
|
124
|
+
- 待执行 = 灰色 + ○
|
|
125
|
+
- 阻塞 = 红色 + ✕
|
|
126
|
+
- 需复审 = 蓝色 + ?
|
|
127
|
+
|
|
128
|
+
**高亮引用** — 适合:北极星、关键宣言
|
|
129
|
+
大字号 + 左侧色条 + 引用样式。视觉上从周围内容跳出来。
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## 二、页面结构
|
|
134
|
+
|
|
135
|
+
### 顶部:一屏概览
|
|
136
|
+
|
|
137
|
+
人类打开第一眼看到全局:
|
|
138
|
+
|
|
139
|
+
- 项目名 + 版本号
|
|
140
|
+
- 北极星一句话(高亮引用样式,最醒目)
|
|
141
|
+
- **环形进度图**:完成 X/Y(百分比)
|
|
142
|
+
- **堆叠条形图**:状态分布(一段条形展示所有状态比例)
|
|
143
|
+
- **数字仪表盘**:进行中 N、阻塞 M(大数字 + 颜色)
|
|
144
|
+
|
|
145
|
+
### Intent Map 区域
|
|
146
|
+
|
|
147
|
+
项目推进到哪了——用多种手段呈现不同维度:
|
|
148
|
+
|
|
149
|
+
- **SVG 依赖图**:依赖链和阻塞点
|
|
150
|
+
- **看板列**:按状态分组的 Intent 卡片(待执行/进行中/完成/阻塞)
|
|
151
|
+
- **过滤面板**:状态过滤 + 模糊搜索(Intent 多时)
|
|
152
|
+
- **点击下钻**:点击节点或卡片 → 侧边详情面板(叙事、验收契约、哲学锚点、验证历史)
|
|
153
|
+
|
|
154
|
+
### 哲学区域
|
|
155
|
+
|
|
156
|
+
"我们信什么"——拆解成视觉块:
|
|
157
|
+
|
|
158
|
+
- **北极星**:高亮引用,一句话
|
|
159
|
+
- **核心信念**:卡片网格,每张一个图标 + 关键句
|
|
160
|
+
- **不可妥协的价值**:清单,每条一句话 + 一句话理由
|
|
161
|
+
- **反模式**:红色警告卡,每张一句
|
|
162
|
+
- **决策原则**:对比表或清单,遇到冲突时怎么取舍
|
|
163
|
+
|
|
164
|
+
### 愿景区域
|
|
165
|
+
|
|
166
|
+
"我们要去哪"——拆解:
|
|
167
|
+
|
|
168
|
+
- **北极星**:和哲学呼应
|
|
169
|
+
- **问题空间**:一段话
|
|
170
|
+
- **意图叙事**:卡片网格,每个意图一张卡片,一句话"为什么存在",点击展开详情
|
|
171
|
+
- **不做什么**:清单,和"做什么"同样醒目
|
|
172
|
+
|
|
173
|
+
### 架构区域(如果有)
|
|
174
|
+
|
|
175
|
+
"怎么实现"——拆解:
|
|
176
|
+
|
|
177
|
+
- **关键决策对比表**:每个决策两列对比(选了什么 / 放弃了什么)
|
|
178
|
+
- **trade-offs 清单**:每条一句话
|
|
179
|
+
|
|
180
|
+
### 验证历史区域(如果有)
|
|
181
|
+
|
|
182
|
+
"质量怎么样"——拆解:
|
|
183
|
+
|
|
184
|
+
- **时间轴**:每次验证一个节点,通过/偏离用颜色区分,点击看证据
|
|
185
|
+
- **热力图网格**(Intent 多时):Intent × 验证轮次矩阵,一眼看出哪个反复偏离
|
|
186
|
+
- **偏离清单**:哪些 Intent 偏离了,证据是什么
|
|
187
|
+
|
|
188
|
+
### 底部
|
|
189
|
+
|
|
190
|
+
- 提醒:这是只读投影,修改请编辑源文件后运行 `loom preview` 重新生成
|
|
191
|
+
- 可选:一个"复制项目状态"按钮
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## 三、符合人类直觉
|
|
196
|
+
|
|
197
|
+
### 视觉权重
|
|
198
|
+
|
|
199
|
+
最重要的信息最大、最醒目。次要信息小、灰、退后:
|
|
200
|
+
- 北极星 → 全页最大字号
|
|
201
|
+
- 进度数字 → 大数字 + 颜色
|
|
202
|
+
- Intent ID → 中等字号,等宽字体
|
|
203
|
+
- 验收契约正文 → 小字,可展开才显示
|
|
204
|
+
- 辅助说明 → 最小字,灰色
|
|
205
|
+
|
|
206
|
+
不要所有文字一样大。人类靠大小判断重要性。
|
|
207
|
+
|
|
208
|
+
### 渐进披露
|
|
209
|
+
|
|
210
|
+
不要一次把所有信息摊开。先给摘要,想看详情再展开:
|
|
211
|
+
- Intent 卡片默认折叠,只显示 ID + 状态 + 一句话
|
|
212
|
+
- 点击展开后显示:意图叙事、验收契约、哲学锚点、验证历史
|
|
213
|
+
- 图表上 Hover 显示 tooltip 预览,点击才展开完整详情
|
|
214
|
+
|
|
215
|
+
### 信息分组
|
|
216
|
+
|
|
217
|
+
相关信息放一起,不相关的用空间分隔:
|
|
218
|
+
- 进度概览自成一个区块
|
|
219
|
+
- Intent 依赖图 + 详情面板自成一个区块
|
|
220
|
+
- 哲学、愿景、架构各自独立区块
|
|
221
|
+
- 区块之间用足够留白分隔(32-48px),不要挤
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## 四、视觉设计原则
|
|
226
|
+
|
|
227
|
+
参考 Anthropic 和 CopilotKit 的 Generative UI 设计系统:
|
|
228
|
+
|
|
229
|
+
### 平面克制
|
|
230
|
+
|
|
231
|
+
- **无渐变**:纯色背景
|
|
232
|
+
- **无阴影**:用边框区分层次
|
|
233
|
+
- **无模糊**:不要 filter: blur
|
|
234
|
+
- **无发光**:不要 text-shadow 或 glow
|
|
235
|
+
- **无装饰性 emoji**:用 CSS 形状或 SVG 路径做图标
|
|
236
|
+
|
|
237
|
+
### 字体克制
|
|
238
|
+
|
|
239
|
+
- 只用两种字重:400(正文)和 500(标题/强调)
|
|
240
|
+
- 不要 600、700——太重,破坏平面感
|
|
241
|
+
- 标题用句首大写,不要全大写
|
|
242
|
+
- 正文不要句中加粗,用 `code style` 标记实体名
|
|
243
|
+
- 最小字号 12px
|
|
244
|
+
|
|
245
|
+
### 边框和间距
|
|
246
|
+
|
|
247
|
+
- 边框 `1px solid`
|
|
248
|
+
- 卡片圆角 8-12px
|
|
249
|
+
- 卡片内边距 16-20px
|
|
250
|
+
- 卡片之间间距 16-24px
|
|
251
|
+
- 区块之间间距 32-48px——大间距区分大板块
|
|
252
|
+
|
|
253
|
+
### CSS 变量
|
|
254
|
+
|
|
255
|
+
```css
|
|
256
|
+
:root {
|
|
257
|
+
--bg: ...;
|
|
258
|
+
--surface: ...;
|
|
259
|
+
--border: ...;
|
|
260
|
+
--text: ...;
|
|
261
|
+
--text-muted: ...;
|
|
262
|
+
--accent: ...;
|
|
263
|
+
--success: ...;
|
|
264
|
+
--warning: ...;
|
|
265
|
+
--danger: ...;
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
不要在 HTML 里硬编码颜色。所有颜色引用变量。
|
|
270
|
+
|
|
271
|
+
### 配色自定
|
|
272
|
+
|
|
273
|
+
不要求暗色也不要求亮色。选你觉得让信息最易读的方案。关键是:
|
|
274
|
+
- 背景和文字对比度足够(WCAG AA 标准)
|
|
275
|
+
- 状态颜色区分明显
|
|
276
|
+
- 不要花哨——信息是主角,视觉是配角
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## 五、技术约束
|
|
281
|
+
|
|
282
|
+
- **单文件**:所有 HTML/CSS/JS 内联,零外部依赖
|
|
283
|
+
- **零构建**:双击即可在浏览器打开
|
|
284
|
+
- **内联 SVG**:图表和依赖图直接写 `<svg>`,不用 `<img>` 或外部库
|
|
285
|
+
- **语义化 HTML**:用 `<section>` `<nav>` `<article>` `<button>`
|
|
286
|
+
- **响应式**:手机宽度下布局不崩(`@media (max-width: 768px)`)
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## 六、质量检查清单
|
|
291
|
+
|
|
292
|
+
生成前逐项确认:
|
|
293
|
+
|
|
294
|
+
**信息密度**
|
|
295
|
+
- [ ] 每个信息块用最合适的视觉手段,不是什么都用卡片或列表
|
|
296
|
+
- [ ] 能用图表展示的数据不用表格
|
|
297
|
+
- [ ] 能用图示展示的关系不用文字描述
|
|
298
|
+
- [ ] 每个块 3 秒内能消化,不超过 3 行文字
|
|
299
|
+
|
|
300
|
+
**功能**
|
|
301
|
+
- [ ] 双击能在浏览器打开,不需要任何外部资源
|
|
302
|
+
- [ ] SVG 依赖图节点颜色按状态编码
|
|
303
|
+
- [ ] 点击 Intent 能展开详情
|
|
304
|
+
- [ ] Tab 切换正常
|
|
305
|
+
- [ ] 图表 Hover 有 tooltip(如果适用)
|
|
306
|
+
|
|
307
|
+
**视觉**
|
|
308
|
+
- [ ] 无渐变、无阴影、无模糊、无发光
|
|
309
|
+
- [ ] 字重只有 400 和 500
|
|
310
|
+
- [ ] 所有颜色用 CSS 变量
|
|
311
|
+
- [ ] 边框统一 1px
|
|
312
|
+
- [ ] 信息层次清晰(大字重要,小字次要)
|
|
313
|
+
|
|
314
|
+
**可访问性**
|
|
315
|
+
- [ ] `@media (prefers-reduced-motion: reduce)` 禁用动画
|
|
316
|
+
- [ ] 对比度足够
|
|
317
|
+
- [ ] 点击区域 ≥ 44px
|
|
318
|
+
- [ ] 不仅靠颜色传达信息(颜色 + 图标 + 文字)
|
|
319
|
+
|
|
320
|
+
**响应式**
|
|
321
|
+
- [ ] 手机宽度下布局不崩
|
|
322
|
+
- [ ] SVG 依赖图横向滚动
|
|
323
|
+
- [ ] Tab 导航横向滚动
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
## 输出
|
|
328
|
+
|
|
329
|
+
把 HTML 写到 `loom-preview.html`。完成后告诉用户文件路径。
|
|
@@ -0,0 +1,15 @@
|
|
|
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);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 输出 preview 提示词。
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
export function generatePreviewPrompt() {
|
|
14
|
+
return readFileSync(PROMPT_PATH, 'utf-8');
|
|
15
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// verify.js — 验证记录的读写和查询
|
|
2
|
+
// 验证记录存放在 .loom/v{N}/verifications/ 下,每个 Intent 一份 JSON + 一份 MD。
|
|
3
|
+
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
/** 合法判定结果 */
|
|
8
|
+
const VALID_VERDICTS = ['passed', 'deviated', 'blocked', 'pending_human'];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 写入一条验证记录(追加模式——同一 Intent 多次验证保留完整历史)。
|
|
12
|
+
* 文件格式: { intent_id, records: [{ round, verdict, timestamp, ... }] }
|
|
13
|
+
* @param {string} verificationsDir — verifications/ 目录路径
|
|
14
|
+
* @param {object} record — 验证记录
|
|
15
|
+
* @param {string} record.intent_id — 如 "INT-001"
|
|
16
|
+
* @param {string} record.verdict — passed | deviated | blocked
|
|
17
|
+
* @param {string} record.timestamp — ISO 8601
|
|
18
|
+
* @param {string} record.summary — 验证摘要
|
|
19
|
+
* @param {object} record.dimensions — 四个维度的验证结果
|
|
20
|
+
* @param {string} [record.deviation_detail] — 偏离说明(deviated 时)
|
|
21
|
+
* @param {boolean} [record.reset_suggested] — 是否建议重置上下文
|
|
22
|
+
* @returns {{ filePath: string, round: number, deviated_count: number, should_escalate: boolean }}
|
|
23
|
+
*/
|
|
24
|
+
export function writeVerification(verificationsDir, record) {
|
|
25
|
+
const errors = [];
|
|
26
|
+
if (!record.intent_id) errors.push('缺少 intent_id');
|
|
27
|
+
if (!record.verdict || !VALID_VERDICTS.includes(record.verdict)) {
|
|
28
|
+
errors.push(`verdict 非法: "${record.verdict}" (合法: ${VALID_VERDICTS.join('|')})`);
|
|
29
|
+
}
|
|
30
|
+
if (!record.timestamp) errors.push('缺少 timestamp');
|
|
31
|
+
if (!record.dimensions) errors.push('缺少 dimensions(四个维度结果)');
|
|
32
|
+
if (errors.length > 0) {
|
|
33
|
+
throw new Error(`验证记录校验失败:\n - ${errors.join('\n - ')}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const filePath = join(verificationsDir, `${record.intent_id}.json`);
|
|
37
|
+
|
|
38
|
+
// 读取已有记录(如果有)
|
|
39
|
+
let data;
|
|
40
|
+
if (existsSync(filePath)) {
|
|
41
|
+
data = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
42
|
+
} else {
|
|
43
|
+
data = { intent_id: record.intent_id, records: [] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 计算轮次和 deviated 计数
|
|
47
|
+
const round = data.records.length + 1;
|
|
48
|
+
const deviatedCount = data.records.filter(r => r.verdict === 'deviated').length
|
|
49
|
+
+ (record.verdict === 'deviated' ? 1 : 0);
|
|
50
|
+
|
|
51
|
+
// 追加新记录
|
|
52
|
+
data.records.push({
|
|
53
|
+
round,
|
|
54
|
+
verdict: record.verdict,
|
|
55
|
+
timestamp: record.timestamp,
|
|
56
|
+
summary: record.summary,
|
|
57
|
+
dimensions: record.dimensions,
|
|
58
|
+
deviation_detail: record.deviation_detail,
|
|
59
|
+
reset_suggested: record.reset_suggested,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
63
|
+
|
|
64
|
+
// 检查是否应该升级 blocked(连续 3 轮 deviated,默认值)
|
|
65
|
+
const DEVIATED_LIMIT = 3;
|
|
66
|
+
const shouldEscalate = record.verdict === 'deviated' && deviatedCount >= DEVIATED_LIMIT;
|
|
67
|
+
|
|
68
|
+
return { filePath, round, deviated_count: deviatedCount, should_escalate: shouldEscalate };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 读取某 Intent 的验证历史。
|
|
73
|
+
* @returns {{ intent_id: string, records: array } | null}
|
|
74
|
+
*/
|
|
75
|
+
export function getVerificationHistory(verificationsDir, intentId) {
|
|
76
|
+
const filePath = join(verificationsDir, `${intentId}.json`);
|
|
77
|
+
if (!existsSync(filePath)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
81
|
+
return JSON.parse(raw);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 返回所有待验证的 Intent(有实现产物但还没验证记录的)。
|
|
86
|
+
* 需要传入 Intent Map 来判断哪些 Intent 是 in_progress。
|
|
87
|
+
*/
|
|
88
|
+
export function getPendingVerifications(loomDir, verificationsDir) {
|
|
89
|
+
const intentMap = JSON.parse(
|
|
90
|
+
readFileSync(join(loomDir, '04_INTENT_MAP.json'), 'utf-8')
|
|
91
|
+
);
|
|
92
|
+
const pending = [];
|
|
93
|
+
for (const [id, intent] of Object.entries(intentMap.intents)) {
|
|
94
|
+
if (intent.status === 'in_progress') {
|
|
95
|
+
const hasRecord = existsSync(join(verificationsDir, `${id}.json`));
|
|
96
|
+
if (!hasRecord) pending.push(id);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return pending;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 列出所有验证记录文件。
|
|
104
|
+
*/
|
|
105
|
+
export function listVerifications(verificationsDir) {
|
|
106
|
+
if (!existsSync(verificationsDir)) return [];
|
|
107
|
+
return readdirSync(verificationsDir)
|
|
108
|
+
.filter((f) => f.endsWith('.json'))
|
|
109
|
+
.map((f) => f.replace('.json', ''));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 获取某 Intent 的验证契约(acceptance 字段的解析结果)。
|
|
114
|
+
* 如果 acceptance 是内联定义,直接返回。
|
|
115
|
+
* 如果是引用(如 "see 05_VERIFICATION.md#int-001"),解析引用并返回对应章节内容。
|
|
116
|
+
* @param {string} loomDir — .loom/v{N}/ 目录
|
|
117
|
+
* @param {string} intentId — Intent ID
|
|
118
|
+
* @returns {string} 验收契约内容
|
|
119
|
+
*/
|
|
120
|
+
export function getVerificationContract(loomDir, intentId) {
|
|
121
|
+
const intentMap = JSON.parse(readFileSync(join(loomDir, '04_INTENT_MAP.json'), 'utf-8'));
|
|
122
|
+
if (!(intentId in intentMap.intents)) {
|
|
123
|
+
throw new Error(`Intent 不存在: ${intentId}`);
|
|
124
|
+
}
|
|
125
|
+
const acceptance = intentMap.intents[intentId].acceptance;
|
|
126
|
+
|
|
127
|
+
// 检测是否是引用格式: "see 05_VERIFICATION.md#section" 或 "05_VERIFICATION.md#section"
|
|
128
|
+
const refMatch = acceptance.match(/(?:see\s+)?(\w+\.md)#([\w-]+)/i);
|
|
129
|
+
if (refMatch) {
|
|
130
|
+
const [, file, section] = refMatch;
|
|
131
|
+
const filePath = join(loomDir, file);
|
|
132
|
+
if (!existsSync(filePath)) {
|
|
133
|
+
throw new Error(`验证契约引用的文件不存在: ${filePath}`);
|
|
134
|
+
}
|
|
135
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
136
|
+
return extractMdSection(content, section);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 内联定义,直接返回
|
|
140
|
+
return acceptance;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 从 heading 文本中提取显式锚点。
|
|
145
|
+
* 支持 Pandoc/MDX 风格语法: "## INT-003 {#int-003}"
|
|
146
|
+
*/
|
|
147
|
+
function extractExplicitAnchor(headingText) {
|
|
148
|
+
const match = headingText.match(/\{#([\w-]+)\}\s*$/);
|
|
149
|
+
return match ? match[1] : null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* slugify — 和 philosophy.js 保持一致的逻辑。
|
|
154
|
+
*/
|
|
155
|
+
function slugify(text) {
|
|
156
|
+
return text
|
|
157
|
+
.replace(/\r/g, '')
|
|
158
|
+
.replace(/\{#[\w-]+\}\s*$/, '')
|
|
159
|
+
.toLowerCase()
|
|
160
|
+
.replace(/[^\w\s-]/g, '')
|
|
161
|
+
.replace(/\s+/g, '-')
|
|
162
|
+
.replace(/-+/g, '-')
|
|
163
|
+
.replace(/^-|-$/g, '')
|
|
164
|
+
.trim();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 从 MD 内容中按 heading slug 提取章节。
|
|
169
|
+
* 支持显式锚点 {#slug} 和自动 slugify。
|
|
170
|
+
*/
|
|
171
|
+
function extractMdSection(content, sectionSlug) {
|
|
172
|
+
const lines = content.split('\n');
|
|
173
|
+
let capturing = false;
|
|
174
|
+
let targetLevel = 0;
|
|
175
|
+
const captured = [];
|
|
176
|
+
|
|
177
|
+
for (const line of lines) {
|
|
178
|
+
const cleanLine = line.replace(/\r$/, '');
|
|
179
|
+
const headingMatch = cleanLine.match(/^(#{1,6})\s+(.+)$/);
|
|
180
|
+
if (headingMatch) {
|
|
181
|
+
const level = headingMatch[1].length;
|
|
182
|
+
const headingText = headingMatch[2];
|
|
183
|
+
const slug = extractExplicitAnchor(headingText) || slugify(headingText);
|
|
184
|
+
if (capturing && level <= targetLevel) break;
|
|
185
|
+
if (slug === sectionSlug) {
|
|
186
|
+
capturing = true;
|
|
187
|
+
targetLevel = level;
|
|
188
|
+
captured.push(cleanLine);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (capturing) captured.push(cleanLine);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (captured.length === 0) {
|
|
196
|
+
throw new Error(`验证契约章节未找到: #${sectionSlug}`);
|
|
197
|
+
}
|
|
198
|
+
return captured.join('\n').trim();
|
|
199
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// version — LOOM 版本管理
|
|
2
|
+
// 提供 list / current / new / use / diff 五个原子操作。
|
|
3
|
+
// CLI 只做数据操作,演进决策(Minor/Major)由 Agent + 用户对话完成。
|
|
4
|
+
|
|
5
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
7
|
+
import { createVersionStructure } from './init.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 列出所有版本目录。
|
|
11
|
+
* @param {string} loomRoot — .loom 目录路径
|
|
12
|
+
* @returns {{ versions: string[], current: string|null }}
|
|
13
|
+
*/
|
|
14
|
+
export function listVersions(loomRoot) {
|
|
15
|
+
if (!existsSync(loomRoot)) {
|
|
16
|
+
throw new Error(`找不到 .loom 目录: ${loomRoot}`);
|
|
17
|
+
}
|
|
18
|
+
const versions = readdirSync(loomRoot)
|
|
19
|
+
.filter((d) => /^v\d+$/.test(d) && statSync(join(loomRoot, d)).isDirectory())
|
|
20
|
+
.sort((a, b) => parseInt(a.slice(1)) - parseInt(b.slice(1)));
|
|
21
|
+
const current = readCurrentPointer(loomRoot);
|
|
22
|
+
return { versions, current };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 读取当前版本指针。
|
|
27
|
+
* 优先读 .loom/current 文件;不存在则回退到自动探测最新版本。
|
|
28
|
+
* @param {string} loomRoot — .loom 目录路径
|
|
29
|
+
* @returns {string|null} 版本号如 'v1',或 null(无版本)
|
|
30
|
+
*/
|
|
31
|
+
export function readCurrentPointer(loomRoot) {
|
|
32
|
+
const pointerPath = join(loomRoot, 'current');
|
|
33
|
+
if (existsSync(pointerPath)) {
|
|
34
|
+
const v = readFileSync(pointerPath, 'utf-8').trim();
|
|
35
|
+
if (/^v\d+$/.test(v) && existsSync(join(loomRoot, v))) return v;
|
|
36
|
+
}
|
|
37
|
+
// 回退:自动探测最新版本
|
|
38
|
+
if (!existsSync(loomRoot)) return null;
|
|
39
|
+
const versions = readdirSync(loomRoot)
|
|
40
|
+
.filter((d) => /^v\d+$/.test(d) && statSync(join(loomRoot, d)).isDirectory())
|
|
41
|
+
.sort((a, b) => parseInt(b.slice(1)) - parseInt(a.slice(1)));
|
|
42
|
+
return versions[0] ?? null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 创建新版本 v{N+1},自动切换为当前版本。
|
|
47
|
+
* 不复制旧版本内容——空目录 + 模板强制 Agent 重新思考。
|
|
48
|
+
* @param {string} projectDir — 项目根目录
|
|
49
|
+
* @returns {{ version: string, created: string[], skipped: string[] }}
|
|
50
|
+
*/
|
|
51
|
+
export function newVersion(projectDir) {
|
|
52
|
+
const loomRoot = join(projectDir, '.loom');
|
|
53
|
+
const { versions } = listVersions(loomRoot);
|
|
54
|
+
const nextNum = versions.length === 0
|
|
55
|
+
? 1
|
|
56
|
+
: parseInt(versions[versions.length - 1].slice(1)) + 1;
|
|
57
|
+
const nextV = `v${nextNum}`;
|
|
58
|
+
const result = createVersionStructure(projectDir, nextV);
|
|
59
|
+
// 自动切换为当前版本
|
|
60
|
+
writeFileSync(join(loomRoot, 'current'), nextV, 'utf-8');
|
|
61
|
+
result.created.push('.loom/current');
|
|
62
|
+
return { version: nextV, created: result.created, skipped: result.skipped };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 切换当前版本指针。
|
|
67
|
+
* @param {string} loomRoot — .loom 目录路径
|
|
68
|
+
* @param {string} version — 目标版本号如 'v1'
|
|
69
|
+
*/
|
|
70
|
+
export function useVersion(loomRoot, version) {
|
|
71
|
+
const v = version.startsWith('v') ? version : `v${version}`;
|
|
72
|
+
if (!existsSync(join(loomRoot, v))) {
|
|
73
|
+
throw new Error(`版本不存在: ${v}`);
|
|
74
|
+
}
|
|
75
|
+
writeFileSync(join(loomRoot, 'current'), v, 'utf-8');
|
|
76
|
+
return v;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 对比两个版本的文件差异。
|
|
81
|
+
* 只对比文件存在性和大小,不做内容 diff(内容 diff 用 Git)。
|
|
82
|
+
* @param {string} loomRoot — .loom 目录路径
|
|
83
|
+
* @param {string} v1 — 版本 A
|
|
84
|
+
* @param {string} v2 — 版本 B
|
|
85
|
+
* @returns {{ only_in_a: string[], only_in_b: string[], different_size: string[], same: string[] }}
|
|
86
|
+
*/
|
|
87
|
+
export function diffVersions(loomRoot, v1, v2) {
|
|
88
|
+
const a = v1.startsWith('v') ? v1 : `v${v1}`;
|
|
89
|
+
const b = v2.startsWith('v') ? v2 : `v${v2}`;
|
|
90
|
+
const dirA = join(loomRoot, a);
|
|
91
|
+
const dirB = join(loomRoot, b);
|
|
92
|
+
if (!existsSync(dirA)) throw new Error(`版本不存在: ${a}`);
|
|
93
|
+
if (!existsSync(dirB)) throw new Error(`版本不存在: ${b}`);
|
|
94
|
+
|
|
95
|
+
const filesA = listFilesRelative(dirA);
|
|
96
|
+
const filesB = listFilesRelative(dirB);
|
|
97
|
+
const setA = new Set(filesA);
|
|
98
|
+
const setB = new Set(filesB);
|
|
99
|
+
|
|
100
|
+
const onlyInA = filesA.filter((f) => !setB.has(f));
|
|
101
|
+
const onlyInB = filesB.filter((f) => !setA.has(f));
|
|
102
|
+
const common = filesA.filter((f) => setB.has(f));
|
|
103
|
+
const differentSize = common.filter((f) => {
|
|
104
|
+
const sa = statSync(join(dirA, f)).size;
|
|
105
|
+
const sb = statSync(join(dirB, f)).size;
|
|
106
|
+
return sa !== sb;
|
|
107
|
+
});
|
|
108
|
+
const same = common.filter((f) => {
|
|
109
|
+
const sa = statSync(join(dirA, f)).size;
|
|
110
|
+
const sb = statSync(join(dirB, f)).size;
|
|
111
|
+
return sa === sb;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return { only_in_a: onlyInA, only_in_b: onlyInB, different_size: differentSize, same };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 递归列出目录下所有文件的相对路径。
|
|
119
|
+
*/
|
|
120
|
+
function listFilesRelative(dir, base = '') {
|
|
121
|
+
const result = [];
|
|
122
|
+
if (!existsSync(dir)) return result;
|
|
123
|
+
for (const entry of readdirSync(dir)) {
|
|
124
|
+
const full = join(dir, entry);
|
|
125
|
+
const rel = base ? `${base}/${entry}` : entry;
|
|
126
|
+
if (statSync(full).isDirectory()) {
|
|
127
|
+
result.push(...listFilesRelative(full, rel));
|
|
128
|
+
} else {
|
|
129
|
+
result.push(rel);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return result.sort();
|
|
133
|
+
}
|