@fanchao8609/agent_brain_sync 1.15.3 → 1.16.1

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 CHANGED
@@ -280,7 +280,7 @@ const subUsage = {
280
280
  '用法:',
281
281
  ' abs todo 看板',
282
282
  ' abs todo add <id> [--note "做什么"] [--section 讨论中|滞留中]',
283
- ' abs todo rename <旧id> --note "<新id>" 给自动登记的占位条目起真名',
283
+ ' abs todo rename <旧id> --note "<新id>" 改任务 id(起错名/不可读时)',
284
284
  ' abs todo note <id> --note "断点/进度"',
285
285
  ' abs todo state <id> --note 进行中|讨论中|滞留中',
286
286
  ' abs todo done <id> [--as 落地|否决|仅方案] [结语文字]',
@@ -348,7 +348,7 @@ const TODO_ACTIONS = {
348
348
  start: 'start', // add 的别名(老习惯保留)
349
349
  note: 'note',
350
350
  state: 'state', // 改行首状态标记:进行中|讨论中|滞留中(原地,不搬区)
351
- rename: 'rename', // 改任务 id:hook 只开 auto-TBD-<会话> 占位,真名由此处补
351
+ rename: 'rename', // 改任务 id:人工起错名、或旧 id 不可读时用
352
352
  done: 'done',
353
353
  };
354
354
 
package/hooks/abs.pi.ts CHANGED
@@ -136,9 +136,23 @@ function resetThrottle(): void {
136
136
  // (表现:嘴上说"先登记",实际没落盘)。故改成描述**意图 + 名字规律**,
137
137
  // 让模型按当前会话实际可见的形态自己挑,不去猜死一个。
138
138
  const TODO_GUIDELINES = [
139
- 'Use the abs task tool to track multi-step work **before** you start it, not after: on the first file edit of a task, call it with action "start" and a short id. (Name varies by host: `mcp__abs` with tool="abs_task", or `mcp__abs__abs_task`, or `abs_task` — use whichever form this session exposes.)',
139
+ // ① 锚点(2026-10-05 照 rpiv-todo 改写):原写 `on the first file edit of a task` ——
140
+ // 实测太晚:那一刻注意力全在"要改什么"上,没人会想起登记。用户实报两次
141
+ // ("侦察阶段全漏"、另一个 pi 会话干脆没登)。rpiv-todo 挂在
142
+ // `immediately after receiving new instructions`(收到指令时)—— 早得多,且
143
+ // 侦察/只读工作(读代码、问需求)也落在"收到指令"之后,能被盖住。
144
+ 'Use the abs task tool as soon as you receive instructions that involve changing this project — including investigation work where you read code or ask questions before any edit. Call it with action "start" and a short id before the work itself, not after. Skip it for pure questions about general knowledge, one-line answers, and conversation. (Tool name varies by host: `mcp__abs` with tool="abs_task", or `mcp__abs__abs_task`, or `abs_task` — use whichever form this session exposes.)',
145
+ // ② 完成即结:不许攒
140
146
  'Mark a task "done" immediately when it finishes — never batch completions at the end of a session.',
147
+ // ③ 断点:跨会话接力靠它
141
148
  'Before starting a task, record the checkpoint with action "note" (which file, which step) so a later session can resume.',
149
+ // ④ 反例(照 rpiv-todo):明确什么情况【不许】标完成 —— 只给正向要求时,
150
+ // 模型倾向于把"我以为做完了"当成完成,结语失真会污染下个会话的判断。
151
+ 'Never mark a task "done" when tests are failing, the implementation is partial, or an error is unresolved — keep it in progress and add a task for the blocker instead.',
152
+ // ⑤ 唯一进行中(同 rpiv-todo):看板要能回答"现在在做什么",多的应转讨论中/滞留中。
153
+ 'Keep exactly one task in "进行中" at a time; use action "state" with 讨论中 or 滞留中 for anything else that is open. If you finish one and another is ready, promote it explicitly.',
154
+ // ⑥ 结语要真实:三种结语各有含义,别一律写落地。
155
+ 'When completing, pick the honest conclusion via the `as` field: 落地 (built and verified) / 否决 (decided against, or built then reverted) / 仅方案 (designed only). A wrong conclusion makes the next session treat "considered" as "completed".',
142
156
  ]
143
157
 
144
158
  /** 当前项目是否有 .brain 图谱 —— 有才加指引(没图谱的项目里这指引是噪音)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.15.3",
3
+ "version": "1.16.1",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -121,7 +121,7 @@ abs rule [add "一句话"] # 读写 ## Rules;abs lint 体检;abs t
121
121
  结论:hook 只会"看到动过文件",不知道"这是件什么事";名字必须由读懂上下文的你起。
122
122
  - 名字要像 `fix-skill-trigger` / `fix-opencode-v2-plugin` 那样说清**在干什么**,
123
123
  不是抄用户的原话(实测抄出来的是 `我已经重启测试一下` 这种开场白)。
124
- - 需要改已有任务的 id:`abs todo rename <旧id> --note "<新id>"`(描述同步换掉)。
124
+ - 需要改已有任务的 id:`abs todo rename <旧id> --note "<新id>"`。
125
125
  2. **一段活儿干完立刻 done** —— 不是等整个需求收尾。宁可拆成 5 条小的,别攒成 1 条大的。
126
126
  3. **动手超过两三轮还没登记 = 已在失控路上** —— 立刻补 `start`。
127
127
 
package/src/lint.js CHANGED
@@ -4,7 +4,7 @@ import { promises as fs } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { requireBrain, brainPath, BRAIN_DIR } from './index.js';
6
6
  import { readRules, idOfPage, statusOfPage, supersededByOf, clip } from './store.js';
7
- import { doneKindOf, doneDateOf, checkFileShape } from './todo.js';
7
+ import { doneKindOf, doneDateOf, checkFileShape, TODO_MAX_LINES, LOG_MAX_LINES, INDEX_MAX_LINES } from './todo.js';
8
8
 
9
9
  // ---------- 图谱遍历 ----------
10
10
 
@@ -105,9 +105,6 @@ export async function listPages(vault) {
105
105
  // 为什么补这段(2026-10-05):lint 此前只看 PAGE_DIRS 子目录,三个根文件不在扫描范围,
106
106
  // OVER-SIZE 也只对 concepts/entities/syntheses 生效。于是「todo.md 涨到上百行、
107
107
  // 断点行里塞实施报告」可以 lint 报 0 问题 —— 看着健康,实际已经变成笔记本。
108
- const TODO_MAX_LINES = 60;
109
- const LOG_MAX_LINES = 2000;
110
- const INDEX_MAX_LINES = 200;
111
108
  const LINT_BREAKPOINT_MAX = 200;
112
109
 
113
110
  async function checkRootFiles(vault) {
package/src/note.js ADDED
@@ -0,0 +1,275 @@
1
+ // src/note.js — 经验实时暂存与知识页脚手架:note(一念一落)/ concept(骨架)/ person(人页)
2
+ // + registerInIndex(把新页登记进 index.md 清单区,三块都用到)。
3
+ //
4
+ // 从 store.js 拆出(2026-10-05):原来 18 个职责挤在一个 1500+ 行文件里。
5
+ // 依赖方向:note.js ← store.js(store 先 import 再 re-export,保持既有调用面)。
6
+ import { promises as fs } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { requireBrain, brainPath } from './index.js';
9
+ import { requireUser, atTag } from './userconfig.js';
10
+ import { today, localStamp, LOG_KINDS } from './todo.js';
11
+ import { editFile, SKIP } from './lock.js';
12
+ import { collapseIndex } from './page.js';
13
+ import { impactOf } from './codegraph.js';
14
+ import { clip, slugOf } from './text.js';
15
+
16
+ // ---------- note: 经验实时暂存(source 页,一念一落,防流失) ----------
17
+ const NOTE_DEDUP_MS = 60 * 1000;
18
+
19
+ export async function cmdNote({ dir, text, tags, when, impact, type }) {
20
+ const clean = String(text || '').trim();
21
+ if (!clean) return '用法: abs note "经验/坑/技巧一句话" [--when "何时该读它"](落 sources/ 暂存页,实时不流失)';
22
+ let root;
23
+ try {
24
+ root = await requireBrain(dir || process.cwd());
25
+ } catch {
26
+ return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
27
+ }
28
+ const who = await requireUser(); // 写操作守卫
29
+ await ensurePersonPage(root, who); // 首次写操作即建人页(已存在不动)
30
+ const srcDir = brainPath(root, 'sources');
31
+ await fs.mkdir(srcDir, { recursive: true });
32
+ // 幂等: 同文本 60s 内只落一份
33
+ const existing = (await fs.readdir(srcDir).catch(() => [])).filter((f) => f.endsWith('.md'));
34
+ for (const f of existing) {
35
+ const body = await fs.readFile(join(srcDir, f), 'utf8').catch(() => '');
36
+ if (body.includes(clean)) {
37
+ return `• 60s 内已落同文本 → ${f} (跳过重复)`;
38
+ }
39
+ }
40
+ // 影响面(可选):显式传 --impact <符号> 时,借本机 CodeGraph 拿「改它波及谁」。
41
+ // 失败/未装 codegraph 静默降级为无,绝不阻断 note 落盘。
42
+ const impactText = impact ? await impactOf(impact, root) : null;
43
+ // 类型(可选):借鉴 TencentDB 的 L1 四分类,把自由文本经验分成可分类的资产。
44
+ // 默认不强制(自由文本仍是主体);显式 --type 时才写进 frontmatter,供检索/load 区分。
45
+ // 合法值对齐 L1 四类:fact 事实 / pref 偏好 / constraint 约束 / event 事件。
46
+ const NOTE_TYPES = ['fact', 'pref', 'constraint', 'event'];
47
+ const noteType = NOTE_TYPES.includes(String(type || '').trim().toLowerCase())
48
+ ? String(type).trim().toLowerCase() : '';
49
+ const tagList = String(tags || '').split(',').map((t) => t.trim()).filter(Boolean);
50
+ const fmTags = ['source', ...tagList].join(', ');
51
+ const slugSrc = slugOf(clean);
52
+ const file = `${today()}-${slugSrc || 'note'}.md`;
53
+ const heading = clip(clean, 80); // 页面标题: 完整优先, 超长才收口
54
+ // 触发条件(2026-09-16):经验"写入多读得少"的根因之一是存的是结论、不是"何时该看"。
55
+ // 带上 --when 后,load 的相关页推荐能按当前在做的事匹配,而不是按主题词。
56
+ const whenText = String(when || '').trim();
57
+ const body = [
58
+ '---',
59
+ `tags: [${fmTags}]`,
60
+ `id: ${file.replace(/\.md$/, '')}`,
61
+ `author: ${who}`,
62
+ `updated: ${today()}`,
63
+ 'status: draft',
64
+ ...(noteType ? [`type: ${noteType}`] : []),
65
+ '---',
66
+ '',
67
+ `# 来源:${heading}`,
68
+ '',
69
+ `TITLE: ${clean}`,
70
+ ...(whenText ? ['', `WHEN: ${whenText}`] : []),
71
+ ...(impactText ? ['', '## 影响面(本机 CodeGraph 自动带出)', '```', impactText, '```'] : []),
72
+ '',
73
+ `## 记录(实时暂存,Teardown 时提炼进 concepts/ 后本页可删)`,
74
+ `- ${clean}`,
75
+ ...(whenText ? [`- 何时读:${whenText}`] : []),
76
+ '',
77
+ '## 关联连接',
78
+ `- ${atTag(who)} — 本页沉淀者`,
79
+ '(提炼成 concepts 规律页后,在此挂双链到该页)',
80
+ '',
81
+ ].join('\n');
82
+ // 源文件是新写唯一文件:tmp+rename 原子落盘(避免并发读读到半写文件)
83
+ const srcFile = join(srcDir, file);
84
+ const tmp = join(srcDir, `.${file}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`);
85
+ await fs.writeFile(tmp, body, 'utf8');
86
+ await fs.rename(tmp, srcFile);
87
+ // index Sources 区登记(锁内幂等:别页已登记则跳过,防并发重复) + log 一行
88
+ const slug = file.replace(/\.md$/, '');
89
+ await registerInIndex(root, 'Sources', slug, heading);
90
+ await cmdLog({ dir: root, title: clean, kind: 'note' });
91
+ return `✓ 经验暂存 → sources/${file}\n ${clean} ${atTag(who)}`;
92
+ }
93
+
94
+ // ---------- concept: 概念页脚手架(给「写入」定结构,不替人做判断) ----------
95
+ /** 建一张带骨架的概念页。
96
+ *
97
+ * 为何需要它(实测 2026-09-15): concepts/ 页原来**没有任何代码写入路径** ——
98
+ * 全靠人/AI 手写 markdown,结果 26 页里 2 页完全没有「做完怎么确认」。
99
+ * 而骨架只写在 skill 的**文字里**("触发场景/表现/解法/验证命令"),没有执行点 → 看运气。
100
+ * 对照: `abs note` 落的 source 页结构整齐,因为模板在**代码里**。
101
+ *
102
+ * 边界(关键): 它只给**结构**,不给**内容**。
103
+ * 「这条值不值得留 / 归哪一页」仍靠人判断 —— 那是 skill 明写的分工(深提炼不自动化)。
104
+ * 所以本命令不猜语义、不自动提炼,只在你要新建页时把该有的位置摆好。
105
+ *
106
+ * 尾巴用「占位符」而非真实值: 这样 lint 的 NO-TAIL 判据在占位未填时仍会报
107
+ * (骨架≠完成)。填完删掉占位行即可。 */
108
+ export async function cmdConcept({ dir, slug, title, tags, desc }) {
109
+ let root;
110
+ try {
111
+ root = await requireBrain(dir || process.cwd());
112
+ } catch {
113
+ return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
114
+ }
115
+ const raw = String(slug || '').trim();
116
+ if (!raw) {
117
+ return [
118
+ '用法: abs concept <slug> --title "一句话标题" [--tags a,b] [--desc "index 里的一句话"]',
119
+ ' 例: abs concept docker-prisma-429 --title "Docker 内存超限导致 Prisma 429"',
120
+ ' 说明: 只给骨架(头/中/尾位置),内容仍由你写 —— 判断不自动化。',
121
+ ].join('\n');
122
+ }
123
+ // slug 即文件名(命名即链接)。收口掉路径分隔符与空白,防逃出 concepts/。
124
+ const name = raw.replace(/[\s/\\]+/g, '-').replace(/[^\w\u4e00-\u9fff.-]/g, '').replace(/^-+|-+$/g, '');
125
+ if (!name) return `✗ slug 无效(清洗后为空): ${raw}`;
126
+ const who = await requireUser();
127
+ await ensurePersonPage(root, who);
128
+ const dirP = brainPath(root, 'concepts');
129
+ await fs.mkdir(dirP, { recursive: true });
130
+ const file = join(dirP, `${name}.md`);
131
+ const head = String(title || '').trim() || name;
132
+ const tagList = ['concept', ...String(tags || '').split(',').map((t) => t.trim()).filter(Boolean)];
133
+ const body = [
134
+ '---',
135
+ `tags: [${tagList.join(', ')}]`,
136
+ `id: ${name}`,
137
+ `author: ${who}`,
138
+ `updated: ${today()}`,
139
+ 'status: draft',
140
+ '---',
141
+ '',
142
+ `# 概念:${head}`,
143
+ '',
144
+ '## 触发场景',
145
+ '<!-- 什么情况下该想起这条?(写可检索的词,别只写“遇到问题”) -->',
146
+ '',
147
+ '## ❌ 表现',
148
+ '<!-- 具体症状 / 贴报错 / 复现条件 -->',
149
+ '',
150
+ '## 🛠 解法',
151
+ '<!-- 根因 + 修复 -->',
152
+ '',
153
+ '## 验证',
154
+ '<!-- 做完怎么确认?跑什么命令 / 看什么信号 / 用什么判据。必须填 —— 没尾巴的经验只能被“相信”,不能被“验证” -->',
155
+ '',
156
+ '## 关联连接',
157
+ `- ${atTag(who)} — 本页沉淀者`,
158
+ '(在这挂相关页双链,别留孤岛)',
159
+ '',
160
+ ].join('\n');
161
+ // 独占写(wx):已存在则 EEXIST —— 与 ensurePersonPage 同路数。
162
+ // 不用「先查后写」:那有 TOCTOU 竞态,且已有人工内容一律不覆盖是本仓硬规则。
163
+ // 也不走 tmp+rename:rename 会默默覆盖已存在文件,而这里必须「存在就拒绝」。
164
+ try {
165
+ await fs.writeFile(file, body, { encoding: 'utf8', flag: 'wx' });
166
+ } catch (e) {
167
+ if (e.code === 'EEXIST') {
168
+ return `• 已存在,不覆盖 → .brain/concepts/${name}.md\n 要改请直接编辑(或先删页);新建请换个 slug。`;
169
+ }
170
+ throw e;
171
+ }
172
+ const oneLine = String(desc || '').trim() || clip(head, 60);
173
+ await registerInIndex(root, 'Concepts', name, oneLine);
174
+ // 不写 log.md(2026-10-05 用户定):`新建概念页 x` 是**命令的副作用**不是成果 ——
175
+ // 38 字符、零信息量,且「该页存在」已由 registerInIndex 落在 index.md 的 Concepts 区
176
+ // (那是 index 的职责)。同件事落两处,且建 10 个页 = 10 行流水噪声自动重现,
177
+ // 靠事后清理治不了。故删掉这次调用,不加开关(没人需要读「某页被创建了」)。
178
+ return `✓ 概念页骨架 → .brain/concepts/${name}.md ${atTag(who)}\n` +
179
+ ' 已给好四段位置;填完内容后:删掉 <!-- --> 占位、按需改 status: active、挂双链。\n' +
180
+ ' 尾部「## 验证」必须填(留空会被 abs lint 报 NO-TAIL)。';
181
+ }
182
+
183
+ // ---------- person: 使用者实体页(首次需要时创建,已存在则不动) ----------
184
+ /** 确保 entities/<name>.md 存在。已存在一律不动(里面的技术栈/特点是人工沉淀的)。
185
+ * 用 `wx` 独占写:并发下后到者拿到 EEXIST 就静默跳过,不覆盖。
186
+ * 失败不抛:建页是附带动作,不能因为它让 todo/log 写不进去。
187
+ * 返回 'created' | 'exists' | 'skip'。 */
188
+ export async function ensurePersonPage(root, name) {
189
+ const nm = String(name || '').trim();
190
+ if (!nm || !/^[\w\u4e00-\u9fff.-]+$/.test(nm)) return 'skip';
191
+ const dir = brainPath(root, 'entities');
192
+ const file = join(dir, `${nm}.md`);
193
+ const body = [
194
+ '---',
195
+ 'tags: [entity, person]',
196
+ `id: ${nm}`,
197
+ `author: ${nm}`,
198
+ `updated: ${today()}`,
199
+ 'status: draft',
200
+ '---',
201
+ '',
202
+ `# ${nm}`,
203
+ '',
204
+ '## 技术栈',
205
+ '<!-- 沉淀时填: 主力语言/框架/工具链。例: TypeScript + Node, 熟悉 MCP 协议与 CLI 工具链 -->',
206
+ '',
207
+ '## 特点 / 工作习惯',
208
+ '<!-- 沉淀时填: 决策偏好、沟通习惯、反复出现的判断倾向。例: 先要方案后动手; 质疑"这需求是否需要存在" -->',
209
+ '',
210
+ '## 名下踩过的坑',
211
+ '(本页被 [[todo]] / [[log]] 里的作者标记引用;沉淀经验时在此挂双链)',
212
+ '',
213
+ ].join('\n');
214
+ try {
215
+ await fs.mkdir(dir, { recursive: true });
216
+ await fs.writeFile(file, body, { encoding: 'utf8', flag: 'wx' });
217
+ } catch (e) {
218
+ if (e.code === 'EEXIST') return 'exists';
219
+ return 'skip';
220
+ }
221
+ // 只有真建成才登记 index(否则 index 指向不存在的页 → INDEX-DEAD-LINK)。
222
+ // 放这里而非各调用点:todo/log/note 三条写路径都要登记,抄三遍必漂。
223
+ await registerInIndex(root, 'Entities', nm, `${nm} — 使用者;技术栈 / 特点 / 名下踩过的坑`);
224
+ return 'created';
225
+ }
226
+
227
+ /** 把新页登记进 index.md 的指定分区(幂等)。供人页/其它程序建页用。 */
228
+ export async function registerInIndex(root, section, slug, desc) {
229
+ const iP = brainPath(root, 'index.md');
230
+ await editFile(iP, (index) => {
231
+ if (!index || index.includes(`[[${slug}]]`)) return SKIP;
232
+ const sIdx = index.indexOf(`## ${section}`);
233
+ if (sIdx === -1) return SKIP;
234
+ const after = index.indexOf('\n## ', sIdx + 1);
235
+ const line = `- [[${slug}]] — ${desc}`;
236
+ const next = after === -1
237
+ ? `${index.replace(/\s*$/, '')}\n${line}\n`
238
+ : index.slice(0, after) + `\n${line}` + index.slice(after);
239
+ // 归一空行:历史手工编辑会留 3+ 空行(load 时 collapseIndex 会压掉,但文件本身没清)。
240
+ // 追加新条目的同时顺手压一次,既清旧债又不改内容(与 collapseIndex 同一判据)。
241
+ return { text: next.replace(/\n{3,}/g, '\n\n') };
242
+ });
243
+ }
244
+ // ---------- log: 追加工作成果沉淀摘要(用户/AI 主动 abs log "..." 记, 不收工具动作流水) ----------
245
+ export async function cmdLog({ dir, title, kind = 'dev' }) {
246
+ const root = await requireBrain(dir || process.cwd());
247
+ const who = await requireUser(); // 写操作守卫
248
+ // ★ kind 必须是枚举值(2026-10-05 加):此前无校验,传什么写什么 ——
249
+ // 实测有测试传 kind:'test' 写进去,而形状闸门上线后才暴露。
250
+ // 枚举内校在**入口**(这里)比事后 lint 更早,且报错能直接告诉可用值。
251
+ if (!LOG_KINDS.includes(String(kind))) {
252
+ throw new Error(
253
+ `✗ log 的 kind 只能是 ${LOG_KINDS.join(' / ')}(收到 "${kind}")\n` +
254
+ ` note=经验/踩坑 / dev=完成的工作 / concept=新建概念页 / ingest=沉淀资料`,
255
+ );
256
+ }
257
+ await ensurePersonPage(root, who); // 首次写操作即建人页(已存在不动)
258
+ const p = brainPath(root, 'log.md');
259
+ const stamp = localStamp();
260
+ // 不硬切: log.md 是人类读的成果摘要, 也是 abs load 的开机入口。600 码点够一条完整小结,
261
+ // 超出才在语义边界收口(曾 slice(0,100) → 34/85 条断在词中间)
262
+ const clean = clip(String(title || '').replace(/\n/g, ' '), 600);
263
+ // 作者前置于 kind:`## [时间] @name dev | 内容`。
264
+ // 一眼先看到谁做的(与 todo 行 `ID @name — 说明` 排版对齐)。
265
+ const line = `## [${stamp}] ${atTag(who)} ${kind} | ${clean}`;
266
+ await editFile(p, (cur) => {
267
+ const text = cur ?? '# 🗒 Activity Log\n';
268
+ // 倒序:新行插在标题后(若已是模板占位行则替换它)
269
+ const lines = text.split('\n');
270
+ const headerIdx = lines.findIndex((l) => l.startsWith('#'));
271
+ lines.splice(headerIdx + 1, 0, line);
272
+ return { text: lines.join('\n') };
273
+ });
274
+ return `✓ log → ${p}\n ${line}`;
275
+ }
package/src/page.js ADDED
@@ -0,0 +1,263 @@
1
+ // src/page.js — 知识页的生命周期:id 冻结 / 状态机 / 推翻 / 待确认队列 / 引用反查。
2
+ //
3
+ // 从 store.js 拆出(2026-10-05):原单文件 1509 行 / 18 职责,读改都吃力。
4
+ // 拆法照 rpiv-todo 的规模(单文件 ≤300 行)。本文件只放"对单页的操作",
5
+ // 不含加载/看板/任务那几块(那些留在 store.js)。
6
+ //
7
+ // 依赖方向:page.js ← store.js(不可反向)。store.js 里的 cmdSupersede /
8
+ // cmdReview / cmdResolve 会 re-export 本文件的实现,保持既有调用面不变。
9
+ import { promises as fs } from 'node:fs';
10
+ import { join, resolve, dirname } from 'node:path';
11
+ import { requireBrain, brainPath, BRAIN_DIR } from './index.js';
12
+ import { editFile, SKIP } from './lock.js';
13
+ // PAGE_DIRS / listPages 早先就在 lint.js(页面清单遍历是 lint 的职责),此处复用而非重写。
14
+ import { PAGE_DIRS, listPages } from './lint.js';
15
+
16
+ // ---------- page id: 改名不改引用 ----------
17
+ // 问题:`.brain` 内部引用靠 [[slug]],而 slug 就是文件名 —— 改一次文件名,
18
+ // 所有指向它的链接静默变成 DEAD-LINK,只能靠 lint 事后抓。
19
+ // 解法(最小代价):页面 frontmatter 写一行 `id:`,建页时冻结。
20
+ // • 不发明新编号:id 默认等于建页时的 slug(人可读、可手写、无需迁移)
21
+ // • 旧页无 id → 回退用 slug,因此存量 31 页零迁移
22
+ // • 改名后 slug 变而 id 不变 → lint 报 ID-DRIFT,提示改成谁
23
+ // 为什么不上内容哈希/uuid:哈希一改内容就变(比文件名还不稳定),
24
+ // uuid 不可读不可手写且要全量迁移。slug 就是最合适的 id,只要不再跟文件名跑。
25
+ const ID_RE = /^id:\s*(.+)$/m;
26
+
27
+ /** 读页面 id;无 id 行则回退 slug(存量页零迁移)。 */
28
+ export function idOfPage(body, slug) {
29
+ const m = String(body || '').match(ID_RE);
30
+ return m ? m[1].trim() : slug;
31
+ }
32
+
33
+ /** 给存量页补 id(只在缺时写),落 frontmatter。返回 'added' | 'exists' | 'no-fm'。 */
34
+ export async function backfillPageId(full, slug) {
35
+ const res = await editFile(full, (cur) => {
36
+ if (!cur || !cur.startsWith('---\n')) return SKIP;
37
+ if (ID_RE.test(cur.split('\n---')[0])) return SKIP; // 已有 id 不动
38
+ const end = cur.indexOf('\n---', 3);
39
+ if (end === -1) return SKIP;
40
+ return { text: `${cur.slice(0, end)}\nid: ${slug}${cur.slice(end)}` };
41
+ });
42
+ return res === SKIP ? 'exists' : 'added';
43
+ }
44
+
45
+ // ---------- page status: 经验/知识页的生命周期 ----------
46
+ // 问题:经验写进去就永远躺在那里 —— 推翻时删不掉(skill 里写着"人工内容一律不覆盖",
47
+ // AI 不敢删)、读的时候又看不见(load 只给分区计数)→ 旧经验持续骗下一个会话。
48
+ // 解法:给已有的 `status:` 字段(字段本来就存在,20 页在用)定死三个值:
49
+ // active 当前有效(缺字段的默认值 —— 存量 22 页零迁移)
50
+ // superseded 已被推翻,别再依据它 —— 配 superseded-by 指向取代它的页
51
+ // draft 待核实(abs note 新落的经验就是这个)
52
+ // 关键:推翻 = 改一行 frontmatter,**不删文件不丢历史** —— AI 敢做,人也能反悔。
53
+ // 为什么不用新字段/新目录:字段已存在且有存量值,重命名会另起一套双轨(同 OPTS-DOUBLE-KEYS 之病)。
54
+ export const PAGE_STATUS = ['active', 'superseded', 'draft'];
55
+ const STATUS_RE = /^status:\s*(\S+)\s*$/m;
56
+ const SUPERSEDED_BY_RE = /^superseded-by:\s*(.+)$/m;
57
+
58
+ /** 读页面 status;无字段或是未知值时当 active(存量页零迁移)。 */
59
+ export function statusOfPage(body, frontmatter) {
60
+ const fm = frontmatter !== undefined
61
+ ? frontmatter
62
+ : (String(body || '').match(/^---\n([\s\S]*?)\n---/) || ['', ''])[1];
63
+ const m = String(fm).match(STATUS_RE);
64
+ const v = m ? m[1].trim() : '';
65
+ return PAGE_STATUS.includes(v) ? v : 'active';
66
+ }
67
+
68
+ /** 读 superseded-by(只在 status=superseded 时有意义)。无则空串。 */
69
+ export function supersededByOf(body) {
70
+ const m = String(body || '').match(SUPERSEDED_BY_RE);
71
+ return m ? m[1].trim() : '';
72
+ }
73
+
74
+ // ---------- supersede: 标记一条经验被推翻(回退的写入端) ----------
75
+ // 为什么是标记而不是删除:
76
+ // ① 删除后下一个会话会重新踩同一个坑并重新记一遍(历史本身是资产)
77
+ // ② AI 不敢删(人工内容不覆盖),但敢改一行 frontmatter
78
+ // ③ 反悔只需把 status 改回 active
79
+ export async function cmdSupersede({ dir, refs, by }) {
80
+ const list = (refs || []).map((r) => String(r).trim()).filter(Boolean);
81
+ if (!list.length) return '用法: abs supersede <页名或id> [更多…] [--by <取代它的页>] — 标记经验已失效(不删文件)';
82
+ let root;
83
+ try {
84
+ root = await requireBrain(dir || process.cwd());
85
+ } catch {
86
+ return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
87
+ }
88
+ const byRef = String(by || '').trim();
89
+ // 取代者必须先存在 —— 否则写下一个永远悬空的引用(lint 会报,不如现在拒)。
90
+ if (byRef) {
91
+ const target = await resolvePage(root, byRef);
92
+ if (!target) return `✗ --by ${byRef}: 图谱里没有这页(先用 abs resolve 确认页名)`;
93
+ }
94
+ const out = [];
95
+ // 取代者 slug 在循环外解析一次(锁内 mutator 不能 await)
96
+ const bySlug = byRef ? (await resolvePage(root, byRef)).slug : '';
97
+ for (const r of list) {
98
+ const hit = await resolvePage(root, r);
99
+ if (!hit) { out.push(`✗ ${r}: 未找到(试 abs query <词> 或 abs index 看清单)`); continue; }
100
+ const res = await editFile(hit.full, (cur) => {
101
+ if (!cur || !cur.startsWith('---\n')) return SKIP;
102
+ const end = cur.indexOf('\n---', 3);
103
+ if (end === -1) return SKIP;
104
+ let fm = cur.slice(0, end);
105
+ // 幂等:已是 superseded 且 superseded-by 一致 → 不写盘
106
+ const curSt = statusOfPage('', fm);
107
+ const curBy = supersededByOf(cur);
108
+ if (curSt === 'superseded' && curBy === bySlug) return SKIP;
109
+ // 去掉旧的 superseded-by(不论换不换取代者,旧值都作废)
110
+ fm = fm.replace(/\nsuperseded-by:.*(?=\n|$)/g, '');
111
+ fm = STATUS_RE.test(fm)
112
+ ? fm.replace(STATUS_RE, 'status: superseded')
113
+ : `${fm}\nstatus: superseded`;
114
+ // superseded-by 用 slug(不是 id):人直接能按名找页,lint 能直接比对文件名
115
+ if (bySlug) fm = `${fm}\nsuperseded-by: ${bySlug}`;
116
+ return { text: fm + cur.slice(end) };
117
+ });
118
+ out.push(res === SKIP
119
+ ? `= ${hit.slug}: 已是 superseded(无变化)`
120
+ : `✓ ${hit.slug} → superseded${byRef ? ` (被 [[${byRef}]] 取代)` : ''}`);
121
+ }
122
+ return out.join('\n');
123
+ }
124
+
125
+ /** 把 id(或 slug)解析为页面路径。命中返回 {slug, dir, full, id},否则 null。 */
126
+ export async function resolvePage(root, idOrSlug) {
127
+ const want = String(idOrSlug || '').trim();
128
+ if (!want) return null;
129
+ const vault = brainPath(root);
130
+ for (const d of PAGE_DIRS) {
131
+ const p = join(vault, d);
132
+ let files;
133
+ try { files = await fs.readdir(p); } catch { continue; }
134
+ for (const f of files) {
135
+ if (!f.endsWith('.md') || f.startsWith('_')) continue;
136
+ const slug = f.replace(/\.md$/, '');
137
+ const full = join(p, f);
138
+ // slug 直接命中就够快(绝大多数调用走这条),不命中才去读 frontmatter 比 id
139
+ if (slug === want) return { slug, dir: d, full, id: want };
140
+ const body = await fs.readFile(full, 'utf8').catch(() => '');
141
+ const id = idOfPage(body, slug);
142
+ if (id === want) return { slug, dir: d, full, id };
143
+ }
144
+ }
145
+ return null;
146
+ }
147
+
148
+ // ---------- review: 待确认页队列(draft → active/superseded) ----------
149
+ // 为何需要:abs note 落的是 status: draft(未经核实),但之前没有「确认」这一步 ——
150
+ // draft 只是标签,没人管,经验就永远停在「待核实」状态,从不正式化。
151
+ // 借鉴 TencentDB 的 review/route 治理环节:提取后必经审查,防止脏知识进入正式图谱。
152
+ // 本命令只做「把 draft 显式升为 active 或否决为 superseded」,不替人判断内容好坏。
153
+ // 动作收口在一处(editFile 锁内),并发安全同 supersede。
154
+ export async function cmdReview({ dir, refs, action }) {
155
+ let root;
156
+ try {
157
+ root = await requireBrain(dir || process.cwd());
158
+ } catch {
159
+ return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
160
+ }
161
+ const act = String(action || '').toLowerCase();
162
+ if (act && !['accept', 'reject'].includes(act)) {
163
+ return '用法: abs review [--accept <页名…>] [--reject <页名…>] — 无参数列出全部 draft 页';
164
+ }
165
+ // 无动作 → 列出所有 draft 页(待确认队列)
166
+ if (!act) {
167
+ const pages = await listPages(brainPath(root));
168
+ // 只扫经验/知识目录(concepts/sources)。entities 是人页、sessions 是日志,
169
+ // 它们不是「待核实的经验」,不该进 review 队列(拉进来会把人页/日志当经验误确认)。
170
+ const REVIEW_DIRS = ['concepts', 'sources'];
171
+ const drafts = pages.filter((p) => REVIEW_DIRS.includes(p.dir) && statusOfPage(p.body) === 'draft');
172
+ if (!drafts.length) return '✓ 没有待确认的 draft 页。';
173
+ const lines = drafts.map((p) => {
174
+ const t = p.body.match(/^#\s*(.+)$/m);
175
+ const title = t ? t[1].trim() : p.slug;
176
+ return ` [draft] ${p.slug} — ${title}`;
177
+ });
178
+ return [
179
+ `待确认 draft 页 ${drafts.length} 条:`,
180
+ ...lines,
181
+ '',
182
+ '确认: abs review --accept <页名> [更多…] 否决: abs review --reject <页名> [更多…]',
183
+ ].join('\n');
184
+ }
185
+ // 有动作 → 对每个 ref 改 status
186
+ const list = (refs || []).map((r) => String(r).trim()).filter(Boolean);
187
+ if (!list.length) return `✗ --${act} 需要至少一个页名。用法: abs review --${act} <页名…>`;
188
+ const target = act === 'accept' ? 'active' : 'superseded';
189
+ const out = [];
190
+ for (const r of list) {
191
+ const hit = await resolvePage(root, r);
192
+ if (!hit) { out.push(`✗ ${r}: 未找到(试 abs review 看清单)`); continue; }
193
+ const res = await editFile(hit.full, (cur) => {
194
+ if (!cur || !cur.startsWith('---\n')) return SKIP;
195
+ const end = cur.indexOf('\n---', 3);
196
+ if (end === -1) return SKIP;
197
+ let fm = cur.slice(0, end);
198
+ const curSt = statusOfPage('', fm);
199
+ // 幂等:已是目标状态 → 不写盘
200
+ if (curSt === target) return SKIP;
201
+ fm = STATUS_RE.test(fm)
202
+ ? fm.replace(STATUS_RE, `status: ${target}`)
203
+ : `${fm}\nstatus: ${target}`;
204
+ return { text: fm + cur.slice(end) };
205
+ });
206
+ out.push(res === SKIP
207
+ ? `= ${hit.slug}: 已是 ${target}(无变化)`
208
+ : `✓ ${hit.slug} → ${target}`);
209
+ }
210
+ return out.join('\n');
211
+ }
212
+
213
+ // ---------- resolve: id/slug → 页面路径(引用的反查端) ----------
214
+ // 配合 frontmatter 的 id: 使用。页改名后 id 不变,靠本命令仍能找回来。
215
+ export async function cmdResolve({ dir, refs }) {
216
+ const list = (refs || []).map((r) => String(r).trim()).filter(Boolean);
217
+ if (!list.length) return '用法: abs resolve <id-or-slug> [更多…] — 按 id/页面名反查路径';
218
+ let root;
219
+ try {
220
+ root = await requireBrain(dir || process.cwd());
221
+ } catch {
222
+ return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
223
+ }
224
+ const lines = [];
225
+ for (const r of list) {
226
+ const hit = await resolvePage(root, r);
227
+ lines.push(hit
228
+ ? `✓ ${r} → ${BRAIN_DIR}/${hit.dir}/${hit.slug}.md${hit.id !== hit.slug ? ` (id=${hit.id})` : ''}`
229
+ : `✗ ${r}: 未找到(试 abs index 看完整清单,或 abs query <词> 全文搜)`);
230
+ }
231
+ return lines.join('\n');
232
+ }
233
+
234
+ export function collapseIndex(text) {
235
+ const s = String(text || '').trim();
236
+ if (!s) return '';
237
+ const out = [];
238
+ let mode = null; // null=逐行透传(文件头);字符串=当前在计数的分区名
239
+ let n = 0; // mode 非 null 时的清单行计数
240
+ const flush = () => {
241
+ if (mode !== null) out.push(n ? `## ${mode}(${n} 页)` : `## ${mode}`);
242
+ mode = null;
243
+ n = 0;
244
+ };
245
+ let skipping = false; // 跳过 Rules 正文(已在上方单独成段)
246
+ for (const l of s.split('\n')) {
247
+ const m = l.match(/^##\s+(.+?)\s*$/);
248
+ if (m) {
249
+ flush();
250
+ const name = m[1].trim();
251
+ skipping = /Rules?|规则/i.test(name);
252
+ if (skipping) continue;
253
+ mode = name; // 页面清单分区:只计数
254
+ continue;
255
+ }
256
+ if (skipping) continue;
257
+ if (mode === null) out.push(l); // 透传区(含文件头 H1)
258
+ else if (l.trim().startsWith('-')) n++;
259
+ }
260
+ flush();
261
+ // 文件头与首个分区之间可能因跳过 Rules 而留下多余空行
262
+ return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
263
+ }