@fanchao8609/agent_brain_sync 1.7.5 → 1.7.7

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 CHANGED
@@ -72,11 +72,18 @@ abs log "完成 X" # 记一行流水;abs log 无参 =
72
72
  abs query <词> # 检索图谱(多词 OR)
73
73
  abs status # 当前项目 + 图谱概要
74
74
  abs lint # 体检:死链/孤岛/超尺寸/堆积
75
+ abs rule # 列出 index.md 的 ## Rules 硬规则
76
+ abs rule add "一句话" # 追加一条硬规则(违反会丢数据/静默失效级的)
77
+ abs config show # 查看使用者姓名(标记作者用)
78
+ abs config set user <名字> # 设置作者名 → ~/.abs/config.json
75
79
  abs todo archive # 归档 Done 区旧日期组(默认留近 3 天)
80
+ abs update # 升级到最新版并刷新四宿主 hook/skill
76
81
  ```
77
82
 
78
83
  > `abs todo start` 与 `abs todo add` 等价(都登记任务)。
79
84
  > 旧版 `abs task ...` / `abs board` 已改名,会报错并提示新写法。
85
+ > `abs wrapup` / `abs teardown-check` 是 hook 内部命令,无需手动调用。
86
+ > **升级后分区名自动归一**:`abs load` 每次都会顺手核对 `index/log/todo` 三文件结构,旧的英文/中文分区名(如 `## 当前路线 (Roadmap)` → `## Roadmap`、`# 🗂 图谱索引` → `# 🗂 Graph Index`)会被自动改回标准;缺分区自动补建,无头文件只提醒不自动改。
80
87
 
81
88
  ### 工作流
82
89
 
@@ -101,6 +108,8 @@ npm i -g @fanchao8609/agent_brain_sync@latest
101
108
  abs install # 重新刷 hook/skill
102
109
  ```
103
110
 
111
+ > ⚠️ 改完源码(尤其 `skill/SKILL.md`、`src/`)后**必须 `abs install --yes` 重扇出**,否则宿主还在跑旧副本 —— 版本号与代码会脱节。发布用 `npm version patch`(自动打 tag),别手改 `package.json` 的 version;发布后 `npm view` 有缓存延迟,必要时 `npm cache clean --force` 再验。
112
+
104
113
  ---
105
114
 
106
115
  ## 卸载
@@ -136,6 +145,8 @@ agent_brain_sync/
136
145
  ├── src/store.js CLI 命令实现
137
146
  ├── src/hosts.js 四宿主接入定义
138
147
  ├── src/install.js 安装/卸载(分区共存合并)
148
+ ├── src/userconfig.js 使用者姓名配置(作者标记)
149
+ ├── src/wrapup.js Stop 收尾快照/归档
139
150
  ├── hooks/event.sh hook 模板
140
151
  ├── skill/SKILL.md 技能(装到各智能体)
141
152
  └── test/ 单测
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.7.5",
3
+ "version": "1.7.7",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",
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
- 图谱需要知道「谁登记的」。写操作(`todo add/note/blocked/done`、`log`、`note`)会检查:
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
- 设置后自动标记(**作者是人页的 wikilink,点得进去看技术栈/特点**):
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
- 同时自动建人页 `.brain/entities/<name>.md`(含「技术栈 / 特点·工作习惯 / 名下踩过的坑」三个空槽),
78
- 并登记进 `index.md` 的 Entities 区。**已存在则一律不动** —— 里面的内容是人工沉淀的,机器不许覆盖。
98
+ 骨架由 `abs init` 生成(`--repair` 只补缺不覆盖)。
79
99
 
80
- > **沉淀时顺手填人页**:经验提炼进 concepts/ 时,若观察到工程师的技术栈或判断倾向,
81
- > 写进 `entities/<name>.md`。这页是可积累的画像,不是一次性标签。
100
+ ### `index.md` —— 图谱入口
82
101
 
83
- **只读命令不检查**(`load`/`todo`/`status`/`lint`/`query`/`index`/`log` 无参)——
84
- hook 在会话结束时非交互调 `abs wrapup`/`abs teardown-check`,那儿拦人会卡断收尾。
85
-
86
- > `index.md` 的**经验行不加作者**:index 行是覆盖式更新的,作者会从"创建者"漂成"最后改的人",
87
- > 语义不固定。要查谁写的,看该页自己的 `author`,或 `abs query` 输出(带每页 author)。
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
- monorepo 若多个子包各自独立交付,可各建一份 `.brain/`。`abs status` 显示当前定位到哪个项目。
108
+ **`## Rules` 区**:铁律清单,`abs load` 每次都全量读(代码里明确不折它)。
109
+ - 一句一条;有概念页就用 `[[链接]]` 指过去,**不在此展开**。
110
+ - 只有「违反会丢数据 / 静默失效 / 白干活」级才进 —— 普通经验进 `concepts/`。
111
+ - 读写:`abs rule` / `abs rule add "一句话"`(>120 字符被拒);`abs lint` 超 30 条会报。
102
112
 
