@fanchao8609/agent_brain_sync 1.8.6 → 1.8.8
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/hooks/abs.opencode.ts +3 -6
- package/hooks/abs.pi.ts +4 -7
- package/package.json +2 -2
- package/skill/abs-agent-brain-sync/SKILL.md +71 -7
- package/skill/abs-think-tree/SKILL.md +194 -0
- package/skill/abs-think-tree/check.js +119 -0
- package/skill/abs-think-tree/check.test.js +161 -0
- package/src/store.js +77 -21
- package/src/todo.js +61 -23
package/hooks/abs.opencode.ts
CHANGED
|
@@ -54,12 +54,9 @@ const server = async ({ client, directory }) => {
|
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
const TEARDOWN_MSG =
|
|
57
|
-
"[abs
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"3) 值得留的经验 abs note \"...\"(宁少勿滥,能从代码 grep 到的不记);\n" +
|
|
61
|
-
"4) abs log \"完成 X:...\" 记一行工作成果,新页同步进 index。\n" +
|
|
62
|
-
"简洁执行,不要复述本条提醒。若本次确实没有可沉淀产出,直接回一句\"无可沉淀\"即可。"
|
|
57
|
+
"[abs] 本会话改过文件,.brain/ 今日无记录。\n" +
|
|
58
|
+
"这条是信息不是命令:该沉淀就沉淀,没有可沉淀的直接回一句「无可沉淀」,不用凑。\n" +
|
|
59
|
+
"需要时:abs todo / abs todo done <id> / abs note \"...\" / abs log \"...\""
|
|
63
60
|
|
|
64
61
|
// bash 里只跑查询类命令不算改文件 (与 pi 侧 READONLY_CMD 同义, 但生成代码里要写进模板串)
|
|
65
62
|
const READONLY_CMD = /^\s*(ls|cat|grep|rg|find|head|tail|wc|git\s+(status|log|diff|show|branch)|pwd|which|echo|node\s+-v|npm\s+(ls|view)|curl)\b/
|
package/hooks/abs.pi.ts
CHANGED
|
@@ -176,13 +176,10 @@ export default function absPiHook(pi: ExtensionAPI): void {
|
|
|
176
176
|
const notes = sessionNotes.splice(0, sessionNotes.length)
|
|
177
177
|
try {
|
|
178
178
|
pi.sendUserMessage(
|
|
179
|
-
"[abs
|
|
180
|
-
"
|
|
181
|
-
"
|
|
182
|
-
|
|
183
|
-
"4) abs log \"完成 X:...\" 记一行工作成果,新页同步进 index。\n" +
|
|
184
|
-
notesBlock(notes) +
|
|
185
|
-
"简洁执行,不要复述本条提醒。若本次确实没有可沉淀产出,直接回一句\"无可沉淀\"即可。",
|
|
179
|
+
"[abs] 本会话改过文件,.brain/ 今日无记录。\n" +
|
|
180
|
+
"这条是信息不是命令:该沉淀就沉淀,没有可沉淀的直接回一句「无可沉淀」,不用凑。\n" +
|
|
181
|
+
"需要时:abs todo / abs todo done <id> / abs note \"...\" / abs log \"...\"" +
|
|
182
|
+
notesBlock(notes),
|
|
186
183
|
{ deliverAs: "followUp" },
|
|
187
184
|
)
|
|
188
185
|
} catch {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fanchao8609/agent_brain_sync",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.8",
|
|
4
4
|
"description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"scripts": {
|
|
35
35
|
"abs": "node bin/abs.js",
|
|
36
36
|
"mcp": "node bin/mcp.js",
|
|
37
|
-
"test": "node --test 'test/*.test.js'",
|
|
37
|
+
"test": "node --test 'test/*.test.js' 'skill/**/*.test.js'",
|
|
38
38
|
"prepack": "node -e \"require('fs').chmodSync('bin/abs.js',0o755);require('fs').chmodSync('bin/mcp.js',0o755)\"",
|
|
39
39
|
"pack:check": "npm pack --dry-run"
|
|
40
40
|
}
|
|
@@ -1,10 +1,32 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: abs-agent-brain-sync
|
|
3
|
-
description: abs (agent-brain-sync) 跨会话 AI 编码记忆与任务续接。开场续接状态(abs load/MCP abs_load),干活中任务/经验实时落盘(abs_task/abs_note)
|
|
3
|
+
description: abs (agent-brain-sync) 跨会话 AI 编码记忆与任务续接。开场续接状态(abs load/MCP abs_load),干活中任务/经验实时落盘(abs_task/abs_note),需要收尾时才走收尾循环。解决会话无状态:经验/进度/坑碎片化、重开失忆。遇 bug 排查时配合挂载 bug-hunter skill。
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# abs — 跨会话记忆 (agent-brain-sync)
|
|
7
7
|
|
|
8
|
+
## 最高优先:触发总则(凌驾本文所有流程)
|
|
9
|
+
|
|
10
|
+
**关键词不是触发器,意图才是。**
|
|
11
|
+
|
|
12
|
+
句子里出现 abs 词(收尾/todo/沉淀/提示/load/log/note)**不等于**要执行 abs 动作。
|
|
13
|
+
|
|
14
|
+
| 用户的意思 | 做什么 |
|
|
15
|
+
|---|---|
|
|
16
|
+
| 谈论、提问、吐槽、定规则("太啰嗦""要简短""这个设计怎样") | **只回应,不执行动作** |
|
|
17
|
+
| 明确让我做事("收尾""记一下""去做") | 执行 |
|
|
18
|
+
|
|
19
|
+
**判断依据是整段话的意图,不是里面出现过哪个词。**
|
|
20
|
+
|
|
21
|
+
反例(真实发生):用户说"尽量简短的汇报" —— 那是在**定规则**,不是在**下命令**;
|
|
22
|
+
被误当成指令跑了一次收尾。
|
|
23
|
+
|
|
24
|
+
推论:
|
|
25
|
+
- 拿不准时**先问**,不要靠关键词直接动手。
|
|
26
|
+
- 规则变更("以后简短点")写入本文,**不执行动作**。
|
|
27
|
+
- 输出简短:收尾/提示/todo 汇报尽量一两句,不写小作文。
|
|
28
|
+
- 本总则适用于所有工具,不限 abs。
|
|
29
|
+
|
|
8
30
|
把 AI 编码经验从会话沙盒里救出来。每个会话都是无状态的——Claude、OpenCode、Cursor
|
|
9
31
|
各开一堆会话,经验/进度/踩坑全碎片化,重开像失忆。本技能用一个放**项目根目录**、
|
|
10
32
|
Obsidian 可直接打开的 Markdown 图谱(`.brain/`)做统一落点。
|
|
@@ -128,7 +150,8 @@ load 只展示最新 5 条、每条按语义边界收口。
|
|
|
128
150
|
行形态:`- [ ] [进行中] <id> [[name]] — 说明 (认领 YYYY-MM-DD)`
|
|
129
151
|
|
|
130
152
|
`abs todo done <id>` 会勾选并归位到 Done 的日期组顶部,断点(`↳` 行)随迁;
|
|
131
|
-
Done 区由 `abs wrapup` 在会话结束时自动把「超 3
|
|
153
|
+
Done 区由 `abs wrapup` 在会话结束时自动把「超 3 天且整天都已完成」的组迁进
|
|
154
|
+
**当天会话快照的 `## 📦 任务归档` 段**(`sessions/log-<日期>.md`)—— 归档不另建文件
|
|
132
155
|
(任一天有未完成则整天不迁)。**什么时候动它见「进行中」一节。**
|
|
133
156
|
|
|
134
157
|
### `entities/` `concepts/` `sources/` `syntheses/` `sessions/`
|
|
@@ -139,10 +162,50 @@ Done 区由 `abs wrapup` 在会话结束时自动把「超 3 天且整天都已
|
|
|
139
162
|
| `concepts/` | 可复用的**规律/坑**(能说"这么做就避坑") | `docker-prisma-429.md` |
|
|
140
163
|
| `sources/` | 实时经验**暂存**(`abs note` 自动落) | `YYYY-MM-DD-slug.md` |
|
|
141
164
|
| `syntheses/` | **横向**选型/架构取舍(跨多个 entity/concept 的判断) | `synthesis-slug.md` |
|
|
142
|
-
| `sessions/` |
|
|
165
|
+
| `sessions/` | 会话快照(含 `## 🪝 Next Session Hook`)+ 当日任务归档段 | **`log-YYYY-MM-DD.md`** |
|
|
143
166
|
|
|
144
167
|
归类拿不准时**默认 `concepts/`**。
|
|
145
168
|
|
|
169
|
+
### `sessions/` 命名契约:一天一个文件(硬规则)
|
|
170
|
+
|
|
171
|
+
**一天只允许一个文件,且只能叫 `log-<日期>.md`。** 该日的一切都放进它:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
log-2026-09-08.md
|
|
175
|
+
├─ 会话快照正文(AI 手写:做了什么/怎么定位/结论)
|
|
176
|
+
├─ ## 关联连接
|
|
177
|
+
├─ ## 🪝 Next Session Hook(强制)
|
|
178
|
+
└─ ## 📦 任务归档(`abs todo archive` 自动写,机器生成勿手改)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**为什么定死**(实测 codebuddy 项目乱成这样,2026-09-15):同一天曾出现 **5 个文件**:
|
|
182
|
+
`2026-09-07-init-brain.md` / `-todo-md-archive.md` / `-todo归档.md` / `-ui-fixes.md` / `log-2026-09-07.md`,
|
|
183
|
+
且五份的 `tags` 各不相同(`source` / `source,archive` / `todo-archive` / `source,session-log` / `session-log`)。
|
|
184
|
+
同一天的记录散在多处,查一次要开五个文件。
|
|
185
|
+
|
|
186
|
+
**✅ 归档的正规做法**(用户 2026-09-15 定为标准,以后都这么干):
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
abs todo archive # 一条命令搞定,无需手工搬
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
它自动把过期日期组写进 `sessions/log-<那天>.md` 的 `## 📦 任务归档` 段:
|
|
193
|
+
- 该日**已有快照** → 只替换归档段,**段外的手写内容逐字保留**(不覆盖人工内容)
|
|
194
|
+
- 该日**没有快照** → 建一个只有归档段的 `log-<日期>.md`(那天本来就只有任务记录)
|
|
195
|
+
- 同日二次归档 → 段内追加,不重复建段
|
|
196
|
+
|
|
197
|
+
**结论:归档这件事不需要「方法」,只需要跑那条命令。**下面的禁令是给**手写**用的
|
|
198
|
+
(手写时才可能犯错)。
|
|
199
|
+
|
|
200
|
+
**四条禁令**(每条都有实测反例):
|
|
201
|
+
1. **不要**再把归档单独建文件(`<日期>-todo归档.md`)——归档已有专门的坑位段,`abs todo archive` 会写进去。
|
|
202
|
+
存量旧页不必手改(lint 不报),但新归档不再产生它。
|
|
203
|
+
2. **不要**在 `sessions/` 放 `tags: [source]` 的页——暂存页属 `sources/`。
|
|
204
|
+
当天做的一组工作不是「暂存线索」,而是**当天的快照正文**:直接写进 `log-<日期>.md`。
|
|
205
|
+
3. **不要**一天拆多个快照(`log-<日期>-<主题>.md`)——多主题就多写几个 `##` 段。
|
|
206
|
+
4. **不要**手工搬大文件进来当归档(如把仓库根 `todo.md` 全文倒进 `sessions/`)——
|
|
207
|
+
那要么进 `sources/`,要么摘出规律进 `concepts/`;全文属外部产物,用指针就行。
|
|
208
|
+
|
|
146
209
|
### 容量纪律(写任何页之前过四关)
|
|
147
210
|
|
|
148
211
|
图谱贵在**精**不在全,不过关就不写或压缩:
|
|
@@ -167,7 +230,7 @@ Done 区由 `abs wrapup` 在会话结束时自动把「超 3 天且整天都已
|
|
|
167
230
|
>
|
|
168
231
|
> **`## Rules` 区原样保留** —— 那是 load 要传达的状态本身(代码里明确不折)。
|
|
169
232
|
> 要全量明细:`abs todo --full` / `abs index`,或直接读 `.brain/` 文件、
|
|
170
|
-
> `.brain/sessions
|
|
233
|
+
> `.brain/sessions/log-<日期>.md`(含 `## 📦 任务归档` 段)。
|
|
171
234
|
2. **对账滞留(强制,别跳过)**:若 `abs load` 顶部出现 `⏳ 上会话滞留`,说明上会话有任务做完/做到一半就断了。**先收尾再开工**:
|
|
172
235
|
- 快照里的任务现在真做完了 → `abs todo done <id>`(done 后下次 load 滞留自动消失);
|
|
173
236
|
- 还没做完 → `abs todo note <id> --note "接到哪/改到哪个文件"` 补断点(别空手续接)。
|
|
@@ -215,7 +278,7 @@ Done 区由 `abs wrapup` 在会话结束时自动把「超 3 天且整天都已
|
|
|
215
278
|
> OpenCode todowrite / pi `/list`)—— 那些多是会话内临时,不写 `.brain/todo.md`,
|
|
216
279
|
> 下会话接不上、收尾没影。原生 todo 顶多记“本会话不跨断点的临时拆解”。
|
|
217
280
|
|
|
218
|
-
##
|
|
281
|
+
## 收尾循环(用户明确要求收尾时才走)
|
|
219
282
|
|
|
220
283
|
**每个任务边界、被 Stop/打断、告一段落时,别停半空。** 这是“开场接上状态、结束落回状态”的闭环。
|
|
221
284
|
|
|
@@ -223,10 +286,11 @@ Done 区由 `abs wrapup` 在会话结束时自动把「超 3 天且整天都已
|
|
|
223
286
|
> 下会话 `abs load` 会自动把滞留顶到顶部(`⏳ 上会话滞留`)—— 所以收尾不靠自觉,是开场被强制接上。
|
|
224
287
|
>
|
|
225
288
|
> **主动注入**:pi 扩展在 `agent_end` 检测「本会话真改过文件」且「log.md 今日无记录」时注入
|
|
226
|
-
> `[abs 收尾提醒]
|
|
289
|
+
> `[abs 收尾提醒]`(每会话最多一次)。**它是一条信息,不是命令** ——
|
|
290
|
+
> 自己判断该不该沉淀;没有可沉淀的就回一句「无可沉淀」,不用强行凑。
|
|
227
291
|
> Claude/Codex 靠 `Stop` 事件达成同样效果。
|
|
228
292
|
|
|
229
|
-
|
|
293
|
+
当用户**明确说要收尾/结束/切别的事**时,按下面走(不是每个词都触发,见开头总则):
|
|
230
294
|
|
|
231
295
|
1. **读 todo** → `abs load`,看 Todo 还有哪些没完成。
|
|
232
296
|
2. **判有没有做完没登记** → 实际完成了漏登记的 `abs todo done <id>`;做到一半补
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: abs-think-tree
|
|
3
|
+
description: 回答前的固定思考骨架 —— 三层下坠式自问, 每层出多个候选互相竞争, 最后一个问题可给多个答案。用于减少"急于表达、只给一个答案、把无答案伪装成有答案"。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 三层思考骨架
|
|
7
|
+
|
|
8
|
+
## 为什么需要它
|
|
9
|
+
|
|
10
|
+
默认失败模式有三个,且都不是"不够聪明"造成的:
|
|
11
|
+
|
|
12
|
+
| 失败模式 | 表现 |
|
|
13
|
+
|---------|------|
|
|
14
|
+
| 急于表达 | 还没想清就写答案,答案是第一个想到的 |
|
|
15
|
+
| 只给一个答案 | 明明有几种合理解读,只交付一种,另外几种当没想过 |
|
|
16
|
+
| 无答案装成有答案 | 问题本身没有确定答案,硬凑一个自圆其说的 |
|
|
17
|
+
|
|
18
|
+
**不是让模型变聪明,是让它的偷懒可见。** 骨架填不满 = 露怯,比给个像样的答案更有价值。
|
|
19
|
+
|
|
20
|
+
## 核心:下坠 + 竞争
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
根: 用户原话(外部给定,不可改)
|
|
24
|
+
└ L1 他为什么问 出 n 个候选,留最贴合原话的 ← 防答非所问
|
|
25
|
+
└ L2 边界在哪 三条轴上定位,留最贴合 L1 的 ← 防答错方向(默认留 2)
|
|
26
|
+
└ L3 他要什么 写出可验收产出形态,留最贴合 L2 的 ← 防给不出东西
|
|
27
|
+
↓
|
|
28
|
+
回答:沿存活路径给,有几条活路径就给几个答案
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**三层职能各自独立,不可合并**:L1 定动机 / L2 定边界 / L3 定产出。
|
|
32
|
+
少了 L2,答案容易落在错的轴上(问主观答客观、问当下答长期)。
|
|
33
|
+
|
|
34
|
+
**竞争的关键:每层的裁判是父节点,不是答案。** 兄弟候选共享同一个父,所以能横向比较——
|
|
35
|
+
"这两条哪个更贴合上面的问题",这是相对判断,不是自评,也不是造证据。
|
|
36
|
+
|
|
37
|
+
**父节点始终是外部给定的**(L1 的父是用户原话),所以裁判链是外部的,模型无法自己定标准。
|
|
38
|
+
|
|
39
|
+
## 留几个(关键)
|
|
40
|
+
|
|
41
|
+
留几个**不看你想要几个,看有没有依据**:
|
|
42
|
+
|
|
43
|
+
| 同层候选之间的差距 | 留 k | 依据 |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| 有明显差距(有候选明显不贴合父节点) | 可以留 1 | 竞争胜出,有裁判 |
|
|
46
|
+
| **无差距(都同样贴合)** | **必须留 2** | 竞争无法裁决,砍任何一个都是无依据 |
|
|
47
|
+
|
|
48
|
+
**默认留 2。** 因为大多数情况下同时存在几个同样合理的视角,而**同样合理时砍掉一个,是拿单一视角冒充完整回答**——这正是这套骨架要防的病。
|
|
49
|
+
|
|
50
|
+
**无差距时强行留 1 的后果**(实测):问"要不要迁移到新框架",两个 L1 候选(评估成本收益 / 遇瓶颈想换)都贴合原话,
|
|
51
|
+
它们的 L2 边界差别只在"当下/长期"一条轴。留 1 就必须砍掉一边——
|
|
52
|
+
**但这里没有胜者,砍谁都是丢掉一个合法视角。**
|
|
53
|
+
|
|
54
|
+
**竞争结构的边界**:它只能淘汰差的,不能淘汰同样好的。兄弟差距为零时,没有裁判。
|
|
55
|
+
|
|
56
|
+
## 流程
|
|
57
|
+
|
|
58
|
+
### L1 他为什么问 —— 出 3 个,留 2
|
|
59
|
+
|
|
60
|
+
写 3 个**具体的后续动作**,不是心情。
|
|
61
|
+
|
|
62
|
+
- ✅ "想快速定位原因"、"想确认是不是自己改错了"、"想让测试通过"
|
|
63
|
+
- ❌ "想了解这个技术"、"想学习一下"(空泛,等于没写)
|
|
64
|
+
|
|
65
|
+
**基准**:用户原话。写不出的直接标"原话不足以判断"。
|
|
66
|
+
|
|
67
|
+
### L2 他在问什么 —— 每支出 2 个,留 1~2(同上:无差距时留 2)
|
|
68
|
+
|
|
69
|
+
**L2 的职能是划定问题边界,不是复述问题。** 复述不产生信息;划边界产生信息。
|
|
70
|
+
|
|
71
|
+
对每个存活的 L1,在三条轴上定位这个问题的边界:
|
|
72
|
+
|
|
73
|
+
| 轴 | 两端 | 划错的后果 |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| 主观 / 客观 | "好不好吃" vs "营养成分" | 答成客观数据 |
|
|
76
|
+
| 当下 / 长期 | "这顿吃啥" vs "长期吃水果好不好" | 答成养生建议 |
|
|
77
|
+
| 判断 / 操作 | "要不要买" vs "怎么挑" | 给方法但没给结论 |
|
|
78
|
+
|
|
79
|
+
- **每条轴必须落到一端**,不许写"两者都有"。
|
|
80
|
+
- 必须能跟 L1 对上。
|
|
81
|
+
|
|
82
|
+
**落不下去时,区分两种原因(路径生死相同,输出描述不同):**
|
|
83
|
+
|
|
84
|
+
| 原因 | 含义 | 输出里怎么说 |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| 落不下去,因为候选本身空泛 | 动机没说清(如"想了解苹果") | "剪掉:动机不具体" |
|
|
87
|
+
| 落不下去,因为信息不够 | 动机成立但原话没有线索 | "悬置:需要你补充 X" |
|
|
88
|
+
|
|
89
|
+
两者都使这一支不往下走,**但前者是判断,后者是缺信息**。不要把缺信息写成判断。
|
|
90
|
+
|
|
91
|
+
- 不同 L1 可能落到同一个边界 → **汇聚 = 强信号**,记录下来。
|
|
92
|
+
|
|
93
|
+
**示例**:"苹果好吃吗"
|
|
94
|
+
- L1 = 想选水果 → L2 = 主观 + 当下 + 判断
|
|
95
|
+
- 这个边界直接挡住"富含维 C"(客观)和"长期吃水果的好处"(长期)—— 那是在答另一个问题
|
|
96
|
+
|
|
97
|
+
**L2 是没它就容易答错方向的一层。** 跳过 L2 直接由 L1 生成答案,最常见的失败是答案落在错的轴上。
|
|
98
|
+
|
|
99
|
+
### L3 他要什么 —— 写出可验收的产出形态
|
|
100
|
+
|
|
101
|
+
这一层是**全流程的地基**。
|
|
102
|
+
|
|
103
|
+
- ✅ "一段能跑的代码"、"一个明确结论"、"2-3 个选项让我选"、"一个追问"
|
|
104
|
+
- ❌ "一个全面的回答"、"一些建议"(不可验收)
|
|
105
|
+
|
|
106
|
+
**写不出可验收形态时,不许硬写。** 直接说明:
|
|
107
|
+
> 这个问题没有可验收的标准,我给的是参考,不是答案。
|
|
108
|
+
|
|
109
|
+
这一条是本骨架最重要的产物——它把"无答案"从失败变成一个合法输出。
|
|
110
|
+
|
|
111
|
+
### 回答
|
|
112
|
+
|
|
113
|
+
沿存活路径写。四条规矩:
|
|
114
|
+
|
|
115
|
+
1. **每条不同的 L2/L3 路径对应一个答案,不合并。** 有 3 条活路径就给 3 个答案。
|
|
116
|
+
2. **汇聚优先,分歧附为例外**(不是并列):
|
|
117
|
+
- 多条路径汇聚 → 当作**主结论**,可标"多条路径汇聚于此"
|
|
118
|
+
- 少数路径分歧 → 当作**例外**附在后面,写明它在哪条轴上不同
|
|
119
|
+
- ❌ 不要写成两个平行答案 —— 那会让用户以为两者同等重要
|
|
120
|
+
|
|
121
|
+
**示例**(三选一汇聚、一条分歧):
|
|
122
|
+
> 主:问的是**主观口味**,苹果甜脆多汁,这几个维度自己判断。
|
|
123
|
+
> 例外:**如果你要给小孩吃,问题就变成安全性(客观轴)**,那是另一个问题,得单独说。
|
|
124
|
+
|
|
125
|
+
3. **不选最优。** 骨架禁止输出"综合来看最佳答案是……"这种合并。
|
|
126
|
+
4. **不回头改上层。** L3 写不出可验收形态时,不许回头改 L2 的边界。
|
|
127
|
+
**理由:下级不能改上级。** L3 没有比 L2 更高的裁判,让它改 L2 = 自证。
|
|
128
|
+
正确做法:直接在 L3 露怯("这题我给参考不是答案")。
|
|
129
|
+
|
|
130
|
+
## 能力边界(不是 bug,无法靠本骨架解决)
|
|
131
|
+
|
|
132
|
+
竞争结构的裁判链是"父节点",而父节点有它看不到的地方:
|
|
133
|
+
|
|
134
|
+
| 边界 | 说明 |
|
|
135
|
+
|---|---|
|
|
136
|
+
| **只能淘汰差的,不能淘汰同样好的** | 兄弟候选同样贴合父节点时,没有裁判 |
|
|
137
|
+
| **只能一致地错,不能发现自己错了** | 父节点错了(如 L1 动机判断错),子节点会沿着它一致错下去;竞争范围只在兄弟之间,淘汰不了父 |
|
|
138
|
+
| **无外部信号** | 要发现"父错了",只能靠用户纠正或可运行的验证(编译/测试/算数) |
|
|
139
|
+
|
|
140
|
+
**本骨架保证一致性,不保证方向。** 它防的是"同一套逻辑内部自相矛盾";
|
|
141
|
+
不防"整套逻辑从根上就错了"——那需要外部裁判。
|
|
142
|
+
|
|
143
|
+
## 自检(答完前扫一眼)
|
|
144
|
+
|
|
145
|
+
| 检查项 | 不合格的样子 |
|
|
146
|
+
|-------|-------------|
|
|
147
|
+
| L1 三条是否互斥? | 三条其实是同一件事的换说法 |
|
|
148
|
+
| L1 是否具体到动作? | "想了解一下" |
|
|
149
|
+
| L2 三条轴是否都落到一端? | 写"两者都有"、没划边界就进 L3 |
|
|
150
|
+
| L2 能否对回 L1? | 对不上还留着 |
|
|
151
|
+
| L3 是否可验收? | "给出好的回答" |
|
|
152
|
+
| 是否偷偷合并了多解? | 说了三种可能,最后只答一种 |
|
|
153
|
+
| 分歧是否被藏起来了? | 两条路径结论冲突,装作没看见 |
|
|
154
|
+
| 无答案时是否露怯? | 硬凑一个"综合"答案 |
|
|
155
|
+
|
|
156
|
+
## 陷阱
|
|
157
|
+
|
|
158
|
+
| 陷阱 | 正确做法 |
|
|
159
|
+
|------|---------|
|
|
160
|
+
| 把 L1 写成用户心情 | 写成具体后续动作 |
|
|
161
|
+
| L2 复述问题(废层) | L2 必须划边界:主观/客观 · 当下/长期 · 判断/操作 |
|
|
162
|
+
| L2 写"两者都有" | 每条轴必须落到一端,落不下去就作废这一支 |
|
|
163
|
+
| 所有层都填满还答错 | 填满是必要不充分;基准是父节点,不是自评 |
|
|
164
|
+
| 层层筛选只剩一条 | k 至少留 2 条到终点,否则退回"急于表达" |
|
|
165
|
+
| 同层候选无差距却留 1 | 无差距必须留 2;只有有明显差距时才能靠竞争留 1 |
|
|
166
|
+
| 为了填满而编候选 | 编不出来时写"原话不足以判断",这本身是结论 |
|
|
167
|
+
| 用"综合来看"合并多解 | 禁止。分歧就是分歧,列出来 |
|
|
168
|
+
| 无答案时硬凑 | 明确说"这题没有可验收标准" |
|
|
169
|
+
| 把骨架当形式走一遍 | 填完还是答第一个想到的 = 没走 |
|
|
170
|
+
|
|
171
|
+
## 边界(这套骨架不适用的时候)
|
|
172
|
+
|
|
173
|
+
- **问题本身有唯一正确答案时**(算术、编译):不需要分叉,直接答 + 验证。
|
|
174
|
+
- **用户明确要一个答案时**:给一个,别列 3 个让用户挑。
|
|
175
|
+
- **纯闲聊**:走这套会显得像官僚流程。
|
|
176
|
+
|
|
177
|
+
**本骨架真正的适用面:需求模糊、可能有多种合理解读、或根本没有确定答案的问题。**
|
|
178
|
+
|
|
179
|
+
## 成本
|
|
180
|
+
|
|
181
|
+
一次思考内完成,不是 n 次调用。**n 条线是同一个 forward pass 里的 n 个位置**,
|
|
182
|
+
上下文共享,token 只比直接回答多约 30%。不烧 token 的关键是**不来回**——
|
|
183
|
+
不在生成和外部判定之间反复起调用。
|
|
184
|
+
|
|
185
|
+
## 收尾:结论必须明确
|
|
186
|
+
|
|
187
|
+
答完必须让用户能判断:
|
|
188
|
+
|
|
189
|
+
1. **给了几个答案**(一条路径就一个,多条就列全)
|
|
190
|
+
2. **哪几条汇聚了**(置信较高处)
|
|
191
|
+
3. **哪几条分歧了**(需要用户定夺处)
|
|
192
|
+
4. **是否无答案**(若 L3 写不出,明确说)
|
|
193
|
+
|
|
194
|
+
不许用"综合来看建议……"把分歧抹平。
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* think-tree 自证器 —— 检查一次三层思考的填写质量。
|
|
3
|
+
*
|
|
4
|
+
* 用法:
|
|
5
|
+
* node skill/abs-think-tree/check.js <填好的答案文件.md>
|
|
6
|
+
*
|
|
7
|
+
* 输入格式(答案文件):
|
|
8
|
+
* ## L1
|
|
9
|
+
* - [动作] 想快速定位原因
|
|
10
|
+
* - [动作] 想确认是不是自己改错了
|
|
11
|
+
* ## L2
|
|
12
|
+
* - [客观][当下][判断] 在问因果
|
|
13
|
+
* ## L3
|
|
14
|
+
* - [产出] 一个嫌疑点 + 证据 + 验证方法
|
|
15
|
+
*
|
|
16
|
+
* 各类标签是硬判据: 不写 = 报错, 不是"提醒"。
|
|
17
|
+
* 目的: 把"贴合用户原话"这句软话, 拆成能失败的检查。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const ACTION_VERBS = /(定位|确认|判断|决定|选择|选定|挑选|修改|查找|比较|评估|验证|交付|得到|知道|排除|复现|区分|排查|了解|收尾|继续|停止|给出|输出|回答|说明|解释|分析|检查|拆|推|排|定|学)/;
|
|
21
|
+
|
|
22
|
+
/** L2 三轴标签。必须三轴齐全, 缺一轴 = 边界没划全。 */
|
|
23
|
+
const AXES = ["主观|客观", "当下|长期", "判断|操作"];
|
|
24
|
+
|
|
25
|
+
/** L3 可验收产出类型的白名单。 */
|
|
26
|
+
const OUTPUT_KINDS = ["代码", "结论", "选项", "追问", "证据", "复现条件", "命令"];
|
|
27
|
+
|
|
28
|
+
function fail(list, msg) {
|
|
29
|
+
list.push(msg);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** L1: 每个候选必须是"动作", 不能是"心情"。 */
|
|
33
|
+
function checkL1(lines, errs) {
|
|
34
|
+
if (!lines.length) return fail(errs, "L1 为空: 至少写 1 个动机候选");
|
|
35
|
+
if (lines.length < 2) fail(errs, "L1 只有 1 个候选: 无兄弟则无法竞争");
|
|
36
|
+
lines.forEach((l, i) => {
|
|
37
|
+
const m = l.match(/^-\s*\[动作\]\s*(.+)$/);
|
|
38
|
+
if (!m) return fail(errs, `L1[${i}] 缺 [动作] 标签: "${l}"`);
|
|
39
|
+
const body = m[1].trim();
|
|
40
|
+
if (!body) return fail(errs, `L1[${i}] 标签后为空`);
|
|
41
|
+
if (!ACTION_VERBS.test(body))
|
|
42
|
+
fail(errs, `L1[${i}] 不像动作(无动词): "${body}"`);
|
|
43
|
+
// 心情词黑名单
|
|
44
|
+
if (/(了解一下|学习一下|感兴趣|好奇|随便看看)/.test(body))
|
|
45
|
+
fail(errs, `L1[${i}] 是心情不是动作: "${body}"`);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** L2: 三轴必须齐全, 且每轴只能落一端。 */
|
|
50
|
+
function checkL2(lines, errs) {
|
|
51
|
+
if (!lines.length) return fail(errs, "L2 为空: 边界没划");
|
|
52
|
+
lines.forEach((l, i) => {
|
|
53
|
+
const m = l.match(/^-\s*((?:\[[^\]]+\])+)\s*(.+)$/);
|
|
54
|
+
if (!m) return fail(errs, `L2[${i}] 缺轴标签: "${l}"`);
|
|
55
|
+
const tags = [...m[1].matchAll(/\[([^\]]+)\]/g)].map((x) => x[1]);
|
|
56
|
+
AXES.forEach((ax) => {
|
|
57
|
+
const ends = ax.split("|");
|
|
58
|
+
const hit = tags.filter((t) => ends.includes(t));
|
|
59
|
+
if (!hit.length) fail(errs, `L2[${i}] 缺轴 [${ax}]: 边界没划全`);
|
|
60
|
+
if (hit.length > 1) fail(errs, `L2[${i}] 轴 [${ax}] 落了两端: 必须只落一端`);
|
|
61
|
+
});
|
|
62
|
+
if (/(两者都有|都算|不确定|看情况)/.test(m[2]))
|
|
63
|
+
fail(errs, `L2[${i}] 边界含糊(写了"两者都有"之类): "${m[2]}"`);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** L3: 必须能指出可验收产出类型, 或明确露怯。 */
|
|
68
|
+
function checkL3(lines, errs) {
|
|
69
|
+
if (!lines.length) return fail(errs, "L3 为空: 既没给产出也没露怯");
|
|
70
|
+
lines.forEach((l, i) => {
|
|
71
|
+
// 露怯分支: 明确说没有可验收标准 —— 合法
|
|
72
|
+
if (/\[无验收标准\]/.test(l)) {
|
|
73
|
+
if (!/给(的)?是参考|不是答案|无法验收/.test(l))
|
|
74
|
+
fail(errs, `L3[${i}] 标了无验收标准但没明说给的是参考: "${l}"`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const m = l.match(/^-\s*\[产出\]\s*(.+)$/);
|
|
78
|
+
if (!m) return fail(errs, `L3[${i}] 缺 [产出] 或 [无验收标准] 标签: "${l}"`);
|
|
79
|
+
if (!OUTPUT_KINDS.some((k) => m[1].includes(k)))
|
|
80
|
+
fail(errs, `L3[${i}] 产出不可验收(不含白名单类型 ${OUTPUT_KINDS.join("/")}): "${m[1]}"`);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** L2 是否逐条能对回 L1 的存活支 —— 需要显式写的 [父:N], 且不得越界。 */
|
|
85
|
+
function checkTrace(lines, l1count, errs) {
|
|
86
|
+
lines.forEach((l, i) => {
|
|
87
|
+
const m = l.match(/\[父:(\d+)\]/);
|
|
88
|
+
if (!m) return fail(errs, `L2[${i}] 缺 [父:N] 标注: 无法证明它来自哪个 L1 支`);
|
|
89
|
+
const n = Number(m[1]);
|
|
90
|
+
if (n >= l1count)
|
|
91
|
+
fail(errs, `L2[${i}] [父:${n}] 越界: L1 只有 ${l1count} 条, 指向不存在的父`);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function check(text) {
|
|
96
|
+
const errs = [];
|
|
97
|
+
const sec = { L1: [], L2: [], L3: [] };
|
|
98
|
+
let cur = null;
|
|
99
|
+
for (const raw of text.split("\n")) {
|
|
100
|
+
const h = raw.match(/^##\s*(L[123])\s*$/);
|
|
101
|
+
if (h) { cur = h[1]; continue; }
|
|
102
|
+
if (cur && raw.trim().startsWith("-")) sec[cur].push(raw.trim());
|
|
103
|
+
}
|
|
104
|
+
checkL1(sec.L1, errs);
|
|
105
|
+
checkL2(sec.L2, errs);
|
|
106
|
+
checkTrace(sec.L2, sec.L1.length, errs);
|
|
107
|
+
checkL3(sec.L3, errs);
|
|
108
|
+
return { ok: errs.length === 0, errors: errs, counts: { L1: sec.L1.length, L2: sec.L2.length, L3: sec.L3.length } };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
112
|
+
const fs = await import("node:fs");
|
|
113
|
+
const p = process.argv[2];
|
|
114
|
+
if (!p) { console.log("用法: node check.js <答案文件.md>"); process.exit(2); }
|
|
115
|
+
const r = check(fs.readFileSync(p, "utf8"));
|
|
116
|
+
console.log(`L1=${r.counts.L1} L2=${r.counts.L2} L3=${r.counts.L3}`);
|
|
117
|
+
if (r.ok) console.log("PASS");
|
|
118
|
+
else { console.log(`FAIL (${r.errors.length})`); r.errors.forEach((e) => console.log(" ✗ " + e)); process.exit(1); }
|
|
119
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { check } from "./check.js";
|
|
4
|
+
|
|
5
|
+
/** 合规样本: 苹果选水果 */
|
|
6
|
+
const GOOD = `
|
|
7
|
+
## L1
|
|
8
|
+
- [动作] 想快速判断买哪个品种
|
|
9
|
+
- [动作] 想决定手上这个吃不吃
|
|
10
|
+
## L2
|
|
11
|
+
- [父:0][主观][当下][判断] 在问口味好不好
|
|
12
|
+
- [父:1][主观][当下][判断] 在问能不能吃
|
|
13
|
+
## L3
|
|
14
|
+
- [产出] 几个口味维度的横向结论
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
test("合规样本 PASS", () => {
|
|
18
|
+
const r = check(GOOD);
|
|
19
|
+
assert.ok(r.ok, r.errors.join("; "));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("L1 空泛失败: '想了解一下'", () => {
|
|
23
|
+
const r = check(`
|
|
24
|
+
## L1
|
|
25
|
+
- [动作] 想了解一下这个技术
|
|
26
|
+
- [动作] 想快速定位原因
|
|
27
|
+
## L2
|
|
28
|
+
- [父:1][主观][当下][判断] x
|
|
29
|
+
## L3
|
|
30
|
+
- [产出] 一个结论
|
|
31
|
+
`);
|
|
32
|
+
assert.ok(!r.ok);
|
|
33
|
+
assert.ok(r.errors.some((e) => /心情不是动作/.test(e)), r.errors.join(";"));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("L1 只有一个候选失败: 无兄弟则无法竞争", () => {
|
|
37
|
+
const r = check(`
|
|
38
|
+
## L1
|
|
39
|
+
- [动作] 想快速定位原因
|
|
40
|
+
## L2
|
|
41
|
+
- [父:0][主观][当下][判断] x
|
|
42
|
+
## L3
|
|
43
|
+
- [产出] 一个结论
|
|
44
|
+
`);
|
|
45
|
+
assert.ok(r.errors.some((e) => /只有 1 个候选/.test(e)), r.errors.join(";"));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("L2 缺轴失败: 边界没划全", () => {
|
|
49
|
+
const r = check(`
|
|
50
|
+
## L1
|
|
51
|
+
- [动作] 想快速定位原因
|
|
52
|
+
- [动作] 想确认改动影响
|
|
53
|
+
## L2
|
|
54
|
+
- [父:0][主观][当下] x
|
|
55
|
+
## L3
|
|
56
|
+
- [产出] 一个结论
|
|
57
|
+
`);
|
|
58
|
+
assert.ok(r.errors.some((e) => /缺轴 \[判断\|操作\]/.test(e)), r.errors.join(";"));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("L2 一轴落两端失败", () => {
|
|
62
|
+
const r = check(`
|
|
63
|
+
## L1
|
|
64
|
+
- [动作] 想快速定位原因
|
|
65
|
+
- [动作] 想确认改动影响
|
|
66
|
+
## L2
|
|
67
|
+
- [父:0][主观][客观][当下][判断] x
|
|
68
|
+
## L3
|
|
69
|
+
- [产出] 一个结论
|
|
70
|
+
`);
|
|
71
|
+
assert.ok(r.errors.some((e) => /落了两端/.test(e)), r.errors.join(";"));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("L2 无父标注失败: 追溯不到来源", () => {
|
|
75
|
+
const r = check(`
|
|
76
|
+
## L1
|
|
77
|
+
- [动作] 想快速定位原因
|
|
78
|
+
- [动作] 想确认改动影响
|
|
79
|
+
## L2
|
|
80
|
+
- [主观][当下][判断] x
|
|
81
|
+
## L3
|
|
82
|
+
- [产出] 一个结论
|
|
83
|
+
`);
|
|
84
|
+
assert.ok(r.errors.some((e) => /缺 \[父:N\]/.test(e)), r.errors.join(";"));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("L3 不可验收失败: '一些建议'", () => {
|
|
88
|
+
const r = check(`
|
|
89
|
+
## L1
|
|
90
|
+
- [动作] 想快速定位原因
|
|
91
|
+
- [动作] 想确认改动影响
|
|
92
|
+
## L2
|
|
93
|
+
- [父:0][主观][当下][判断] x
|
|
94
|
+
## L3
|
|
95
|
+
- [产出] 一些建议
|
|
96
|
+
`);
|
|
97
|
+
assert.ok(r.errors.some((e) => /不可验收/.test(e)), r.errors.join(";"));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("L3 露怯合法: 无验收标准 + 明说是参考", () => {
|
|
101
|
+
const r = check(`
|
|
102
|
+
## L1
|
|
103
|
+
- [动作] 想快速定位原因
|
|
104
|
+
- [动作] 想确认改动影响
|
|
105
|
+
## L2
|
|
106
|
+
- [父:0][主观][当下][判断] x
|
|
107
|
+
## L3
|
|
108
|
+
- [无验收标准] 此题给的是参考, 不是答案
|
|
109
|
+
`);
|
|
110
|
+
assert.ok(r.ok, r.errors.join(";"));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("L3 标了露怯但没说'是参考' -> 失败", () => {
|
|
114
|
+
const r = check(`
|
|
115
|
+
## L1
|
|
116
|
+
- [动作] 想快速定位原因
|
|
117
|
+
- [动作] 想确认改动影响
|
|
118
|
+
## L2
|
|
119
|
+
- [父:0][主观][当下][判断] x
|
|
120
|
+
## L3
|
|
121
|
+
- [无验收标准] 这题很难
|
|
122
|
+
`);
|
|
123
|
+
assert.ok(!r.ok);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 核心用例: 编造动机。
|
|
128
|
+
* 骨架的已知边界是"拦不住 L1 编一个说得通的动机"。
|
|
129
|
+
* 这里试: 一个句子里没有任何线索, 却写了具体动作 —— 检查器能抓到吗?
|
|
130
|
+
*/
|
|
131
|
+
test("边界用例: 编造的动机能通过全部格式检查 —— 这是检查器的能力边界", () => {
|
|
132
|
+
// 用户只说"你觉得呢", 原话里没有任何动作线索。
|
|
133
|
+
// 但编出带动词的动机很容易 —— 动词白名单挡不住编造。
|
|
134
|
+
const fabricated = `
|
|
135
|
+
## L1
|
|
136
|
+
- [动作] 想确认我的判断是否可靠
|
|
137
|
+
- [动作] 想知道我是否认真思考过
|
|
138
|
+
## L2
|
|
139
|
+
- [父:0][主观][当下][判断] 在问结论
|
|
140
|
+
- [父:1][主观][当下][判断] 在问过程
|
|
141
|
+
## L3
|
|
142
|
+
- [产出] 一个明确结论
|
|
143
|
+
`;
|
|
144
|
+
const r = check(fabricated);
|
|
145
|
+
// 断言: 它能通过 —— 这是能力边界, 必须被记录, 不能被假装解决
|
|
146
|
+
assert.ok(r.ok, "格式检查无法发现'动机是编的': " + r.errors.join(";"));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("[父:N] 越界能被发现 (曾漏, 已修)", () => {
|
|
150
|
+
const r = check(`
|
|
151
|
+
## L1
|
|
152
|
+
- [动作] 想快速定位原因
|
|
153
|
+
- [动作] 想确认改动影响
|
|
154
|
+
## L2
|
|
155
|
+
- [父:9][主观][当下][判断] x
|
|
156
|
+
## L3
|
|
157
|
+
- [产出] 一个结论
|
|
158
|
+
`);
|
|
159
|
+
assert.ok(!r.ok, "应发现越界");
|
|
160
|
+
assert.ok(r.errors.some((e) => /越界/.test(e)), r.errors.join(";"));
|
|
161
|
+
});
|
package/src/store.js
CHANGED
|
@@ -4,7 +4,7 @@ import { promises as fs } from 'node:fs';
|
|
|
4
4
|
import { join, resolve, dirname } from 'node:path';
|
|
5
5
|
import { requireBrain, brainPath, absLogDir, BRAIN_DIR } from './index.js';
|
|
6
6
|
import { requireUser, atTag, getUser } from './userconfig.js';
|
|
7
|
-
import { stripStateMark, ensureStateMark, normalizeTodo, addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, setStateMark, TASK_STATES, insertDoneGrouped, idOfTaskLine, archiveDoneInText,
|
|
7
|
+
import { stripStateMark, ensureStateMark, normalizeTodo, addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, setStateMark, TASK_STATES, insertDoneGrouped, idOfTaskLine, archiveDoneInText, upsertArchiveSection, DONE_KINDS, withDoneKind, doneKindOf, doneDateOf, collapseDone, SEC, rebuildStructure } from './todo.js';
|
|
8
8
|
import { editFile, SKIP } from './lock.js';
|
|
9
9
|
import { appendWrapup, strandedFor } from './wrapup.js';
|
|
10
10
|
|
|
@@ -685,16 +685,17 @@ export async function cmdTodoArchive({ dir, keepDays = 3, dryRun = false } = {})
|
|
|
685
685
|
return `[dry-run] 将归档 ${plan.archived.length} 天 / ${plan.count} 条(每天一个文件)\n ${brief}${why}`;
|
|
686
686
|
}
|
|
687
687
|
|
|
688
|
-
// 1)
|
|
689
|
-
//
|
|
688
|
+
// 1) 归档目标 = 当天的会话快照文件(log-<日期>.md),写进其「任务归档」段。
|
|
689
|
+
// 为何合一(2026-09-15 用户定): 归档与会话快照是**同一天的记录**,分两个文件查着要开两处。
|
|
690
|
+
// 边界: ① 文件不存在→建只有归档段的页(那天可能没写快照)
|
|
691
|
+
// ② 文件已存在(含 AI 手写快照)→ 只替换归档段,段外逐字保留
|
|
692
|
+
// (硬规则「已存在的人工内容一律不覆盖」由 upsertArchiveSection 保证)。
|
|
690
693
|
const sessDir = brainPath(root, 'sessions');
|
|
691
694
|
for (const g of plan.archived) {
|
|
692
695
|
const pageP = join(sessDir, `${g.slug}.md`);
|
|
693
696
|
let page = null;
|
|
694
697
|
try { page = await fs.readFile(pageP, 'utf8'); } catch { /* 首次 */ }
|
|
695
|
-
const nextPage = page
|
|
696
|
-
? renderArchivePage({ group: g })
|
|
697
|
-
: page.replace(/\s*$/, '') + '\n\n' + renderArchiveBody([g]) + '\n';
|
|
698
|
+
const nextPage = upsertArchiveSection(page, g);
|
|
698
699
|
const tmp = join(sessDir, `.${g.slug}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`);
|
|
699
700
|
await fs.writeFile(tmp, nextPage, 'utf8');
|
|
700
701
|
await fs.rename(tmp, pageP);
|
|
@@ -707,15 +708,17 @@ export async function cmdTodoArchive({ dir, keepDays = 3, dryRun = false } = {})
|
|
|
707
708
|
});
|
|
708
709
|
|
|
709
710
|
// 3) index 登记(每个日期页一行,幂等)
|
|
711
|
+
// 坑(2026-09-15 修): 原先把归档页登记到 `## Sources` 区 —— 但它们在 sessions/ 里,
|
|
712
|
+
// 旧数据实测就是错位的(09-10/09-12 两条曾键在 Sources 段,人工才发现)。现改登 `## Sessions`。
|
|
710
713
|
const iP = brainPath(root, 'index.md');
|
|
711
714
|
await editFile(iP, (index) => {
|
|
712
715
|
if (!index) return SKIP;
|
|
713
716
|
const missing = plan.archived.filter((g) => !index.includes(`[[${g.slug}]]`));
|
|
714
717
|
if (!missing.length) return SKIP;
|
|
715
|
-
const sIdx = index.indexOf('##
|
|
718
|
+
const sIdx = index.indexOf('## Sessions');
|
|
716
719
|
if (sIdx === -1) return SKIP;
|
|
717
720
|
const after = index.indexOf('\n## ', sIdx + 1);
|
|
718
|
-
const add = missing.map((g) => `- [[${g.slug}]] —
|
|
721
|
+
const add = missing.map((g) => `- [[${g.slug}]] — ${g.date}(会话快照 + 任务归档)`).join('\n');
|
|
719
722
|
const next = after === -1
|
|
720
723
|
? `${index.replace(/\s*$/, '')}\n${add}\n`
|
|
721
724
|
: index.slice(0, after) + `\n${add}` + index.slice(after);
|
|
@@ -775,17 +778,13 @@ export async function cmdTeardownCheck({ dir, payload }) {
|
|
|
775
778
|
await fs.writeFile(mark, stamp).catch(() => {});
|
|
776
779
|
|
|
777
780
|
const msg = [
|
|
778
|
-
// 未设姓名时把设置指令插到第0条 —— 否则后续 todo add/log/note
|
|
779
|
-
// 而收尾提醒本身不提这事,使用者只会看到一连串报错。
|
|
781
|
+
// 未设姓名时把设置指令插到第0条 —— 否则后续 todo add/log/note 全会被守卫拦下。
|
|
780
782
|
(await getUser() ? [] : [
|
|
781
|
-
'0) 本机尚未设置使用者姓名 —— 先跑 abs config set user
|
|
783
|
+
'0) 本机尚未设置使用者姓名 —— 先跑 abs config set user <你的名字>,否则 todo/log/note 都会被拦下;',
|
|
782
784
|
]),
|
|
783
|
-
'[abs
|
|
784
|
-
'
|
|
785
|
-
'
|
|
786
|
-
'3) 值得留的经验 abs note "..."(宁少勿滥,能从代码 grep 到的不记);',
|
|
787
|
-
'4) abs log "完成 X:..." 记一行工作成果,新页同步进 index。',
|
|
788
|
-
'简洁执行,不要复述本条提醒。若本次确实没有可沉淀产出,直接回一句"无可沉淀"即可。',
|
|
785
|
+
'[abs] 本会话改过文件,.brain/ 今日无记录。',
|
|
786
|
+
'这条是信息不是命令:该沉淀就沉淀,没有可沉淀的直接回一句「无可沉淀」,不用凑。',
|
|
787
|
+
'需要时:abs todo / abs todo done <id> / abs note "..." / abs log "..."',
|
|
789
788
|
].join('\n');
|
|
790
789
|
|
|
791
790
|
// Claude Code Stop hook 契约: {"decision":"block","reason":"..."} = 阻止结束并把 reason 回灌给 agent
|
|
@@ -1355,9 +1354,13 @@ export async function cmdLint({ dir }) {
|
|
|
1355
1354
|
issues.push(`DEAD-LINK: ${pg.rel} -> [[${ln}]]`);
|
|
1356
1355
|
}
|
|
1357
1356
|
}
|
|
1358
|
-
// ORPHAN: sources/
|
|
1359
|
-
//
|
|
1360
|
-
|
|
1357
|
+
// ORPHAN: sources/ 暂存页与会话/归档页豁免。前者是暂存线索(提炼成 concept 前天然孤立),
|
|
1358
|
+
// 后者是历史记录(已登记在 index.md,就是图谱入口,无需再制造双链)。
|
|
1359
|
+
// 豁免名单含两种归档命名:旧 `*-todo归档`(存量页仍在)+ 新 `log-*`
|
|
1360
|
+
//(2026-09-15 起归档并入当天快照,见 todo.js 的 upsertArchiveSection)。
|
|
1361
|
+
const isTerminal = pg.dir === 'sources'
|
|
1362
|
+
|| /todo归档$/.test(pg.slug)
|
|
1363
|
+
|| (pg.dir === 'sessions' && /^log-/.test(pg.slug));
|
|
1361
1364
|
if (!isTerminal && !pg.links.length && !linkedNames.has(pg.slug)) {
|
|
1362
1365
|
issues.push(`ORPHAN-PAGE: ${pg.rel} (no links out, no links in)`);
|
|
1363
1366
|
}
|
|
@@ -1367,7 +1370,12 @@ export async function cmdLint({ dir }) {
|
|
|
1367
1370
|
if (['concepts', 'entities', 'syntheses'].includes(pg.dir) && !(inbound.get(pg.slug) || 0)) {
|
|
1368
1371
|
issues.push(`NO-INBOUND: ${pg.rel} (无人链接到本页;在相关页的 ## 关联连接 挂一条 [[${pg.slug}]])`);
|
|
1369
1372
|
}
|
|
1370
|
-
|
|
1373
|
+
// UNRESOLVED-CONFLICT: 有「## 知识冲突」段但还是 draft = 冲突标了没裁决。
|
|
1374
|
+
// 判据必须认【段标题】而非页内出现「知识冲突」字样。
|
|
1375
|
+
// 坑(2026-09-15 实测): 原用裸子串 → codebuddy 的会话快照因任务描述里写了
|
|
1376
|
+
// 「更新 session-key-fingerprint-flaw(知识冲突裁决)」而误报(它并无该段)。
|
|
1377
|
+
// 同 NO-TAIL 的教训: 判据看结构,不看关键词。
|
|
1378
|
+
if (/^#{2,6}[^\n]*知识冲突/m.test(pg.body) && /status: draft/.test(pg.frontmatter)) {
|
|
1371
1379
|
issues.push(`UNRESOLVED-CONFLICT: ${pg.rel}`);
|
|
1372
1380
|
}
|
|
1373
1381
|
if (['concepts', 'entities', 'syntheses'].includes(pg.dir)) {
|
|
@@ -1419,6 +1427,54 @@ export async function cmdLint({ dir }) {
|
|
|
1419
1427
|
}
|
|
1420
1428
|
}
|
|
1421
1429
|
|
|
1430
|
+
// SESSIONS-NAMING: sessions/ 的一天一文件契约(见 skill 的「sessions/ 命名契约」)。
|
|
1431
|
+
// 实测 codebuddy 乱局(2026-09-15):一天最多出现 5 个文件、5 种 tags、同天两个快照。
|
|
1432
|
+
// 判据纯机械:按“日期前缀”归组——同天 >1 个文件报 SPLIT;单个但非规范名报 NAMING。
|
|
1433
|
+
// 只看文件名,不猜内容。
|
|
1434
|
+
const sessByDate = new Map();
|
|
1435
|
+
for (const pg of pages) {
|
|
1436
|
+
if (pg.dir !== 'sessions') continue;
|
|
1437
|
+
// 同时认两种写法:规范名 `log-<日期>` 与旧/杂命名 `<日期>-…`。
|
|
1438
|
+
// 坑(写测试时抓到的): 首版只写 `^(\d{4}-…)` → **匹配不上规范名 `log-2026-09-07`**,
|
|
1439
|
+
// 于是「一个 log- + 一个旧杂文件」被数成 1 个而非 2 个,漏报。
|
|
1440
|
+
const m = pg.slug.match(/^(?:log-)?(\d{4}-\d{2}-\d{2})/);
|
|
1441
|
+
if (!m) continue;
|
|
1442
|
+
// 长期存续的归档页豁免:**只认独立的 `archive` 标签**(如 `tags: [session-log, archive]`),
|
|
1443
|
+
// 不认 `todo-archive`(那是旧归档页的标签,它正是要迁移的对象)。
|
|
1444
|
+
// 坑(2026-09-15 在 ~/Docker 实测抓到): 首版用 `\barchive\b` —— 而 `todo-archive`
|
|
1445
|
+
// 里 `-` 与 `a` 之间也是词边界 → **老式归档页全被豁免**,一个都不报(漏报四天)。
|
|
1446
|
+
// 豁免是为「外部产物全文归档」(如仓库 todo.md 全文)设的,不是为旧命名归档页。
|
|
1447
|
+
const tags = String(pg.frontmatter).match(/^tags:\s*(.+)$/m)?.[1] || '';
|
|
1448
|
+
const tagList = tags.replace(/^\[|\]$/g, '').split(',').map((t) => t.trim());
|
|
1449
|
+
if (tagList.includes('archive')) continue;
|
|
1450
|
+
if (!sessByDate.has(m[1])) sessByDate.set(m[1], []);
|
|
1451
|
+
sessByDate.get(m[1]).push(pg.slug);
|
|
1452
|
+
}
|
|
1453
|
+
for (const [date, slugs] of sessByDate) {
|
|
1454
|
+
if (slugs.length > 1) {
|
|
1455
|
+
issues.push(`SESSIONS-SPLIT: ${date} 在 sessions/ 有 ${slugs.length} 个文件(${slugs.join('、')});` +
|
|
1456
|
+
`一天只应有一个 \`log-${date}.md\`:归档写进其「## 📦 任务归档」段,多主题写成多个 ## 子段`);
|
|
1457
|
+
} else if (!slugs[0].startsWith('log-')) {
|
|
1458
|
+
// 单个文件但**不是规范名** —— 旧命名(`<日期>-todo归档.md` 等)单独存留。
|
|
1459
|
+
// 坑(2026-09-15 在 ~/Docker 实测抓到): 首版只看“同天 >1 个” → 4 个日期
|
|
1460
|
+
// 各只有一份 `<日期>-todo归档.md` → 一个都不报(漏报)。
|
|
1461
|
+
// 旧命名的页无论是否孤单都该改:跑 `abs todo archive` 后并入 `log-<日期>.md`。
|
|
1462
|
+
issues.push(`SESSIONS-NAMING: ${date} 的文件 \`${slugs[0]}.md\` 不是规范名;` +
|
|
1463
|
+
`应为 \`log-${date}.md\`(跑 abs todo archive 会并入;旧归档页可删)`);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
// SESSIONS-MISPLACED: sessions/ 里放了 tags 既非 session-log / todo-archive / archive 的页。
|
|
1467
|
+
// 实例:codebuddy 的 `2026-09-07-ui-fixes.md`(tags: [source,session-log])等 5 页 ——
|
|
1468
|
+
// 当时做的一组工作不是「暂存线索」,而就是当天的快照正文。
|
|
1469
|
+
for (const pg of pages) {
|
|
1470
|
+
if (pg.dir !== 'sessions') continue;
|
|
1471
|
+
const tags = String(pg.frontmatter).match(/^tags:\s*(.+)$/m)?.[1] || '';
|
|
1472
|
+
if (!/session-log|todo-archive|archive/.test(tags)) {
|
|
1473
|
+
issues.push(`SESSIONS-MISPLACED: ${pg.rel}(tags: ${tags.trim()} 不属 sessions/;` +
|
|
1474
|
+
`当天工作写进 \`log-<日期>.md\` 正文,暂存线索用 \`abs note\` 落 sources/)`);
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1422
1478
|
// SUPERSEDED-DANGLING: superseded 页声明的取代者也必须存在。
|
|
1423
1479
|
// 它跟 DEAD-LINK 同性质(指向不存在的页),但后果更重:
|
|
1424
1480
|
// 读者被引导去找一个不存在的"新版本",比单纯断链更容易让人以为"没新页就是没替代"。
|
package/src/todo.js
CHANGED
|
@@ -426,13 +426,13 @@ function isUndoneLine(l) {
|
|
|
426
426
|
* 规则(用户定):
|
|
427
427
|
* ① 只保留近 keepDays 天(含今天);更早的才归档。
|
|
428
428
|
* ② 某一天只要还有未完成(- [ ])任务,**整天都不归档**(不拆半天)。
|
|
429
|
-
* ③
|
|
430
|
-
*
|
|
431
|
-
* `- [[
|
|
429
|
+
* ③ 每**天**一个文件,slug 为 `log-<日期>`(由 slugFor 给)—— 即**归进那天的会话快照**
|
|
430
|
+
* (同一天的东西放一处,别为归档另建文件)。本函数只负责从 todo 文本里移除
|
|
431
|
+
* + 在 Done 区尾部的 `### 归档` 区**每天记一行** `- [[log-<日期>]] 完成任务 N 条`。
|
|
432
432
|
* 无日期组(### (未标日期))无法判天数,**保守不归档**。
|
|
433
433
|
* @returns {{text:string, archived:{date:string,lines:string[],slug:string,count:number}[], skipped:{date:string,reason:string}[], count:number}}
|
|
434
434
|
*/
|
|
435
|
-
export function archiveDoneInText(text, { keepDays = 3, from = today(), slugFor = (d) =>
|
|
435
|
+
export function archiveDoneInText(text, { keepDays = 3, from = today(), slugFor = (d) => `log-${d}` } = {}) {
|
|
436
436
|
const lines = String(text || '').split('\n');
|
|
437
437
|
const di = lines.findIndex((l) => l.startsWith('## Done'));
|
|
438
438
|
if (di === -1) return { text, archived: [], skipped: [], count: 0 };
|
|
@@ -484,31 +484,69 @@ export function archiveDoneInText(text, { keepDays = 3, from = today(), slugFor
|
|
|
484
484
|
return { text: rebuilt.join('\n').replace(/\n+$/, '\n'), archived, skipped, count };
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
-
/**
|
|
487
|
+
/** 归档页正文(按日期分组,原文保留)。供同日追写复用。 */
|
|
488
488
|
export function renderArchiveBody(groups) {
|
|
489
489
|
const out = [];
|
|
490
490
|
for (const g of groups) out.push(`### ${g.date}`, '', ...g.lines, '');
|
|
491
491
|
return out.join('\n').replace(/\n+$/, '\n');
|
|
492
492
|
}
|
|
493
493
|
|
|
494
|
-
/**
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
494
|
+
/** 归档区标题。归档内容全部落在这个二级标题下,**与 AI 手写的会话快照共存**:
|
|
495
|
+
* 同一天的记录(快照 + 任务明细)放一个文件里,而不是分两个文件。
|
|
496
|
+
* 写入侧只动这一段:标题之前的内容原样保留(已存在的人工内容一律不覆盖)。 */
|
|
497
|
+
export const ARCHIVE_SECTION = '## 📦 任务归档';
|
|
498
|
+
|
|
499
|
+
/** 把「任务归档」段合并进已有正文。**纯函数**(不碰磁盘)。
|
|
500
|
+
*
|
|
501
|
+
* 为何不再「每天一个文件」(2026-09-15 用户定):
|
|
502
|
+
* 归档页与 `log-<日期>.md` 会话快照是**同一天的记录**,分两处查着要开两个文件。
|
|
503
|
+
* 改为:归档写进当天快照的 `ARCHIVE_SECTION` 段。
|
|
504
|
+
*
|
|
505
|
+
* 两个边界(都有测试钉住):
|
|
506
|
+
* ① 文件不存在 → 建一个**只有归档段**的页(那天可能没写快照,仍只落这一个文件)。
|
|
507
|
+
* ② 文件已存在(含 AI 手写的快照)→ **只替换归档段**,段外内容逐字保留。
|
|
508
|
+
* 同日重复归档(罕见)则把新日期组接在段内已有内容之后,不重复建段。
|
|
509
|
+
*
|
|
510
|
+
* @param body 现有全文(null = 文件不存在)
|
|
511
|
+
* @param group { date, lines, count }
|
|
512
|
+
*/
|
|
513
|
+
export function upsertArchiveSection(body, group) {
|
|
514
|
+
const chunk = renderArchiveBody([group]);
|
|
515
|
+
if (body == null) {
|
|
516
|
+
// ① 新文件:只有归档段(无手写快照也合法 —— 那天本来就只有任务记录)
|
|
517
|
+
const fm = [
|
|
518
|
+
'---',
|
|
519
|
+
'tags: [session-log, todo-archive, 历史]',
|
|
520
|
+
`updated: ${group.date}`,
|
|
521
|
+
'status: reviewed',
|
|
522
|
+
'---',
|
|
523
|
+
'',
|
|
524
|
+
`# ${group.date} 记录`,
|
|
525
|
+
'',
|
|
526
|
+
'> 本页 = 该日的会话快照(若有)+ 从 `todo.md` Done 区迁出的任务明细。',
|
|
527
|
+
'',
|
|
528
|
+
ARCHIVE_SECTION,
|
|
529
|
+
'',
|
|
530
|
+
chunk.trimEnd(),
|
|
531
|
+
'',
|
|
532
|
+
];
|
|
533
|
+
return fm.join('\n');
|
|
534
|
+
}
|
|
535
|
+
// ② 已存在:只动归档段,段外原样
|
|
536
|
+
const lines = body.split('\n');
|
|
537
|
+
const i = lines.findIndex((l) => l.trim() === ARCHIVE_SECTION);
|
|
538
|
+
if (i === -1) {
|
|
539
|
+
// 有文件但还没归档段 → 追加到末尾(不搅动已有内容)
|
|
540
|
+
return `${body.replace(/\s*$/, '')}\n\n${ARCHIVE_SECTION}\n\n${chunk.trimEnd()}\n`;
|
|
541
|
+
}
|
|
542
|
+
// 找到本段结束(下一个同级或更高级标题)
|
|
543
|
+
let j = i + 1;
|
|
544
|
+
while (j < lines.length && !/^#{1,2} \S/.test(lines[j].trim())) j++;
|
|
545
|
+
const head = lines.slice(0, i + 1);
|
|
546
|
+
const tailPart = lines.slice(j);
|
|
547
|
+
const existingInner = lines.slice(i + 1, j).join('\n').trim();
|
|
548
|
+
const merged = existingInner ? `${existingInner}\n\n${chunk.trimEnd()}` : chunk.trimEnd();
|
|
549
|
+
return [...head, '', merged, '', ...tailPart].join('\n').replace(/\n{3,}/g, '\n\n');
|
|
512
550
|
}
|
|
513
551
|
|
|
514
552
|
/** 幂等:把 todo 全文里平铺的旧 Done 区按日期分组(新日期在前,未标日期归尾)。
|