@fanchao8609/agent_brain_sync 1.7.4 → 1.7.6
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/bin/abs.js +12 -1
- package/bin/mcp.js +16 -1
- package/package.json +1 -1
- package/skill/SKILL.md +135 -157
- package/src/store.js +236 -23
- package/src/todo.js +121 -14
package/bin/abs.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// bin/abs.js — abs CLI 入口。
|
|
3
3
|
// abs <cmd> [args]
|
|
4
4
|
// 命令: init / board / status / load / task / install / uninstall / help
|
|
5
|
-
import { cmdInit, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdShow, cmdRepair, cmdWrapup, cmdTeardownCheck, cmdTodoArchive } from '../src/store.js';
|
|
5
|
+
import { cmdInit, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdShow, cmdRepair, cmdWrapup, cmdRule, cmdTeardownCheck, cmdTodoArchive } from '../src/store.js';
|
|
6
6
|
import { setUser, getUser, userConfigPath } from '../src/userconfig.js';
|
|
7
7
|
import { runInstall, runUninstall } from '../src/install.js';
|
|
8
8
|
import { readFileSync } from 'node:fs';
|
|
@@ -186,6 +186,8 @@ const usage = `abs — agent-brain-sync 记忆工具
|
|
|
186
186
|
归档 Done 区旧日期组 → sessions/<日期>-todo归档.md
|
|
187
187
|
(默认保留近 3 天; 任一天有未完成则整天不归档)
|
|
188
188
|
abs lint 图谱体检 (死链/孤岛/超尺寸/堆积)
|
|
189
|
+
abs rule 列出 index.md 的 ## Rules 硬规则
|
|
190
|
+
abs rule add "一句话" 追加一条硬规则 (只放违反会丢数据/静默失效级的;展开写概念页)
|
|
189
191
|
abs config [show] 查看使用者姓名 (标记作者用)
|
|
190
192
|
abs config set user <名字> 设置使用者姓名 → ~/.abs/config.json
|
|
191
193
|
未设置时写操作(todo/log/note)会报错要求先设置
|
|
@@ -386,6 +388,15 @@ async function main() {
|
|
|
386
388
|
console.log(await cmdConfig({ sub: opts._[0], value: opts._.slice(1).join(' ') }));
|
|
387
389
|
break;
|
|
388
390
|
}
|
|
391
|
+
case 'rule': {
|
|
392
|
+
// abs rule 列出
|
|
393
|
+
// abs rule add "..." 追加一条
|
|
394
|
+
const [sub, ...rest3] = opts._;
|
|
395
|
+
const isAdd = sub === 'add';
|
|
396
|
+
if (!isAdd && sub) throw new Error(`✗ 未知子命令 "${sub}"\n 用法: abs rule / abs rule add "一句话"`);
|
|
397
|
+
console.log(await cmdRule({ dir: opts.dir, action: isAdd ? 'add' : 'list', text: rest3.join(' ') }));
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
389
400
|
case 'query': {
|
|
390
401
|
console.log(await cmdQuery({ dir: opts.dir, terms: opts._ }));
|
|
391
402
|
break;
|
package/bin/mcp.js
CHANGED
|
@@ -12,7 +12,7 @@ import { readFileSync } from 'node:fs';
|
|
|
12
12
|
import { join, dirname } from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { findBrainRoot, absLogDir } from '../src/index.js';
|
|
15
|
-
import { cmdBoard, cmdLoad, cmdStatus, cmdTask, cmdQuery, cmdLint, cmdNote, cmdWrapup } from '../src/store.js';
|
|
15
|
+
import { cmdBoard, cmdLoad, cmdStatus, cmdTask, cmdQuery, cmdLint, cmdNote, cmdWrapup, cmdRule } from '../src/store.js';
|
|
16
16
|
|
|
17
17
|
// ---------- 技术日志: MCP 请求跟踪(调试用, 与图谱 log.md 完全分开) ----------
|
|
18
18
|
// 落 ~/.abs/log/mcp.log: 每次工具调用一行 [时间] tool cwd 参数摘要 → 耗时/结果摘要。
|
|
@@ -142,6 +142,21 @@ tool(
|
|
|
142
142
|
}
|
|
143
143
|
);
|
|
144
144
|
|
|
145
|
+
tool(
|
|
146
|
+
'abs_rule',
|
|
147
|
+
'读/写 index.md 的 ## Rules 硬规则区。action=list 列出;action=add 追加一条(只放一句话 + [[链接]],展开写概念页)。',
|
|
148
|
+
{
|
|
149
|
+
cwd: z.string().describe('项目根目录(.brain/ 所在处)'),
|
|
150
|
+
action: z.enum(['list', 'add']).optional().describe('list=列出(默认);add=追加一条'),
|
|
151
|
+
text: z.string().optional().describe('action=add 时的一句话硬规则'),
|
|
152
|
+
},
|
|
153
|
+
async ({ cwd, action, text }) => {
|
|
154
|
+
const root = await findBrainRoot(cwd || process.cwd());
|
|
155
|
+
if (!root) return err('未找到 .brain/,先 abs init');
|
|
156
|
+
return { content: [{ type: 'text', text: await cmdRule({ dir: root, action: action || 'list', text }) }] };
|
|
157
|
+
}
|
|
158
|
+
);
|
|
159
|
+
|
|
145
160
|
tool(
|
|
146
161
|
'abs_lint',
|
|
147
162
|
'图谱体检:死链/孤岛/缺 frontmatter/超尺寸/sources 堆积/index 漏列。',
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -11,6 +11,36 @@ Obsidian 可直接打开的 Markdown 图谱(`.brain/`)做统一落点。
|
|
|
11
11
|
**骨架/任务/暂存/检索/体检走 abs 工具(不手工建骨架、不手工登记任务);深提炼(把暂存经验
|
|
12
12
|
写成 concept/entity 页)必须手工——那是判断力,abs 不替你判断什么值得沉淀。**
|
|
13
13
|
|
|
14
|
+
## CLI 命令速查(`abs`,完整帮助:`abs help`)
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# 读
|
|
18
|
+
abs load # 开机读状态(Roadmap + Rules + todo + 最新 log)
|
|
19
|
+
abs todo # 看板(Done 折成计数;明细 abs todo --full)
|
|
20
|
+
abs index / abs log # 完整 index.md / log.md
|
|
21
|
+
abs status # 当前项目 + 图谱概要
|
|
22
|
+
abs query <词1> [词2 …] # 检索 .brain/ 知识页(多词 OR)
|
|
23
|
+
abs lint # 图谱体检(死链/孤岛/超限/堆积/Rules 超限)
|
|
24
|
+
|
|
25
|
+
# 写
|
|
26
|
+
abs todo add <id> --note "做什么" # 登记任务(start 同义)
|
|
27
|
+
abs todo note <id> --note "断点/进度" # 实时落 ↳ 断点 行
|
|
28
|
+
abs todo blocked <id> --note "卡点原因" # 移入 Blocked
|
|
29
|
+
abs todo done <id> [--as 落地|否决|仅方案] # 完成(默认 落地)
|
|
30
|
+
abs log "完成 X:…" # 记一行工作成果(无参=查看)
|
|
31
|
+
abs note "经验一句话" [--tags 坑,docker] # 经验实时暂存 → sources/
|
|
32
|
+
abs rule [add "一句话"] # 读写 index.md 的 ## Rules 硬规则
|
|
33
|
+
|
|
34
|
+
# 维护
|
|
35
|
+
abs todo archive [--keep-days N] [--dry-run] # 归档 Done 旧日期组 → sessions/
|
|
36
|
+
abs init [--repair] # 建图谱;--repair 只补缺不覆盖
|
|
37
|
+
abs config [set user <名字>] # 使用者姓名(写操作需先设)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
> **agent 读写优先走 MCP**(`abs_load`/`abs_task`/`abs_note`/`abs_query`/`abs_lint`/
|
|
41
|
+
> `abs_rule`)—— 常驻 ~2ms,比 bash 跑 CLI(每次起 node 进程 27ms)快一个量级。
|
|
42
|
+
> CLI 留给「人手动查看」。`abs wrapup` / `abs teardown-check` 是 hook 内部命令,不需手动调。
|
|
43
|
+
|
|
14
44
|
## 触发总入口(每次命中技能,第一步先走这里)
|
|
15
45
|
|
|
16
46
|
技能被触发(用户问话、开新会话、或说 `abs ...`)时,**第一步永远是下面这条链**,
|
|
@@ -57,100 +87,82 @@ Obsidian 可直接打开的 Markdown 图谱(`.brain/`)做统一落点。
|
|
|
57
87
|
|
|
58
88
|
> 为什么:方案先过目能省掉整轮返工;登记让跨会话可续;"问开工"把决定权留在用户手里。
|
|
59
89
|
> **这不是拖延** —— 总结方案本身就是工作,做完再问。
|
|
90
|
+
## 图谱定位
|
|
60
91
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
**未设置姓名则报错并给设置命令**,不默默落盘无名条目。
|
|
65
|
-
|
|
66
|
-
```bash
|
|
67
|
-
abs config set user <你的名字> # 写入 ~/.abs/config.json,一次即可
|
|
68
|
-
abs config # 查看当前姓名
|
|
69
|
-
ABS_USER=<名字> abs todo add ... # 临时覆盖(CI/多身份),不改落盘配置
|
|
70
|
-
```
|
|
92
|
+
`.brain/` 放**项目根**,一个项目一份。所有命令只认**当前目录**的 `.brain/`(在项目根运行,不传路径)。
|
|
93
|
+
**不向子目录归属,也不向上搜索**(上爬会命中 `~/.brain`,把无关项目静默挂错)。宁可报错也不猜。
|
|
94
|
+
`abs status` 显示当前定位。
|
|
71
95
|
|
|
72
|
-
|
|
73
|
-
- `todo.md`:`- [ ] TASK-ID [[fanchao]] — 说明 (认领 2026-09-12)`(作者紧跟 id,扫板先看到人)
|
|
74
|
-
- `log.md`:`## [2026-09-12 13:17] [[fanchao]] dev | 完成 X`(作者前置于 kind)
|
|
75
|
-
- `sources/`:frontmatter `author: fanchao`
|
|
96
|
+
## `.brain/` 怎么组织(每个文件/分区做什么、怎么用)
|
|
76
97
|
|
|
77
|
-
|
|
78
|
-
并登记进 `index.md` 的 Entities 区。**已存在则一律不动** —— 里面的内容是人工沉淀的,机器不许覆盖。
|
|
98
|
+
骨架由 `abs init` 生成(`--repair` 只补缺不覆盖)。
|
|
79
99
|
|
|
80
|
-
|
|
81
|
-
> 写进 `entities/<name>.md`。这页是可积累的画像,不是一次性标签。
|
|
100
|
+
### `index.md` —— 图谱入口
|
|
82
101
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
> 历史条目**不回填**:旧行的裸 `@name` 只在原位更新时按原形态保留,不批量改写(原文/现场已不在,
|
|
89
|
-
> 回填等于编造)。
|
|
90
|
-
|
|
91
|
-
## 图谱定位(一个项目一个 `.brain/`,abs 自动定位不用手工指定路径)
|
|
92
|
-
|
|
93
|
-
`.brain/` 放项目根,一个项目只建一份。所有 `abs` 命令(`abs load/todo/note/task/query/lint...`)
|
|
94
|
-
**只认当前目录的 `.brain/`**——必须在项目根目录(即 `.brain/` 所在处)运行,
|
|
95
|
-
不用传路径。
|
|
96
|
-
|
|
97
|
-
**不在子目录自动归属,也不向上搜索**。原因是上爬会命中家目录 `~/.brain`
|
|
98
|
-
(vault / 临时目录 / 任意路径都可能爬到),把无关项目静默挂到别人图谱上。
|
|
99
|
-
宁可报错也不猜。多项目各自独立,各自 `cd` 到自己的根再跑。
|
|
102
|
+
| 分区 | 放什么 | 怎么用 |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| `## Roadmap` | 方向:已落地 / 下一阶段候选 | **写方向不写版本号**(复述第三方状态必然漂移)。有界的,load 原样展示 |
|
|
105
|
+
| `## Rules` | 本项目铁律 | 见下 |
|
|
106
|
+
| `## Concepts` `## Entities` `## Sources` `## Syntheses` `## Sessions` | 各类页的清单 | 每页一行 `- [[slug]] — 一句话`(`abs note`/建归档页会自动登记),load 里折成计数 |
|
|
100
107
|
|
|
101
|
-
|
|
108
|
+
**`## Rules` 区**:铁律清单,`abs load` 每次都全量读(代码里明确不折它)。
|
|
109
|
+
- 一句一条;有概念页就用 `[[链接]]` 指过去,**不在此展开**。
|
|
110
|
+
- 只有「违反会丢数据 / 静默失效 / 白干活」级才进 —— 普通经验进 `concepts/`。
|
|
111
|
+
- 读写:`abs rule` / `abs rule add "一句话"`(>120 字符被拒);`abs lint` 超 30 条会报。
|
|
102
112
|
|
|
103
|
-
|
|
113
|
+
### `log.md` —— 工作成果流水
|
|
104
114
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
├── index.md # 总索引 + 当前路线(Roadmap)。入口。
|
|
108
|
-
├── log.md # 工作成果流水:完成 X 的一行摘要,倒序。不写工具动作。
|
|
109
|
-
├── todo.md # 动态看板:进行中/待办/阻塞/已完成。进度唯一真源。
|
|
110
|
-
├── entities/ # 实体页:一个"具名事物"一页。
|
|
111
|
-
├── concepts/ # 概念页:一个"可复用规律/坑"一页。
|
|
112
|
-
├── sources/ # 暂存页:实时经验(abs note)落点。提炼完即归档/删。
|
|
113
|
-
├── syntheses/ # 综合页:跨实体横向判断/选型/路线。
|
|
114
|
-
└── sessions/ # 会话快照 + Next Session Hook。
|
|
115
|
-
```
|
|
115
|
+
倒序一行摘要(`abs log "完成 X:…"`)。**只记成果,不收工具动作流水**(那在 `~/.abs/log/`)。
|
|
116
|
+
load 只展示最新 5 条、每条按语义边界收口。
|
|
116
117
|
|
|
117
|
-
|
|
118
|
-
`abs init --repair` 只补缺、不覆盖已有文件。
|
|
118
|
+
### `todo.md` —— 活看板(进度唯一真源)
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
| 分区 | 放什么 |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `## Backlog` | 想做但没开工 |
|
|
123
|
+
| `## Today / In Progress` | 正在做 |
|
|
124
|
+
| `## Blocked` | 卡住(附原因,`abs todo blocked`) |
|
|
125
|
+
| `## Done` | 已完成,**必须带结语 `【落地/否决/仅方案】` + `(完成 YYYY-MM-DD)`** |
|
|
121
126
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
| `concepts/` | 可复用的**规律/坑**:`docker-prisma-429` | kebab-case |
|
|
126
|
-
| `sources/` | 实时经验**暂存**(`abs note` 自动落这里) | `YYYY-MM-DD-slug.md` |
|
|
127
|
-
| `syntheses/` | **横向综合**:选型、架构取舍 | `synthesis-slug.md` |
|
|
127
|
+
`abs todo done <id>` 会勾选并归位到 Done 的日期组顶部,断点(`↳` 行)随迁;
|
|
128
|
+
Done 区由 `abs wrapup` 在会话结束时自动把「超 3 天且整天都已完成」的组迁到 `sessions/<日期>-todo归档.md`
|
|
129
|
+
(任一天有未完成则整天不迁)。**什么时候动它见「进行中」一节。**
|
|
128
130
|
|
|
129
|
-
|
|
131
|
+
### `entities/` `concepts/` `sources/` `syntheses/` `sessions/`
|
|
130
132
|
|
|
131
|
-
|
|
133
|
+
| 目录 | 放什么 | 命名 |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `entities/` | 具名的**事物**(能说"它是什么") | `Docker.md` |
|
|
136
|
+
| `concepts/` | 可复用的**规律/坑**(能说"这么做就避坑") | `docker-prisma-429.md` |
|
|
137
|
+
| `sources/` | 实时经验**暂存**(`abs note` 自动落) | `YYYY-MM-DD-slug.md` |
|
|
138
|
+
| `syntheses/` | **横向**选型/架构取舍(跨多个 entity/concept 的判断) | `synthesis-slug.md` |
|
|
139
|
+
| `sessions/` | 会话快照 + `## 🪝 Next Session Hook`、todo 归档页 | `log-YYYY-MM-DD.md` |
|
|
132
140
|
|
|
133
|
-
|
|
141
|
+
归类拿不准时**默认 `concepts/`**。
|
|
134
142
|
|
|
135
|
-
|
|
136
|
-
能从代码 grep 读出的细节一律不记。
|
|
137
|
-
2. **单页硬上限**:`entities/ concepts/ syntheses/` 单页 <150 行/<5KB。超了拆或外链。
|
|
138
|
-
3. **sources 是暂存不是存档**:提炼成规律后删/归档 source(同步清引用,防死链)。
|
|
139
|
-
4. **写前压缩三问**:规律还是噪音?不记会怎样?能不能用一行链接已有页代替新增整页?
|
|
143
|
+
### 容量纪律(写任何页之前过四关)
|
|
140
144
|
|
|
141
|
-
|
|
145
|
+
图谱贵在**精**不在全,不过关就不写或压缩:
|
|
146
|
+
1. **再命中**:下会话不知道这条会踩同坑/重做同决定?会→存,不会→不存。代码能 grep 到的一律不记。
|
|
147
|
+
2. **单页上限**:`entities/concepts/syntheses` 单页 <150 行且 <8KB,超了拆或外链。
|
|
148
|
+
3. **sources 是暂存**:提炼成规律后删/归档,并清掉指向它的引用(防死链)。
|
|
149
|
+
4. **能不能用一行链接代替新增整页**?
|
|
142
150
|
|
|
151
|
+
`abs lint` 兜底:死链/孤岛/缺 frontmatter/超限/sources 堆积/index 漏列/Rules 超限。
|
|
143
152
|
## 开场:Init Sync(开工 / 默认续 todo)
|
|
144
153
|
|
|
145
154
|
图谱已存在;收到第一个核心开发指令**之前**走这条链载入上下文:
|
|
146
155
|
|
|
147
|
-
1. **读状态**:`abs load`(或 MCP `abs_load`)读 index 路线 + todo 看板 + 最近 log。
|
|
156
|
+
1. **读状态**:`abs load`(或 MCP `abs_load`)读 index 路线 + Rules + todo 看板 + 最近 log。
|
|
157
|
+
> **开工前先看 `## Rules`** —— 那是本项目踩过坑后定下的硬规则,每条都是曾经付过代价的。
|
|
158
|
+
> 违反的代价一般是丢数据/静默失效/白干活,而它就在 load 输出里,没有理由不看。
|
|
159
|
+
>
|
|
148
160
|
> **load 输出是折叠过的,不是全量。** 两个无上限增长的区块在读取侧收口:
|
|
149
161
|
> - **Done 区** → 按日期计数(曾占 load 输出 68.8%,长历史项目上单次 load 吃掉 40% 上下文)
|
|
150
162
|
> - **index 页面清单** → 各分区只给页数(concept 清单占 load 输出 64%,隨图谱线性增长)
|
|
151
163
|
> - 「最近动作」每条按语义边界收口到 220 字符
|
|
152
164
|
>
|
|
153
|
-
>
|
|
165
|
+
> **路线(Roadmap) 与 Rules 两区原样保留** —— 那是 load 要传达的状态本身(代码里明确不折)。
|
|
154
166
|
> 要全量明细:`abs todo --full` / `abs index`,或直接读 `.brain/` 文件、
|
|
155
167
|
> `.brain/sessions/<日期>-todo归档.md`。
|
|
156
168
|
2. **对账滞留(强制,别跳过)**:若 `abs load` 顶部出现 `⏳ 上会话滞留`,说明上会话有任务做完/做到一半就断了。**先收尾再开工**:
|
|
@@ -158,68 +170,44 @@ monorepo 若多个子包各自独立交付,可各建一份 `.brain/`。`abs st
|
|
|
158
170
|
- 还没做完 → `abs todo note <id> --note "接到哪/改到哪个文件"` 补断点(别空手续接)。
|
|
159
171
|
滞留没清完就不算接上了状态——这是「任务做完没进 Done」的根治动作。
|
|
160
172
|
3. **读命中页**:按关键词在 index 定位 → 读对应 concepts/entities 全文。
|
|
161
|
-
> ⚠️ **绝不通读 `.brain
|
|
162
|
-
>
|
|
163
|
-
>
|
|
164
|
-
> - 要某页 → 只读那一页;页太长则只取相关小节
|
|
165
|
-
> - 需要汇总多页/多命令时,用 context-mode 的 `ctx_execute` 类工具在沙箱里处理后
|
|
166
|
-
> **只打印结论**,别把原始文件内容拉进上下文。
|
|
173
|
+
> ⚠️ **绝不通读 `.brain/`**(29 页就约 11 万 token,全读塞满窗口)。
|
|
174
|
+
> 要状态→`abs load`;要主题→`abs query <词>`(只回命中几页);要某页→只读那页。
|
|
175
|
+
> 汇总多文件时用 `ctx_execute` 类工具在沙箱里处理,**只打印结论**。
|
|
167
176
|
4. **续 todo**:默认续 todo 分支 → 把顶部未完成项当当前任务开做。
|
|
168
|
-
5. **登记新任务**:有明确新任务而 todo 没有 → `abs todo add <id> --note 做什么`
|
|
169
|
-
再动工。不登记,会话一切断就丢。
|
|
170
|
-
|
|
171
|
-
## 进行中:todo 是活看板 + 经验实时落(最重要的纪律)
|
|
177
|
+
5. **登记新任务**:有明确新任务而 todo 没有 → `abs todo add <id> --note 做什么` 再动工。
|
|
172
178
|
|
|
173
|
-
|
|
174
|
-
同一个反射,别等收尾。用工具(MCP `abs_task` / CLI `abs todo`):
|
|
179
|
+
## 进行中:什么时候动它(最重要的节)
|
|
175
180
|
|
|
176
|
-
|
|
177
|
-
> CLI 单次 27ms(其中 20ms 是每次起 node 进程的固定开销,读写本身仅 3ms)。
|
|
178
|
-
> 一轮发多条命令时差距明显。CLI 留给「人手动查看看板」,agent 读写走 MCP。
|
|
179
|
-
> 详见 [[perf-fixed-overhead]]。
|
|
181
|
+
**todo 不是收尾仪式,是随改随写的活看板。** 每个任务边界立即更新,与 git commit 同反射。
|
|
180
182
|
|
|
181
183
|
| 时机 | 动作 |
|
|
182
184
|
|---|---|
|
|
183
185
|
| 认领新任务 | `abs todo add <id> --note "做什么"` |
|
|
184
|
-
| 子任务做完 | `abs todo done <id
|
|
185
|
-
| 碰壁/阻塞 | `abs todo blocked <id> --note "卡点原因"
|
|
186
|
-
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
>
|
|
195
|
-
>
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
>
|
|
202
|
-
>
|
|
203
|
-
|
|
204
|
-
**经验/坑刚冒出来就落**:`abs note "一句话经验" --tags 坑,docker`(MCP `abs_note`)——
|
|
205
|
-
暂存进 sources/(幂等去重、自动进 index/log),防 context 爆/截断流失。宁少勿滥。
|
|
206
|
-
|
|
207
|
-
## 每轮结束:收尾循环(Stop/告一段落后必做)
|
|
208
|
-
|
|
209
|
-
**每个任务边界、每轮被 Stop/打断、告一段落时,别停半空——走收尾循环。**
|
|
210
|
-
这是"开场接上状态、结束落回状态"的闭环,否则下会话接不上、经验流失。
|
|
211
|
-
|
|
212
|
-
> 触发信号:Stop/会话结束 时 hook 会把「当前项目仍未完成任务 + 断点」快照进 `~/.abs/log/wrapup.log`
|
|
213
|
-
> (经 `abs wrapup`,机械、幂等去重,不替你做判断)。**下会话 `abs load` 会自动把滞留顶到顶部**
|
|
214
|
-
> (`⏳ 上会话滞留`),所以收尾不是靠自觉记日志,而是开场被强制接上。要不要把某个任务标 done,
|
|
215
|
-
> 仍由你判断(快照只记录「哪些还开着」,不猜完成)。
|
|
186
|
+
| 子任务做完 | `abs todo done <id>` |
|
|
187
|
+
| 碰壁/阻塞 | `abs todo blocked <id> --note "卡点原因"` |
|
|
188
|
+
| 被打断/干到一半 | `abs todo note <id> --note "改到哪个文件/到哪步"` |
|
|
189
|
+
|
|
190
|
+
**经验刚冒出来就落**:`abs note "一句话经验" --tags 坑,docker` → 暂存 `sources/`(幂等去重)。宁少勿滥。
|
|
191
|
+
|
|
192
|
+
> **写操作走 MCP(`abs_task`/`abs_note`),别用 bash 跑 CLI** —— MCP 常驻 ~2ms,
|
|
193
|
+
> CLI 每次起 node 进程 27ms。([[perf-fixed-overhead]])
|
|
194
|
+
>
|
|
195
|
+
> **跨会话任务只用 abs todo,别用宿主原生 todo**(Claude TodoWrite / codex todo-list /
|
|
196
|
+
> OpenCode todowrite / pi `/list`)—— 那些多是会话内临时,不写 `.brain/todo.md`,
|
|
197
|
+
> 下会话接不上、收尾没影。原生 todo 顶多记“本会话不跨断点的临时拆解”。
|
|
198
|
+
|
|
199
|
+
## 每轮结束:收尾循环(Stop / 告一段落后必做)
|
|
200
|
+
|
|
201
|
+
**每个任务边界、被 Stop/打断、告一段落时,别停半空。** 这是“开场接上状态、结束落回状态”的闭环。
|
|
202
|
+
|
|
203
|
+
> Stop 时 hook 把「未完成任务 + 断点」快照进 `~/.abs/log/wrapup.log`(`abs wrapup`,机械幂等),
|
|
204
|
+
> 下会话 `abs load` 会自动把滞留顶到顶部(`⏳ 上会话滞留`)—— 所以收尾不靠自觉,是开场被强制接上。
|
|
216
205
|
>
|
|
217
|
-
>
|
|
218
|
-
>
|
|
219
|
-
>
|
|
220
|
-
> 确无可沉淀产出回一句「无可沉淀」即可。Claude/Codex 侧靠 `Stop` 事件(见 event.sh)达成同样效果。
|
|
206
|
+
> **主动注入**:pi 扩展在 `agent_end` 检测「本会话真改过文件」且「log.md 今日无记录」时注入
|
|
207
|
+
> `[abs 收尾提醒]`(每会话最多一次)。**收到就照做,别复述提醒**;确无可沉淀回一句「无可沉淀」。
|
|
208
|
+
> Claude/Codex 靠 `Stop` 事件达成同样效果。
|
|
221
209
|
|
|
222
|
-
收到 Stop / "结束/先这样/切别的事" /
|
|
210
|
+
收到 Stop / "结束/先这样/切别的事" / 长任务告一段落,立即执行:
|
|
223
211
|
|
|
224
212
|
1. **读 todo** → `abs load`,看 Today 还有哪些没完成。
|
|
225
213
|
2. **判有没有做完没登记** → 实际完成了漏登记的 `abs todo done <id>`;做到一半补
|
|
@@ -227,7 +215,10 @@ monorepo 若多个子包各自独立交付,可各建一份 `.brain/`。`abs st
|
|
|
227
215
|
3. **沉淀经验(该沉淀才沉淀)** → 踩了值得记的坑/有可复用技巧/跨会话判断 → `abs note`
|
|
228
216
|
暂存;值得深提炼的(规律/坑/决策)按 Teardown 走完整流程。
|
|
229
217
|
4. **更新 index/log/todo** → 新页同步进 index;`log.md` 倒序记一行**工作成果**摘要
|
|
230
|
-
(`abs log "完成 X:..."`,不是工具动作);todo
|
|
218
|
+
(`abs log "完成 X:..."`,不是工具动作);todo 对账。
|
|
219
|
+
**新规律是「违反会丢数据/静默失效/白干活」级别 → 往 index 的 `## Rules` 加一行**
|
|
220
|
+
(短句 + `[[概念页]]`,不展开)。普通经验不进 Rules —— 否则会长成第二份概念库。
|
|
221
|
+
跑 `abs lint` 确认自洽。
|
|
231
222
|
|
|
232
223
|
**完成标准**:看板反映真实状态(Done 无滞留半成品)、该沉淀已落、index/log/todo 与事实一致。
|
|
233
224
|
|
|
@@ -241,53 +232,40 @@ monorepo 若多个子包各自独立交付,可各建一份 `.brain/`。`abs st
|
|
|
241
232
|
4. **对账 todo**:滞留 Today 归位(Done 标日期 / Backlog 补断点);遗留 bug 写 Backlog/Blocked。
|
|
242
233
|
5. **综合(可选)**:推进了选型/取舍 → `syntheses/`。
|
|
243
234
|
6. **收拢 sources**:提炼成规律的删 source,**同步清指向它的引用**(防死链)。
|
|
244
|
-
7. **修 index + 记 log**:新页同步 index
|
|
235
|
+
7. **修 index + 记 log**:新页同步 index;**过 Rules 门槛的规律加一行到 `## Rules`**;
|
|
236
|
+
`log.md` 倒序记一行摘要。
|
|
245
237
|
8. **留接力棒**:`sessions/log-YYYY-MM-DD.md`,强制写 `## 🪝 Next Session Hook`。
|
|
246
238
|
|
|
247
239
|
**完成标准**:每条过了容量纪律的知识一处落点;index 与事实一致;sessions 有带 Hook 快照。
|
|
248
240
|
|
|
249
|
-
##
|
|
241
|
+
## 知识页格式
|
|
250
242
|
|
|
251
|
-
|
|
243
|
+
统一 frontmatter:`tags / author / updated / status`(`status: draft`,或 `reviewed` = 冲突已裁决)。
|
|
244
|
+
`tags` 首标签 ∈ `entity|concept|source|synthesis|session-log`。`author` 与 `entities/<name>.md` 同名。
|
|
252
245
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
updated: YYYY-MM-DD
|
|
258
|
-
status: draft # 或 reviewed(仅指知识冲突裁决结案)
|
|
259
|
-
---
|
|
260
|
-
```
|
|
261
|
-
|
|
262
|
-
作者名同时是**人页 slug**:`[[fanchao]]` → `entities/fanchao.md`(技术栈 / 特点 / 名下踩过的坑)。
|
|
263
|
-
|
|
264
|
-
- **关联连接区**:每页必须有 `## 关联连接`,用 `[[页面名]]` 链相关页。严禁孤岛页。
|
|
265
|
-
- **知识冲突**:与旧页矛盾不静默覆盖。加 `## 知识冲突` 两版都留、标来源时间,交人工裁决。
|
|
266
|
-
- **命名即链接**:`[[Docker]]` 落 entities/Docker.md;`[[docker-prisma-429]]` 落 concepts/。别建别名层。
|
|
267
|
-
|
|
268
|
-
概念页核心结构(坑):`触发场景 / ❌表现(贴报错) / 🛠解法(根因+修复+验证命令) / 关联连接`。
|
|
246
|
+
- **每页必须有 `## 关联连接`**,用 `[[页面名]]` 链相关页 —— 严禁孤岛页。
|
|
247
|
+
- **命名即链接**:`[[Docker]]` → `entities/Docker.md`;`[[docker-prisma-429]]` → `concepts/`。不建别名层。
|
|
248
|
+
- **知识冲突**:不静默覆盖,加 `## 知识冲突` 两版都留、标来源时间,交人工裁决。
|
|
249
|
+
- 概念页骨架:`触发场景 / ❌表现(贴报错) / 🛠解法(根因+修复+验证命令) / 关联连接`。
|
|
269
250
|
|
|
270
251
|
## 维护:query / lint
|
|
271
252
|
|
|
272
|
-
-
|
|
273
|
-
|
|
274
|
-
-
|
|
275
|
-
index 漏列。按报告修(死链→补链;孤岛→补关联;超大→拆;sources 积压→提炼归档)。
|
|
253
|
+
- `abs query <词>` 检索(多词 OR)→ 读命中页 → 答用 `[[页面名]]` 标来源。
|
|
254
|
+
**代码问题(符号在哪/谁调用)不在 .brain,直接读源码**;.brain 只答"踩过什么坑/上次做到哪"。
|
|
255
|
+
- `abs lint` 体检:死链/孤岛/缺 frontmatter/模板残留/超尺寸/sources 堆积/index 漏列/Rules 超限。
|
|
276
256
|
|
|
277
|
-
##
|
|
257
|
+
## 三层分工
|
|
278
258
|
|
|
279
|
-
| 层 | 干什么 |
|
|
280
|
-
|
|
281
|
-
| **hook(机械)** |
|
|
282
|
-
| **CLI/MCP(实时)** |
|
|
283
|
-
| **skill(自觉)** | **深提炼**(sources→concepts)+
|
|
259
|
+
| 层 | 干什么 |
|
|
260
|
+
|---|---|
|
|
261
|
+
| **hook(机械)** | 自动记技术日志到 `~/.abs/log/`,你不用管 |
|
|
262
|
+
| **CLI/MCP(实时)** | 任务/经验实时落盘:`abs todo …`、`abs note` |
|
|
263
|
+
| **skill(自觉)** | **深提炼**(sources→concepts)+ 收尾 + 修 index —— 工具不替你判断 |
|
|
284
264
|
|
|
285
|
-
|
|
286
|
-
(`abs init`/`abs_task`/`abs note`/`abs query`/`abs lint`);**深提炼(sources→concept/entity 页)
|
|
287
|
-
手工写**——那是判断力,工具不替。改完 `abs lint` 确认自洽。
|
|
265
|
+
骨架/任务/暂存/检索/体检走工具;**深提炼手工写**(那是判断力)。改完跑 `abs lint`。
|
|
288
266
|
|
|
289
267
|
## 自我约束
|
|
290
268
|
|
|
291
|
-
- 只读写 `.brain/`
|
|
292
|
-
-
|
|
293
|
-
- 双链/frontmatter/index
|
|
269
|
+
- 只读写 `.brain/` 与目标代码,不动全局配置(一次性接入除外)。
|
|
270
|
+
- 只写真实发生的事实;遵守容量纪律,宁缺毋滥。
|
|
271
|
+
- 双链/frontmatter/index 必须自洽 —— 坏链 = 掰断接力棒。
|
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 { addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, moveBlocked, insertDoneGrouped, idOfTaskLine, archiveDoneInText, renderArchivePage, renderArchiveBody, DONE_KINDS, withDoneKind, doneKindOf, doneDateOf, collapseDone } from './todo.js';
|
|
7
|
+
import { addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, moveBlocked, insertDoneGrouped, idOfTaskLine, archiveDoneInText, renderArchivePage, renderArchiveBody, 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
|
|
|
@@ -108,23 +108,112 @@ function resolveProjectDir(dir) {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
export function indexTemplate() {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
'',
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
'## Sessions',
|
|
122
|
-
'',
|
|
123
|
-
].join('\n');
|
|
111
|
+
// 同 todoTemplate:由 rebuildStructure 生成,模板 = 重排结果,不会来回抖。
|
|
112
|
+
return rebuildStructure(
|
|
113
|
+
['# 🗂 Graph Index', '',
|
|
114
|
+
'本文件唯一入口。每新建/大改一页,同步在此分类下加一行 [[页面名]] — 一句话。', '',
|
|
115
|
+
'## Roadmap', '## Rules', '## Concepts', '## Entities', '## Sources', '## Syntheses', '## Sessions'].join('\n'),
|
|
116
|
+
{
|
|
117
|
+
h1: '# 🗂 Graph Index',
|
|
118
|
+
order: ['## Roadmap', '## Rules', '## Concepts', '## Entities', '## Sources', '## Syntheses', '## Sessions'],
|
|
119
|
+
},
|
|
120
|
+
).text;
|
|
124
121
|
}
|
|
125
122
|
|
|
126
123
|
export function logTemplate() {
|
|
127
|
-
return ['# 🗒
|
|
124
|
+
return ['# 🗒 Activity Log', '', '## [YYYY-MM-DD] ingest | 沉淀 <slug>', ''].join('\n');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------- 结构核对: load 每次都读 index/log/todo,顺手核形状 ----------
|
|
128
|
+
/** 标准形状表(与 indexTemplate/logTemplate/todoTemplate 同源)。
|
|
129
|
+
* 标记不一致 = 直接改成标准(“能自己处理的先处理”)。
|
|
130
|
+
* 只按**整行精确匹配**改标题,绝不动正文 —— 不做模糊替换,否则正文里提到的旧名会被误改。 */
|
|
131
|
+
export const BRAIN_SHAPE = {
|
|
132
|
+
'todo.md': {
|
|
133
|
+
h1: '# 📋 Todo Board',
|
|
134
|
+
order: ['## Backlog', '## Today / In Progress', '## Blocked', '## Done'],
|
|
135
|
+
},
|
|
136
|
+
'log.md': {
|
|
137
|
+
h1: '# 🗒 Activity Log',
|
|
138
|
+
order: [], // log 无固定分区(条目行自带时间,倒序)
|
|
139
|
+
},
|
|
140
|
+
'index.md': {
|
|
141
|
+
h1: '# 🗂 Graph Index',
|
|
142
|
+
order: ['## Roadmap', '## Rules', '## Concepts', '## Entities', '## Sources', '## Syntheses', '## Sessions'],
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** 旧标记 → 标准标记。load 发现就改(幂等)。含 H1 与分区/分组标题的旧名。 */
|
|
147
|
+
export const LEGACY_MARKS = [
|
|
148
|
+
['# 🗂 图谱索引', '# 🗂 Graph Index'],
|
|
149
|
+
['# 🗒 操作日志', '# 🗒 Activity Log'],
|
|
150
|
+
['# 📋 Todo 看板', '# 📋 Todo Board'],
|
|
151
|
+
['# 操作日志', '# 🗒 Activity Log'],
|
|
152
|
+
['# 图谱索引', '# 🗂 Graph Index'],
|
|
153
|
+
['# Todo 看板', '# 📋 Todo Board'],
|
|
154
|
+
['## 当前路线 (Roadmap)', '## Roadmap'],
|
|
155
|
+
['## Done(只留近期,旧的迁 log.md/快照)', '## Done'],
|
|
156
|
+
['### 归档', '### Archived'],
|
|
157
|
+
['### (未标日期)', '### Undated'],
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
/** 把不符标准的标记改成标准(按整行精确匹配,不碰正文)。
|
|
161
|
+
* log.md 特殊:它没有固定分区,只有条目行 —— 不做结构重排,只改 H1。
|
|
162
|
+
* 返回 { text, changed };无不一致时 changed 为空。 */
|
|
163
|
+
export function fixMarks(text, spec) {
|
|
164
|
+
const lines = String(text ?? '').split('\n');
|
|
165
|
+
const changed = [];
|
|
166
|
+
const h1At = lines.findIndex((l) => l.trim().startsWith('# '));
|
|
167
|
+
if (h1At !== -1 && lines[h1At].trim() !== spec.h1) {
|
|
168
|
+
const cur = lines[h1At].trim();
|
|
169
|
+
const hit = LEGACY_MARKS.find(([o]) => o === cur && o.startsWith('# '));
|
|
170
|
+
if (hit) { lines[h1At] = spec.h1; changed.push(`${cur} → ${spec.h1}`); }
|
|
171
|
+
}
|
|
172
|
+
for (let i = 0; i < lines.length; i++) {
|
|
173
|
+
if (i === h1At) continue;
|
|
174
|
+
const t = lines[i].trim();
|
|
175
|
+
const hit = LEGACY_MARKS.find(([o]) => o === t && !o.startsWith('# '));
|
|
176
|
+
if (hit) { lines[i] = hit[1]; changed.push(`${hit[0]} → ${hit[1]}`); }
|
|
177
|
+
}
|
|
178
|
+
return { text: lines.join('\n'), changed };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 核对 index/log/todo 的结构,不符就按标准重建(B 档:重排分区 + 内容按归属回填)。
|
|
183
|
+
* 它每次 load 都跑,所以改动立即生效,不必等额外命令。
|
|
184
|
+
*
|
|
185
|
+
* 边界(内容安全):
|
|
186
|
+
* - 只动**标题行位置**与缺失分区的空位,已有内容行按原归属搬运,不改写;
|
|
187
|
+
* - **非标准分区原样留在末尾**(人自加的区不合入,机器不猜语义);
|
|
188
|
+
* - log.md 不做重排(无固定分区,条目自带时间倒序);
|
|
189
|
+
* - 锁内重算 + “改了才写盘”,幂等。
|
|
190
|
+
*
|
|
191
|
+
* 不抛错(load 不能因核对失败而挂)。返回 { fixed:[描述], warn:[描述] }。
|
|
192
|
+
*/
|
|
193
|
+
export async function checkBrainShape(root) {
|
|
194
|
+
const fixed = [];
|
|
195
|
+
const warn = [];
|
|
196
|
+
for (const [file, spec] of Object.entries(BRAIN_SHAPE)) {
|
|
197
|
+
const p = brainPath(root, file);
|
|
198
|
+
let text;
|
|
199
|
+
try { text = await fs.readFile(p, 'utf8'); } catch { continue; }
|
|
200
|
+
if (!text.trim()) continue;
|
|
201
|
+
const l1 = (text.split('\n')[0] || '').trim();
|
|
202
|
+
const knownH1 = l1 === spec.h1 || LEGACY_MARKS.some(([o]) => o === l1 && o.startsWith('# '));
|
|
203
|
+
let changed = [];
|
|
204
|
+
await editFile(p, (cur) => {
|
|
205
|
+
if (cur === null) return SKIP;
|
|
206
|
+
const r = spec.order.length
|
|
207
|
+
? rebuildStructure(cur, { ...spec, renames: LEGACY_MARKS.filter(([o]) => o.startsWith('## ') || o.startsWith('### ')) })
|
|
208
|
+
: fixMarks(cur, spec);
|
|
209
|
+
if (!r.changed.length) return SKIP;
|
|
210
|
+
changed = r.changed;
|
|
211
|
+
return { text: r.text };
|
|
212
|
+
}).catch(() => {});
|
|
213
|
+
if (changed.length) fixed.push(`${file}: ${changed.join('; ')}`);
|
|
214
|
+
if (!knownH1) warn.push(`${file}: 标题非标准(读到 "${clip(l1, 24) || '(空)'}")`);
|
|
215
|
+
}
|
|
216
|
+
return { fixed, warn };
|
|
128
217
|
}
|
|
129
218
|
|
|
130
219
|
// ---------- board: 看板 ----------
|
|
@@ -161,6 +250,9 @@ async function listBrainFiles(root) {
|
|
|
161
250
|
// ---------- load: 开机读状态 ----------
|
|
162
251
|
export async function cmdLoad({ dir }) {
|
|
163
252
|
const root = await requireBrain(dir || process.cwd());
|
|
253
|
+
// 结构核对先跑:load 每回都要读这三个文件,顺手把它们形状摆正(缺分区)或提个醒(无头)。
|
|
254
|
+
// 正常时完全静默、零字节,不增加 load 体积(load 已被压缩到 ~1.8k token)。
|
|
255
|
+
const shape = await checkBrainShape(root).catch(() => ({ fixed: [], warn: [] }));
|
|
164
256
|
const todo = await readTodo(root);
|
|
165
257
|
const index = await readIfExists(brainPath(root, 'index.md'));
|
|
166
258
|
const log = await readIfExists(brainPath(root, 'log.md'));
|
|
@@ -168,6 +260,14 @@ export async function cmdLoad({ dir }) {
|
|
|
168
260
|
const sections = [
|
|
169
261
|
`📂 abs → 项目: ${root}`,
|
|
170
262
|
];
|
|
263
|
+
if (shape.fixed.length || shape.warn.length) {
|
|
264
|
+
const rows = [
|
|
265
|
+
...shape.fixed.map((a) => ` ✓ 已补: ${a}`),
|
|
266
|
+
...shape.warn.map((a) => ` ⚠ ${a}`),
|
|
267
|
+
];
|
|
268
|
+
if (shape.warn.length) rows.push(' (无头文件不自动改:结构可能整体脱轨,请手工对齐 .brain/ 模板)');
|
|
269
|
+
sections.push('--- 文件形状核对 ---', ...rows, '');
|
|
270
|
+
}
|
|
171
271
|
// 未设姓名时开场就提醒 —— load 是开机第一屏,不在这里提,
|
|
172
272
|
// 用户要撞到第一次写操作才知道(init/load 一路沉默)。
|
|
173
273
|
if (!(await getUser())) {
|
|
@@ -178,10 +278,13 @@ export async function cmdLoad({ dir }) {
|
|
|
178
278
|
);
|
|
179
279
|
}
|
|
180
280
|
sections.push(
|
|
181
|
-
|
|
281
|
+
// Rules 单独成段且放在最前(仅次于项目行/滞留):它是硬规则,不是普通清单。
|
|
282
|
+
// 坑: 曾在 index 段内与页面清单平铺 —— AI 会当普通清单划过,而它每条都是付过代价的。
|
|
283
|
+
...(rulesSection(index) ? [rulesSection(index), ''] : []),
|
|
284
|
+
'--- Roadmap (index.md) ---',
|
|
182
285
|
collapseIndex(index) || '(index.md 为空)',
|
|
183
286
|
'',
|
|
184
|
-
'--- Todo
|
|
287
|
+
'--- Todo Board (todo.md) ---',
|
|
185
288
|
collapseDone(todo).text || '(todo.md 为空)',
|
|
186
289
|
'\n(Done 已按日期折叠计数;明细: abs todo --full)',
|
|
187
290
|
'',
|
|
@@ -205,6 +308,91 @@ export async function cmdLoad({ dir }) {
|
|
|
205
308
|
return sections.join('\n');
|
|
206
309
|
}
|
|
207
310
|
|
|
311
|
+
// ---------- Rules: index.md 里的硬规则区 ----------
|
|
312
|
+
/** index.md 的 `## Rules` 区名与上限。 */
|
|
313
|
+
export const RULES_HEADING = '## Rules';
|
|
314
|
+
export const RULES_MAX = 30; // 超过就 lint 报:它属于“被读到才有价值”的区,不能无界增长
|
|
315
|
+
|
|
316
|
+
/** 提取 index.md 里的 Rules 区条目(不含标题)。返回 { items:[行], body, found }。 */
|
|
317
|
+
export function readRules(indexText) {
|
|
318
|
+
const lines = String(indexText || '').split('\n');
|
|
319
|
+
const i = lines.findIndex((l) => l.trim() === RULES_HEADING);
|
|
320
|
+
if (i === -1) return { items: [], body: '', found: false };
|
|
321
|
+
const rest = lines.slice(i + 1);
|
|
322
|
+
const j = rest.findIndex((l) => /^##\s/.test(l));
|
|
323
|
+
const body = rest.slice(0, j === -1 ? rest.length : j);
|
|
324
|
+
return { items: body.filter((l) => l.trim().startsWith('- ')), body: body.join('\n').trim(), found: true };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** load 里 Rules 的呈现:带独立段头,条目原样(不折、不截)。无条目则不占字节。 */
|
|
328
|
+
function rulesSection(indexText) {
|
|
329
|
+
const { items, body, found } = readRules(indexText);
|
|
330
|
+
if (!found || !body) return '';
|
|
331
|
+
// 有引言句(> 开头)时一并带上,它解释了这个区是干什么的。
|
|
332
|
+
const intro = body.split('\n').filter((l) => l.trim().startsWith('>')).join('\n');
|
|
333
|
+
return [
|
|
334
|
+
`--- Rules (硬规则,先读) — ${items.length} 条 ---`,
|
|
335
|
+
intro,
|
|
336
|
+
...items,
|
|
337
|
+
].filter((x) => x !== '').join('\n');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* `abs rule` —— 读写 index.md 的 Rules 区。
|
|
342
|
+
* 无参 = 列出(只给规则,不被 load 的其它内容占上下文)。
|
|
343
|
+
* add <一句话> = 追加一条(走锁写、幂等去重)。
|
|
344
|
+
* 门槛:只该放“违反会丢数据/静默失效/白干活”级规律;长句会被拒并指向概念页。
|
|
345
|
+
*/
|
|
346
|
+
export async function cmdRule({ dir, action, text }) {
|
|
347
|
+
let root;
|
|
348
|
+
try { root = await requireBrain(dir || process.cwd()); } catch {
|
|
349
|
+
return '未找到 .brain/ 图谱。先在项目根运行: abs init';
|
|
350
|
+
}
|
|
351
|
+
const p = brainPath(root, 'index.md');
|
|
352
|
+
if (!action || action === 'list' || action === 'show') {
|
|
353
|
+
const { items, found } = readRules(await readFileOrNull(p));
|
|
354
|
+
if (!found) return `index.md 无 \`${RULES_HEADING}\` 区(跑 abs load 会自动补位)`;
|
|
355
|
+
if (!items.length) return `${RULES_HEADING} 区为空(add "一句话" 追加)`;
|
|
356
|
+
return [`${RULES_HEADING} — ${items.length} 条:`, ...items].join('\n');
|
|
357
|
+
}
|
|
358
|
+
if (action !== 'add') {
|
|
359
|
+
return `用法: abs rule 列出硬规则\n abs rule add "一句话" [--note "补充"]`;
|
|
360
|
+
}
|
|
361
|
+
const clean = String(text || '').replace(/\s+/g, ' ').trim();
|
|
362
|
+
if (!clean) return '用法: abs rule add "一句话硬规则"';
|
|
363
|
+
// 门槛:一句话说完。太长说明该写概念页,Rules 只放指针。
|
|
364
|
+
if (clean.length > 120) {
|
|
365
|
+
return `✗ 太长(${clean.length} 字符 > 120)—— Rules 只放一句话摘要,展开写成概念页,\n 这里改成短句 + [[页面名]] 链接。`;
|
|
366
|
+
}
|
|
367
|
+
let added = null;
|
|
368
|
+
await editFile(p, (cur) => {
|
|
369
|
+
if (cur === null) return SKIP;
|
|
370
|
+
const lines = cur.split('\n');
|
|
371
|
+
const i = lines.findIndex((l) => l.trim() === RULES_HEADING);
|
|
372
|
+
if (i === -1) return SKIP; // 无该区不擅自建(load 会补位)
|
|
373
|
+
if (lines.some((l) => l.trim() === `- ${clean}`)) return SKIP; // 幂等:同句不重复
|
|
374
|
+
// 插在该区最后一条条目之后(保序、不搅动其它条目)
|
|
375
|
+
let at = i + 1;
|
|
376
|
+
for (let k = i + 1; k < lines.length; k++) {
|
|
377
|
+
if (/^##\s/.test(lines[k])) break;
|
|
378
|
+
if (lines[k].trim().startsWith('- ')) at = k + 1;
|
|
379
|
+
}
|
|
380
|
+
lines.splice(at, 0, `- ${clean}`);
|
|
381
|
+
added = clean;
|
|
382
|
+
return { text: lines.join('\n') };
|
|
383
|
+
});
|
|
384
|
+
if (added === null) return `• 已有同句或 index.md 无 ${RULES_HEADING} 区(跳过)`;
|
|
385
|
+
const { items } = readRules(await readFileOrNull(p));
|
|
386
|
+
const warn = items.length > RULES_MAX
|
|
387
|
+
? `\n⚠ Rules 已 ${items.length} 条 > ${RULES_MAX}:考虑把其中几条提炼成概念页(跑 abs lint 会报)`
|
|
388
|
+
: '';
|
|
389
|
+
return `✓ 已加硬规则(共 ${items.length} 条)\n - ${clean}${warn}`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function readFileOrNull(p) {
|
|
393
|
+
try { return await fs.readFile(p, 'utf8'); } catch { return ''; }
|
|
394
|
+
}
|
|
395
|
+
|
|
208
396
|
/** index.md 在 `abs load` 里的折叠形态:保留「当前路线」(那是 load 要传达的状态,
|
|
209
397
|
* 且有界),把页面清单各分区折成计数。
|
|
210
398
|
*
|
|
@@ -231,8 +419,10 @@ export function collapseIndex(text) {
|
|
|
231
419
|
if (m) {
|
|
232
420
|
flush();
|
|
233
421
|
const name = m[1].trim();
|
|
234
|
-
|
|
235
|
-
|
|
422
|
+
// 「Roadmap」与「Rules」都是**内容**不是清单:原样保留。
|
|
423
|
+
// Rules 区尤其不能折 —— 它的全部价值就是被读到;折成"(N 页)"等于把它静默删掉。
|
|
424
|
+
if (/路线|Roadmap|Rules?|规则/i.test(name)) {
|
|
425
|
+
out.push(l);
|
|
236
426
|
mode = null;
|
|
237
427
|
} else {
|
|
238
428
|
mode = name; // 页面清单分区:只计数
|
|
@@ -460,7 +650,7 @@ export async function cmdLog({ dir, title, kind = 'dev' }) {
|
|
|
460
650
|
// 一眼先看到谁做的(与 todo 行 `ID @name — 说明` 排版对齐)。
|
|
461
651
|
const line = `## [${stamp}] ${atTag(who)} ${kind} | ${clean}`;
|
|
462
652
|
await editFile(p, (cur) => {
|
|
463
|
-
const text = cur ?? '# 🗒
|
|
653
|
+
const text = cur ?? '# 🗒 Activity Log\n';
|
|
464
654
|
// 倒序:新行插在标题后(若已是模板占位行则替换它)
|
|
465
655
|
const lines = text.split('\n');
|
|
466
656
|
const headerIdx = lines.findIndex((l) => l.startsWith('#'));
|
|
@@ -541,7 +731,7 @@ async function markDone(file, id, kind = '落地') {
|
|
|
541
731
|
export async function cmdShow({ dir, view, full }) {
|
|
542
732
|
const v = String(view || '').toLowerCase();
|
|
543
733
|
if (!['todo', 'index', 'log'].includes(v)) {
|
|
544
|
-
return '用法: abs <todo|index|log> — todo=看板(原 board), index
|
|
734
|
+
return '用法: abs <todo|index|log> — todo=看板(原 board), index=Graph Index, log=操作流水';
|
|
545
735
|
}
|
|
546
736
|
const root = await requireBrain(dir || process.cwd());
|
|
547
737
|
const p = brainPath(root, `${v === 'todo' ? 'todo' : v}.md`);
|
|
@@ -765,8 +955,16 @@ export async function cmdLint({ dir }) {
|
|
|
765
955
|
for (const pg of pages) {
|
|
766
956
|
if (!pg.hasFrontmatter) issues.push(`NO-FRONTMATTER: ${pg.rel}`);
|
|
767
957
|
for (const ln of pg.links) {
|
|
768
|
-
|
|
769
|
-
|
|
958
|
+
// 两类都不是真链接,只报 TEMPLATE-LINK(且不短路就会再报一次 DEAD-LINK,同一条报两遍):
|
|
959
|
+
// ① 模板占位: `[[页面名]]` / `[[slug]]` / `[[Name]]` —— 模板没填
|
|
960
|
+
// ② 描述语法时引用的字面量: `[[<slug>]]` —— 尖括号不是合法 wikilink 字符,
|
|
961
|
+
// 而是任务描述在解释格式(如 “在 ### 归档 段留下 [[<slug>]] 完成任务 N 条”)。
|
|
962
|
+
// 单靠关键字(slug/name)分辨不了两者,故额外认尖括号形态。
|
|
963
|
+
if (/^<.+>$/.test(ln) || /slug|Name|name|Date|页面名$/.test(ln)) {
|
|
964
|
+
issues.push(`TEMPLATE-LINK: ${pg.rel} -> [[${ln}]]`);
|
|
965
|
+
} else if (!names.has(ln)) {
|
|
966
|
+
issues.push(`DEAD-LINK: ${pg.rel} -> [[${ln}]]`);
|
|
967
|
+
}
|
|
770
968
|
}
|
|
771
969
|
// ORPHAN: sources/ 暂存页与 todo 归档页豁免。前者是暂存线索(提炼成 concept 前天然孤立),
|
|
772
970
|
// 后者是历史数据倾倒(已登记在 index.md,就是图谱入口,无需再制造双链)。
|
|
@@ -797,6 +995,21 @@ export async function cmdLint({ dir }) {
|
|
|
797
995
|
const nsrc = pages.filter((p) => p.dir === 'sources').length;
|
|
798
996
|
if (nsrc > 10) issues.push(`SOURCES-PILED-UP: sources/ has ${nsrc} files > 10; 提炼归档旧 source`);
|
|
799
997
|
|
|
998
|
+
// Rules 区:它的价值在“少而重”,且不被折叠(load 每次都全量读)。
|
|
999
|
+
// 无上限增长 = 把 load 又撑回去(同 Done / index 清单的膨胀根因)。
|
|
1000
|
+
{
|
|
1001
|
+
const idxTxt = await readFileOrNull(join(vault, 'index.md'));
|
|
1002
|
+
const { items, found } = readRules(idxTxt);
|
|
1003
|
+
if (found && items.length > RULES_MAX) {
|
|
1004
|
+
issues.push(`RULES-PILED-UP: Rules 区 ${items.length} 条 > ${RULES_MAX};把长条目提炼成概念页,这里只留一句 + 链接`);
|
|
1005
|
+
}
|
|
1006
|
+
// 该区是 load 必读的硬规则清单,条目却写得像段落 → 提醒改短句。
|
|
1007
|
+
const longOnes = items.filter((l) => l.trim().length > 160);
|
|
1008
|
+
if (longOnes.length) {
|
|
1009
|
+
issues.push(`RULES-TOO-LONG: Rules 区 ${longOnes.length} 条超 160 字符(如 "${clip(longOnes[0].trim(), 40)}");展开写进概念页,这里只留短句 + [[链接]]`);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
800
1013
|
// Done 区堆积:它无上限增长,且 `abs todo`/`abs load` 每次全量打印 → 越积越难用。
|
|
801
1014
|
// (与 hooks.log/wrapup.log 同类问题;那两处有轮转,这里靠 `abs todo archive`。)
|
|
802
1015
|
// 坑: 曾经写成 brainPath(vault, 'todo.md'),而 vault 已经是 .brain 目录
|
package/src/todo.js
CHANGED
|
@@ -45,28 +45,135 @@ export async function ensureTodo(brainRoot) {
|
|
|
45
45
|
return p;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** 分区名常量(单一真源)。改名时只改这里 —— 之前散在 20+ 处,一改就漏。
|
|
49
|
+
* 旧名(中文)留在 LEGACY_SECTION_RENAMES 作迁移用。 */
|
|
50
|
+
export const SEC = {
|
|
51
|
+
backlog: 'Backlog',
|
|
52
|
+
today: 'Today / In Progress',
|
|
53
|
+
blocked: 'Blocked',
|
|
54
|
+
done: 'Done',
|
|
55
|
+
archived: 'Archived', // Done 区内部的归档标记区(原 '### 归档')
|
|
56
|
+
undated: 'Undated', // Done 区内部无完成日期的尾组(原 '### (未标日期)')
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** 旧名 → 新名。供 `abs init --repair` 一次性迁移(幂等)。
|
|
60
|
+
* 只改匹配整行的标题,不动正文;不做模糊替换(防误改正文里提到的旧名)。 */
|
|
61
|
+
export const LEGACY_SECTION_RENAMES = [
|
|
62
|
+
// H1(文件标题)
|
|
63
|
+
['# 🗂 图谱索引', '# 🗂 Graph Index'],
|
|
64
|
+
['# 🗒 操作日志', '# 🗒 Activity Log'],
|
|
65
|
+
['# 📋 Todo 看板', '# 📋 Todo Board'],
|
|
66
|
+
// ## 分区
|
|
67
|
+
['## 当前路线 (Roadmap)', '## Roadmap'],
|
|
68
|
+
['## Done(只留近期,旧的迁 log.md/快照)', '## Done'],
|
|
69
|
+
// ### 区内分组标题
|
|
70
|
+
['### 归档', '### Archived'],
|
|
71
|
+
['### (未标日期)', '### Undated'],
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/** 把文件里的旧分区名就地改成新名(只改匹配整行的标题,不动正文)。
|
|
75
|
+
* 返回 { text, changed:[旧名→新名] }。幂等:已改过的再跑 changed 为空。 */
|
|
76
|
+
export function renameLegacySections(text) {
|
|
77
|
+
const lines = String(text ?? '').split('\n');
|
|
78
|
+
const changed = [];
|
|
79
|
+
const out = lines.map((l) => {
|
|
80
|
+
const t = l.trim();
|
|
81
|
+
for (const [oldN, newN] of LEGACY_SECTION_RENAMES) {
|
|
82
|
+
if (t === oldN) { changed.push(`${oldN} → ${newN}`); return newN; }
|
|
83
|
+
}
|
|
84
|
+
return l;
|
|
85
|
+
});
|
|
86
|
+
return { text: out.join('\n'), changed };
|
|
87
|
+
}
|
|
88
|
+
|
|
48
89
|
export function todoTemplate() {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
'- [ ] 待办任务',
|
|
53
|
-
'## Today / In Progress',
|
|
54
|
-
|
|
55
|
-
'## Done(只留近期,旧的迁 log.md/快照)',
|
|
56
|
-
'',
|
|
57
|
-
].join('\n');
|
|
90
|
+
// 由 rebuildStructure 生成,保证“模板”与“重排结果”逐字节一致
|
|
91
|
+
// (否则 load 会把新建的模板又重排一次 = 无意义的写盘)。
|
|
92
|
+
return rebuildStructure(
|
|
93
|
+
['# 📋 Todo Board', '## Backlog', '- [ ] 待办任务', '## Today / In Progress', '## Blocked', '## Done'].join('\n'),
|
|
94
|
+
{ h1: '# 📋 Todo Board', order: ['## Backlog', '## Today / In Progress', '## Blocked', '## Done'] },
|
|
95
|
+
).text;
|
|
58
96
|
}
|
|
59
97
|
|
|
60
98
|
/** 归一化 todo.md 分区:老格式(In Progress/Todo)迁移为 B4 定稿格式(Backlog→Today / In Progress→Blocked→Done)。
|
|
61
99
|
* 幂等:已是新格式则原样返回。迁移原则——老 "In Progress" 内容进 "Today / In Progress",老 "Todo" 内容进 "Backlog"。 */
|
|
62
100
|
export const TODO_SECTIONS = ['Backlog', 'Today / In Progress', 'Blocked', 'Done'];
|
|
63
101
|
|
|
102
|
+
/** 结构重建(B 档):以标准分区表为准重排整个文件。
|
|
103
|
+
*
|
|
104
|
+
* 规则(保证内容不丢、不挪错):
|
|
105
|
+
* 1. 按 spec.order 顺序输出标准分区,每个分区下放**归属于它**的内容行;
|
|
106
|
+
* 2. 归属判定:按行所处的原分区归入对应标准分区;旧名先按 spec.renames 归一;
|
|
107
|
+
* 3. **非标准分区**(人自加的,如 `## 备忘`)→ 内容连同它自己的标题一起**原样保留在末尾**,
|
|
108
|
+
* 绝不合入已有标准分区(机器不知道它的语义,猜错就是挪错内容);
|
|
109
|
+
* 4. 自由正文(不属于任何分区的行,如 H1 后的说明句)保留在 H1 之后;
|
|
110
|
+
* 5. 幂等:已是标准结构 → 输出逐字节相同。
|
|
111
|
+
*
|
|
112
|
+
* 返回 { text, changed };changed 为空的描述列表(空=无需改盘)。 */
|
|
113
|
+
export function rebuildStructure(text, spec) {
|
|
114
|
+
const s = String(text ?? '');
|
|
115
|
+
const lines = s.split('\n');
|
|
116
|
+
const h1At = lines.findIndex((l) => l.trim().startsWith('# '));
|
|
117
|
+
const bodyStart = h1At === -1 ? 0 : h1At + 1;
|
|
118
|
+
const bucket = new Map(); // 标准分区名 -> 内容行
|
|
119
|
+
const extras = []; // [{ title, lines }] 非标准分区,原样保留到末尾
|
|
120
|
+
const preamble = []; // H1 与第一个 ## 之间的自由正文
|
|
121
|
+
let curStd = null; // 当前在的标准分区名(null = 前言)
|
|
122
|
+
let curExtra = null; // 当前在的额外分区对象
|
|
123
|
+
for (let i = bodyStart; i < lines.length; i++) {
|
|
124
|
+
const l = lines[i];
|
|
125
|
+
if (/^#{2,3}\s/.test(l)) {
|
|
126
|
+
const t = l.trim();
|
|
127
|
+
const renamed = (spec.renames?.find(([o]) => o === t) || [, t])[1];
|
|
128
|
+
if (spec.order.includes(renamed)) {
|
|
129
|
+
curStd = renamed;
|
|
130
|
+
curExtra = null;
|
|
131
|
+
if (!bucket.has(curStd)) bucket.set(curStd, []);
|
|
132
|
+
} else {
|
|
133
|
+
curExtra = { title: renamed, lines: [] };
|
|
134
|
+
extras.push(curExtra);
|
|
135
|
+
curStd = null;
|
|
136
|
+
}
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (curExtra) curExtra.lines.push(l);
|
|
140
|
+
else if (curStd) bucket.get(curStd).push(l);
|
|
141
|
+
else preamble.push(l);
|
|
142
|
+
}
|
|
143
|
+
const out = [spec.h1];
|
|
144
|
+
const pre = trimBlank(preamble);
|
|
145
|
+
if (pre.length) out.push('', ...pre);
|
|
146
|
+
// 空分区之间不插空行(否则每次首跑都会“把空行加进去”而写盘一次,
|
|
147
|
+
// 而 load 是好读命令 —— 不该因纯排版差异去改文件)。
|
|
148
|
+
// 有内容的第一个分区与前言之间保留一个空行(排版),其余紧凑。
|
|
149
|
+
for (const [idx, name] of spec.order.entries()) {
|
|
150
|
+
const body = trimBlank(bucket.get(name) || []);
|
|
151
|
+
if (body.length || (idx === 0 && pre.length)) out.push('', name, ...body);
|
|
152
|
+
else out.push(name);
|
|
153
|
+
}
|
|
154
|
+
for (const e of extras) {
|
|
155
|
+
const body = trimBlank(e.lines);
|
|
156
|
+
out.push('', e.title);
|
|
157
|
+
if (body.length) out.push(...body);
|
|
158
|
+
}
|
|
159
|
+
const next = out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '') + '\n';
|
|
160
|
+
return { text: next, changed: next === s ? [] : ['结构按标准重排'] };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 去掉首尾空行,不动中间(保留用户的分段)。 */
|
|
164
|
+
function trimBlank(arr) {
|
|
165
|
+
const a = [...arr];
|
|
166
|
+
while (a.length && !a[0].trim()) a.shift();
|
|
167
|
+
while (a.length && !a[a.length - 1].trim()) a.pop();
|
|
168
|
+
return a;
|
|
169
|
+
}
|
|
170
|
+
|
|
64
171
|
export function normalizeTodo(text) {
|
|
65
172
|
const lines = text.split('\n');
|
|
66
173
|
const has = (name) => lines.some((l) => l.trim() === `## ${name}`);
|
|
67
174
|
if (has('Backlog') || has('Today / In Progress')) return text; // 已是新格式
|
|
68
175
|
if (!has('In Progress') && !has('Todo')) return text; // 不是老格式,不动
|
|
69
|
-
const out = ['# 📋 Todo
|
|
176
|
+
const out = ['# 📋 Todo Board'];
|
|
70
177
|
const grab = (name) => {
|
|
71
178
|
const items = [];
|
|
72
179
|
let inSec = false;
|
|
@@ -83,7 +190,7 @@ export function normalizeTodo(text) {
|
|
|
83
190
|
out.push('## Backlog', ...todo.length ? todo : []);
|
|
84
191
|
out.push('## Today / In Progress', ...inprog.length ? inprog : []);
|
|
85
192
|
out.push('## Blocked', ...blocked.length ? blocked : []);
|
|
86
|
-
out.push('## Done
|
|
193
|
+
out.push('## Done', ...done.length ? done : []);
|
|
87
194
|
return out.join('\n');
|
|
88
195
|
}
|
|
89
196
|
|
|
@@ -156,7 +263,7 @@ function renderDoneGroups(units) {
|
|
|
156
263
|
const dates = [...byDate.keys()].sort().reverse(); // 新日期在前
|
|
157
264
|
const out = [];
|
|
158
265
|
for (const d of dates) out.push(`### ${d}`, '', ...byDate.get(d).flatMap((u) => u.lines), '');
|
|
159
|
-
if (undated.length) out.push(
|
|
266
|
+
if (undated.length) out.push(`### ${SEC.undated}`, '', ...undated.flatMap((u) => u.lines), '');
|
|
160
267
|
return out;
|
|
161
268
|
}
|
|
162
269
|
|
|
@@ -206,11 +313,11 @@ export function collapseDone(text) {
|
|
|
206
313
|
}
|
|
207
314
|
|
|
208
315
|
/** 归档标记区标题。它在 Done 区内部、日期分组之后,形如:
|
|
209
|
-
* ###
|
|
316
|
+
* ### Archived
|
|
210
317
|
* - [[2026-09-10-todo归档]] 完成任务 10 条
|
|
211
318
|
* 这区不是任务行,不能被 parseDoneUnits 吃挂,否则 markDone 重建 Done 时会把它丢掉。
|
|
212
319
|
* 故先切出去当"不透明区域"原样保留。 */
|
|
213
|
-
const ARCHIVE_HEADING =
|
|
320
|
+
const ARCHIVE_HEADING = `### ${SEC.archived}`;
|
|
214
321
|
|
|
215
322
|
/** 把 Done 主体行切成 { groupLines, archiveLines }:把 `### 归档` 区当"不透明块"原样保留。
|
|
216
323
|
* 注意不能简单"从标题切到文件尾" —— 否则一旦有日期组落在归档区之后(手改/旧数据),
|