103
- ## 图谱长什么样(都在 `.brain/` 下)
113
+ ### `log.md` —— 工作成果流水
104
114
 
105
- ```
106
- .brain/
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
- **骨架由 `abs init` 生成,不手工建。** 每个 `.brain/` 建一份,不逐子目录乱建。结构不完整时
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
- | `entities/` | 具名的**事物**:`docker`、`auth-module` | TitleCase:`Docker.md` |
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
- 判断一问:能说"它是什么"→ entities;能说"这么做就避坑"→ concepts;卡住默认 concepts。
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
- 1. **再命中测试**:下个会话不知道这条,会不会踩同坑/重做同决定?会→存;不会→不存。
136
- 能从代码 grep 读出的细节一律不记。
137
- 2. **单页硬上限**:`entities/ concepts/ syntheses/` 单页 <150 行/<5KB。超了拆或外链。
138
- 3. **sources 是暂存不是存档**:提炼成规律后删/归档 source(同步清引用,防死链)。
139
- 4. **写前压缩三问**:规律还是噪音?不记会怎样?能不能用一行链接已有页代替新增整页?
143
+ ### 容量纪律(写任何页之前过四关)
140
144
 
141
- `abs lint` 检查单页超限、sources 堆积、死链、index 漏列。
145
+ 图谱贵在**精**不在全,不过关就不写或压缩:
146
+ 1. **再命中**:下会话不知道这条会踩同坑/重做同决定?会→存,不会→不存。代码能 grep 到的一律不记。
147
+ 2. **单页上限**:`entities/concepts/syntheses` 单页 <150 行且 <8KB,超了拆或外链。
148
+ 3. **sources 是暂存**:提炼成规律后删/归档,并清掉指向它的引用(防死链)。
149
+ 4. **能不能用一行链接代替新增整页**?
142
150
 
151
+ `abs lint` 兜底:死链/孤岛/**悬挂页(NO-INBOUND)**/缺 frontmatter/超限/sources 堆积/**超龄未提炼(SOURCE-UNDISTILLED)**/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
- > **路线(Roadmap)区原样保留** —— 那是 load 要传达的状态本身。
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/`**。它是一个不断长大的知识图谱,当前仓库 29 页就约 11 万 token
162
- > (单页最大 16KB)—— 全读会把窗口直接塞满。正确姿势:
163
- > - 要状态 → `abs load`|要主题 → `abs query <词>`(只回命中几页)
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
- **todo 不是收尾仪式,是干活中随改随写的活看板。** 每个任务边界立即更新,和 git commit
174
- 同一个反射,别等收尾。用工具(MCP `abs_task` / CLI `abs todo`):
179
+ ## 进行中:什么时候动它(最重要的节)
175
180
 
176
- > **写操作优先 MCP,不要用 `bash` 跑 `abs`。** 实测:MCP 单次 ~2ms(server 常驻),
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>`(自动归位 Done 对应 `### YYYY-MM-DD` 分组顶部,新完成在前;断点随迁) |
185
- | 碰壁/阻塞 | `abs todo blocked <id> --note "卡点原因"`(移 Blocked) |
186
- | 被打断/改向/干到一半停 | `abs todo note <id> --note "改到哪个文件/到哪步"`(补 ↳ 断点 行) |
187
-
188
- **Done 区会自动收口**:会话结束时 hook 调 `abs wrapup`,顺手把「超过 3 天 且 整天都已完成」
189
- 的日期组迁到 `.brain/sessions/<日期>-todo归档.md`,并在 Done 区尾部留一行
190
- `### 归档` → `- [[<日期>-todo归档]] 完成任务 N 条`。
191
- **任一天只要还有未完成任务(`- [ ]`),整天都不归档** —— 不会把半成品扫走。
192
- 手动跑:`abs todo archive [--keep-days N] [--dry-run]`。
193
-
194
- > `abs todo start` 与 `abs todo add` 等价(老写法仍可用)。
195
- > 旧版 `abs task ...` / `abs board` 已改名,会报错并提示新写法。
196
- > 只读命令(`todo`/`status`/`lint`/`load`/`index`)遇多余参数会报错 —— 不再静默吞掉。
197
-
198
- > **跨会话任务只用 abs todo,别用宿主原生 todo。** Cl​aude TodoWrite/Task、co​dex todo-list、
199
- > Op​enCode todowrite、pi `/list`/goal 各有各的原生任务——但**多是会话内临时**,不会写进
200
- > `.brain/todo.md`。若用原生 todo 建了跨会话任务,它就会「只在界面 0/N 里、abs 看不到」,
201
- > 下会话接不上、收尾没影。**分工**:跨会话/会被打断的任务 → `abs todo add`(唯一真源);
202
- > 原生 todo 顶多记「本会话内不跨断点的临时拆解草稿」。
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**(Cl​aude TodoWrite / co​dex todo-list /
196
+ > Op​enCode 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
- > **主动注入(pi 已实现,别等它、也别嫌它吵)**:pi 扩展在 `agent_end` 检测「本会话真改过文件
218
- > (write/edit/非只读 bash)」且「`.brain/log.md` 今日无记录」时,会注入一条 `[abs 收尾提醒]` 消息
219
- > 逼你走本循环(每会话最多一次,已收尾/无图谱则不打扰)。**收到就照做,别复述提醒、别解释为什么在收尾**;
220
- > 确无可沉淀产出回一句「无可沉淀」即可。Cl​aude/Co​dex 侧靠 `Stop` 事件(见 event.sh)达成同样效果。
206
+ > **主动注入**:pi 扩展在 `agent_end` 检测「本会话真改过文件」且「log.md 今日无记录」时注入
207
+ > `[abs 收尾提醒]`(每会话最多一次)。**收到就照做,别复述提醒**;确无可沉淀回一句「无可沉淀」。
208
+ > Cl​aude/Co​dex 靠 `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 对账。跑 `abs lint` 确认自洽。
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,42 @@ 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;`log.md` 倒序记一行摘要。
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
- ## 知识页格式(concepts/entities/syntheses)
241
+ ## 知识页格式
250
242
 
251
- 所有页统一 frontmatter:`tags / author / updated / status`。
243
+ 统一 frontmatter:`tags / author / updated / status`(`status: draft`,或 `reviewed` = 冲突已裁决)。
244
+ `tags` 首标签 ∈ `entity|concept|source|synthesis|session-log`。`author` 与 `entities/<name>.md` 同名。
252
245
 
253
- ```markdown
254
- ---
255
- tags: [concept, 领域] # 首标签 ∈ entity|concept|source|synthesis|session-log
256
- author: fanchao # 作者(abs note 自动写;手写页也须填,且与 entities/<name>.md 同名)
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
- - **query(检索)**:`abs query <词>`(或先读 index 定位)→ 读命中页 → 答用 `[[页面名]]` 标来源。
273
- **代码问题(符号在哪/谁调用)答案不在 .brain,直接读源码**;.brain 只答"踩过什么坑/上次做到哪"。
274
- - **lint(体检)**:`abs lint`。查死链/孤岛/缺 frontmatter/模板残留/未决冲突/超尺寸/sources 堆积/
275
- index 漏列。按报告修(死链→补链;孤岛→补关联;超大→拆;sources 积压→提炼归档)。
253
+ - `abs query <词>` 检索(多词 OR)→ 读命中页 → 答用 `[[页面名]]` 标来源。
254
+ **代码问题(符号在哪/谁调用)不在 .brain,直接读源码**;.brain 只答"踩过什么坑/上次做到哪"。
255
+ - `abs lint` 体检:死链/孤岛/**悬挂页**/缺 frontmatter/模板残留/超尺寸/sources 堆积/**超龄未提炼**/index 漏列/Rules 超限。
256
+ - `NO-INBOUND`:有出边但无人 `[[链接]]` 到你 = 挂在图上没人接(孤岛检查只抓"零出零入")。
257
+ - `SOURCE-UNDISTILLED`:source 超 7 天仍未链到任何 concept = 暂存了没归位。
276
258
 
277
- ## 分工:hook 机械记 / 工具实时落 / skill 深提炼(装了 abs 的项目)
259
+ ## 三层分工
278
260
 
279
- | 层 | 干什么 | 靠什么 |
280
- |---|---|---|
281
- | **hook(机械)** | SessionStart/UserPromptSubmit/Stop/SessionEnd 自动记**技术日志**(~/.abs/log/) | 宿主 hook 配置。你不写技术日志。 |
282
- | **CLI/MCP(实时)** | 任务/经验**实时落盘**:`abs todo add/note/blocked/done`、`abs note` | 每个任务边界立即调;经验随时 abs note。 |
283
- | **skill(自觉)** | **深提炼**(sources→concepts)+ 收尾循环 + 修 index | 判断什么值得沉淀,工具不替你判断。 |
261
+ | 层 | 干什么 |
262
+ |---|---|
263
+ | **hook(机械)** | 自动记技术日志到 `~/.abs/log/`,你不用管 |
264
+ | **CLI/MCP(实时)** | 任务/经验实时落盘:`abs todo …`、`abs note` |
265
+ | **skill(自觉)** | **深提炼**(sources→concepts)+ 收尾 + 修 index —— 工具不替你判断 |
284
266
 
285
- 实时层解决"断了就丢";自觉层解决"噪音污染"。分工明确:骨架/任务/暂存/检索/体检走 abs 工具
286
- (`abs init`/`abs_task`/`abs note`/`abs query`/`abs lint`);**深提炼(sources→concept/entity 页)
287
- 手工写**——那是判断力,工具不替。改完 `abs lint` 确认自洽。
267
+ 骨架/任务/暂存/检索/体检走工具;**深提炼手工写**(那是判断力)。改完跑 `abs lint`。
288
268
 
289
269
  ## 自我约束
290
270
 
291
- - 只读写 `.brain/` 与目标代码,不动全局配置(一次性接入安装除外)。
292
- - 内容基于真实发生的事实;遵守容量纪律宁缺毋滥。
293
- - 双链/frontmatter/index 必须自洽——图谱给下个会话读,坏链=掰断接力棒。
271
+ - 只读写 `.brain/` 与目标代码,不动全局配置(一次性接入除外)。
272
+ - 只写真实发生的事实;遵守容量纪律,宁缺毋滥。
273
+ - 双链/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
- return [
112
- '# 🗂 图谱索引',
113
- '',
114
- '本文件唯一入口。每新建/大改一页,同步在此分类下加一行 [[页面名]] — 一句话。',
115
- '',
116
- '## 当前路线 (Roadmap)',
117
- '## Concepts',
118
- '## Entities',
119
- '## Sources',
120
- '## Syntheses',
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 ['# 🗒 操作日志', '', '## [YYYY-MM-DD] ingest | 沉淀 <slug>', ''].join('\n');
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
- '--- 当前路线 (index.md) ---',
281
+ // Rules 单独成段且放在最前(仅次于项目行/滞留):它是硬规则,不是普通清单。
282
+ // 坑: 曾在 index 段内与页面清单平铺 —— AI 会当普通清单划过,而它每条都是付过代价的。
283
+ ...(rulesSection(index) ? [rulesSection(index), ''] : []),
284
+ '--- Roadmap (index.md) ---',
182
285
  collapseIndex(index) || '(index.md 为空)',
183
286
  '',
184
- '--- Todo 看板 (todo.md) ---',
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
- if (/路线|Roadmap/i.test(name)) {
235
- out.push(l); // 「当前路线」是内容不是清单:原样保留
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 ?? '# 🗒 操作日志\n';
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=图谱索引, log=操作流水';
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`);
@@ -754,6 +944,10 @@ export async function cmdLint({ dir }) {
754
944
  if (f.endsWith('.md')) names.add(f.replace(/\.md$/, ''));
755
945
  }
756
946
  const linkedNames = new Set(pages.flatMap((p) => p.links));
947
+ // 入度统计(不含 index.md):图上"有人引用它"才算被接上。
948
+ // index 是入口清单(每页都会被登记),算进去就永远不会有 NO-INBOUND —— 失去意义。
949
+ const inbound = new Map();
950
+ for (const p of pages) for (const ln of p.links) inbound.set(ln, (inbound.get(ln) || 0) + 1);
757
951
  // index.md 里列的 [[x]] —— 用于反向查死引用(列了但页不存在)
758
952
  let indexLinks = [];
759
953
  try {
@@ -782,6 +976,12 @@ export async function cmdLint({ dir }) {
782
976
  if (!isTerminal && !pg.links.length && !linkedNames.has(pg.slug)) {
783
977
  issues.push(`ORPHAN-PAGE: ${pg.rel} (no links out, no links in)`);
784
978
  }
979
+ // NO-INBOUND: 有出边但无人指向 = 挂在图上没人接。ORPHAN-PAGE 只抓"零出零入",
980
+ // 抓不到"连了 5 条出去却没人连它"的悬挂页(实测 concepts/file-shape-check-on-load 即是)。
981
+ // 只查知识页(concepts/entities/syntheses)——sources/sessions 的孤立是设计使然。
982
+ if (['concepts', 'entities', 'syntheses'].includes(pg.dir) && !(inbound.get(pg.slug) || 0)) {
983
+ issues.push(`NO-INBOUND: ${pg.rel} (无人链接到本页;在相关页的 ## 关联连接 挂一条 [[${pg.slug}]])`);
984
+ }
785
985
  if (/知识冲突/.test(pg.body) && /status: draft/.test(pg.frontmatter)) {
786
986
  issues.push(`UNRESOLVED-CONFLICT: ${pg.rel}`);
787
987
  }
@@ -805,6 +1005,36 @@ export async function cmdLint({ dir }) {
805
1005
  const nsrc = pages.filter((p) => p.dir === 'sources').length;
806
1006
  if (nsrc > 10) issues.push(`SOURCES-PILED-UP: sources/ has ${nsrc} files > 10; 提炼归档旧 source`);
807
1007
 
1008
+ // SOURCE-UNDISTILLED: source 页超过 SOURCE_STALE_DAYS 天仍没链到任何 concept 页 = 暂存了没归位。
1009
+ // 只数总量(SOURCES-PILED-UP)抓不到"4 个 source 里 3 个没提炼"——实测本仓即如此。
1010
+ // 判据机械可判:出边里有没有 concepts/ 的页 + mtime 超龄,不猜语义。
1011
+ const conceptSlugs = new Set(pages.filter((p) => p.dir === 'concepts').map((p) => p.slug));
1012
+ const staleMs = SOURCE_STALE_DAYS * 86400 * 1000;
1013
+ for (const pg of pages) {
1014
+ if (pg.dir !== 'sources') continue;
1015
+ if (pg.links.some((ln) => conceptSlugs.has(ln))) continue;
1016
+ let ageMs = 0;
1017
+ try { ageMs = Date.now() - (await fs.stat(join(root, pg.rel))).mtimeMs; } catch { continue; }
1018
+ if (ageMs > staleMs) {
1019
+ issues.push(`SOURCE-UNDISTILLED: ${pg.rel}(${SOURCE_STALE_DAYS} 天未提炼成 concept;提炼后删 source 并清引用)`);
1020
+ }
1021
+ }
1022
+
1023
+ // Rules 区:它的价值在“少而重”,且不被折叠(load 每次都全量读)。
1024
+ // 无上限增长 = 把 load 又撑回去(同 Done / index 清单的膨胀根因)。
1025
+ {
1026
+ const idxTxt = await readFileOrNull(join(vault, 'index.md'));
1027
+ const { items, found } = readRules(idxTxt);
1028
+ if (found && items.length > RULES_MAX) {
1029
+ issues.push(`RULES-PILED-UP: Rules 区 ${items.length} 条 > ${RULES_MAX};把长条目提炼成概念页,这里只留一句 + 链接`);
1030
+ }
1031
+ // 该区是 load 必读的硬规则清单,条目却写得像段落 → 提醒改短句。
1032
+ const longOnes = items.filter((l) => l.trim().length > 160);
1033
+ if (longOnes.length) {
1034
+ issues.push(`RULES-TOO-LONG: Rules 区 ${longOnes.length} 条超 160 字符(如 "${clip(longOnes[0].trim(), 40)}");展开写进概念页,这里只留短句 + [[链接]]`);
1035
+ }
1036
+ }
1037
+
808
1038
  // Done 区堆积:它无上限增长,且 `abs todo`/`abs load` 每次全量打印 → 越积越难用。
809
1039
  // (与 hooks.log/wrapup.log 同类问题;那两处有轮转,这里靠 `abs todo archive`。)
810
1040
  // 坑: 曾经写成 brainPath(vault, 'todo.md'),而 vault 已经是 .brain 目录
@@ -867,6 +1097,10 @@ const PAGE_DIRS = ['entities', 'concepts', 'sources', 'syntheses', 'sessions'];
867
1097
  const PAGE_MAX_LINES = 150;
868
1098
  const PAGE_MAX_BYTES = 8 * 1024;
869
1099
 
1100
+ // source 页超龄未提炼的天数阈值(SOURCE-UNDISTILLED)。
1101
+ // 7 天 = 跨过至少一个完整工作周还没人提炼,基本等于被遗忘。
1102
+ const SOURCE_STALE_DAYS = 7;
1103
+
870
1104
  async function listPages(vault) {
871
1105
  let indexText = '';
872
1106
  try {
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
- return [
50
- '# 📋 Todo 看板',
51
- '## Backlog',
52
- '- [ ] 待办任务',
53
- '## Today / In Progress',
54
- '## Blocked',
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(只留近期,旧的迁 log.md/快照)', ...done.length ? 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('### (未标日期)', '', ...undated.flatMap((u) => u.lines), '');
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
  * 注意不能简单"从标题切到文件尾" —— 否则一旦有日期组落在归档区之后(手改/旧数据),