@fanchao8609/agent_brain_sync 1.10.0 → 1.10.3

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
@@ -32,7 +32,7 @@ abs install --agent pi --no-mcp # 只装 hook + skill,不要 MCP
32
32
  | claude-code | `~/.claude/settings.json` hooks | `mcpServers.abs` (stdio) | `~/.claude/skills/abs-agent-brain-sync/` |
33
33
  | codex | `~/.codex/hooks.json` | config.toml `[mcp_servers.abs]` | `~/.codex/skills/` |
34
34
  | opencode | `~/.config/opencode/plugins/abs.ts` | opencode.json mcp.abs | skills/ |
35
- | pi | `~/.pi/agent/extensions/abs.ts` | `~/.pi/agent/mcp.json` `mcpServers.abs` | `~/.pi/agent/skills/` |
35
+ | pi | `~/.pi/agent/extensions/abs.ts` | `~/.pi/agent/mcp-adapter.json` `mcpServers.abs` | `~/.pi/agent/skills/` |
36
36
 
37
37
  > **skill 规则**:`skill/` 下每个含 `SKILL.md` 的子目录 = 一个 skill,
38
38
  > **目录名即安装名**(须与 frontmatter `name` 一致,否则 pi 会告警)。
@@ -53,6 +53,44 @@ npm install && npm link # 之后全局就有 abs
53
53
 
54
54
  ## 怎么用
55
55
 
56
+ ### pi:编辑器上的 todo 面板
57
+
58
+ pi 宿主额外多一层 UI(其它宿主没有):**编辑器上方常驻一块 todo 面板**,直接读当前项目 `.brain/todo.md`。
59
+
60
+ ```
61
+ ─────────────────────────────────────────────────
62
+ 📋 todo (3) — fanchao · ☕ 靠咖啡续命
63
+ ├─ [进行中] some-task ●●○ — 干活中
64
+ │ ↳ 断点: hooks/abs.pi.ts:120
65
+ ├─ [滞留中] waiting — 等外部输入
66
+ └─ [进行中] another ●●● — …
67
+ ```
68
+
69
+ - **只显示未完成**,Done 归计数不占位(数量在标题里)
70
+ - **按当前使用者过滤**:显示 `[[我]]` 的 + 没标作者的(老任务/手写),别人的不显示
71
+ - **断点行 `↳` 挂在父任务下**;`├─` / `└─` 表结构
72
+ - **「进行中」带三点动画**`○○○ → ●●○ → ●●●`(250ms/帧)—— 一眼看出哪条在跑
73
+ - **实时刷新**:我调 `abs_task` / `abs_board` 后立即重画,不用等我讲完话
74
+ - **上描边与输入框同色满宽**(主题色 `thinkingOff`,跟 pi 输入框的边框一致)
75
+ - 窄终端按显示宽度截断(CJK 计 2 列),不溢出
76
+
77
+ **每次启动随机昵称**(132 条,附在作者名后)—— 每次打开 pi 换一条;池子分三批:
78
+ 日常作息吃喝摸鱼 / 职场抱怨 / 自嘲(`编程全靠蒙`、`AI救我狗命`)。
79
+
80
+ | 环境变量 | 作用 |
81
+ |---|---|
82
+ | `ABS_TODO_PANEL=0` | 关掉面板 |
83
+ | `ABS_TODO_NICK=0` | 关掉随机昵称 |
84
+ | `ABS_TODO_GUIDE=0` | 关掉 system prompt 里的 todo 登记指引 |
85
+
86
+ > **设计取舍**:面板是**纯展示层**,只读 `todo.md` 不写任何东西,也不建第二套状态 ——
87
+ > 数据源就是 abs 自己的看板(磁盘文件,跨会话可续接)。所以没有折叠快捷键、没有依赖图,
88
+ > 只有 ~50 行渲染代码;对比 rpiv-todo 的 ~1800 行(它把状态存会话 transcript,新会话会丢)。
89
+ >
90
+ > 另一层是**触发指引**:扩展往 system prompt 的 Guidelines 段注入 3 条静态条目
91
+ > (动手前 `start` / 完成立刻 `done` / 断点及时 `note`)。这是**静态 prompt 内容**,
92
+ > 不是往对话里插消息 —— 本项目删过两次「插话式提醒」(会抢 turn 打断用户)。
93
+
56
94
  ### 项目里开一次
57
95
 
58
96
  ```bash
@@ -89,6 +127,9 @@ abs update # 升级到最新版并刷新四宿主
89
127
  > `abs todo start` 与 `abs todo add` 等价(都登记任务)。
90
128
  > 旧版 `abs task ...` / `abs board` 已改名,会报错并提示新写法。
91
129
  > `abs wrapup` / `abs teardown-check` 是 hook 内部命令,无需手动调用。
130
+ > **作者名要填真的**:`tester` / `foo` / `aaa` 这类占位名会被拒 —— 因为 `{user}` 是全局单值,
131
+ > 填错会污染之后所有项目的 `[[作者]]` 标记(`aaa` 这类堆字也拒,但 `oo`/`ee` 这种两字母缩写放行)。
132
+ > `abs load` 对已落盘的占位名会给出警告,提示改回真名。
92
133
  > **升级后分区名自动归一**:`abs load` 每次都会顺手核对 `index/log/todo` 三文件结构,旧的分区名(如 `# 🗂 图谱索引` → `# 🗂 Graph Index`)会被自动改回标准;缺分区自动补建,无头文件只提醒不自动改。
93
134
 
94
135
  ### 工作流
@@ -151,11 +192,12 @@ agent_brain_sync/
151
192
  ├── src/store.js CLI 命令实现
152
193
  ├── src/hosts.js 四宿主接入定义
153
194
  ├── src/install.js 安装/卸载(分区共存合并)
154
- ├── src/userconfig.js 使用者姓名配置(作者标记)
195
+ ├── src/userconfig.js 使用者姓名配置(作者标记;占位名如 tester/foo 会被拒)
155
196
  ├── src/wrapup.js Stop 收尾快照/归档
156
197
  ├── hooks/event.sh hook 模板
198
+ ├── hooks/abs.pi.ts pi 扩展模板(含 todo 面板 / 随机昵称 / 常驻指引)
157
199
  ├── skill/<名称>/SKILL.md 技能(每个子目录 = 一个 skill,装到各智能体)
158
- └── test/ 单测
200
+ └── test/ 单测(400+,node:test)
159
201
  ```
160
202
 
161
203
  MIT
package/hooks/abs.pi.ts CHANGED
@@ -82,10 +82,18 @@ function resetThrottle(): void {
82
82
  // 若将来要扩展,也绝不能退化成插话。
83
83
  //
84
84
  // 关掉即设 ABS_TODO_GUIDE=0。
85
- // 工具名写 `abs_task`(不带 mcp__abs__ 前缀)—— 2026-10-03 审查发现:MCP 默认
86
- // exposure=codemode,模型侧看到的就叫 abs_task;写错名字等于让模型去找不存在的工具。
85
+ //
86
+ // 工具名**不能写死**(2026-10-04 实报修正):abs 走 MCP,而 MCP 工具在模型侧的
87
+ // 名字随宿主的 exposure 配置变,至少三种实测形态 ——
88
+ // ① pi-mcp-adapter 的 namespace 模式:顶层只有代理入口 `mcp__abs`,
89
+ // 子工具名要作为 `tool` 参数传(namespace-tools.ts 只注册 mcp__<server> 一个)。
90
+ // ② 内建 MCP 直出:工具名就是 `mcp__abs__abs_task`。
91
+ // ③ codemode / 直出形态:子工具直接叫 `abs_task`。
92
+ // 上一版写死 `abs_task` → 在 ① 下模型去找一个不存在的顶层工具,指引等于空转
93
+ // (表现:嘴上说"先登记",实际没落盘)。故改成描述**意图 + 名字规律**,
94
+ // 让模型按当前会话实际可见的形态自己挑,不去猜死一个。
87
95
  const TODO_GUIDELINES = [
88
- 'Use `abs_task` to track multi-step work **before** you start it, not after: on the first file edit of a task, call action "start" with a short id.',
96
+ '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.)',
89
97
  'Mark a task "done" immediately when it finishes — never batch completions at the end of a session.',
90
98
  'Before starting a task, record the checkpoint with action "note" (which file, which step) so a later session can resume.',
91
99
  ]
@@ -455,9 +463,18 @@ export function renderPanelLines(
455
463
  if (data.total === 0) return []
456
464
  const colorOf = (state: string): string => (state === '滞留中' ? 'muted' : state === '讨论中' ? 'dim' : 'accent')
457
465
  const lines: string[] = []
458
- // 上描边:一条深灰横线,把面板与上方内容分开。
459
- // 留 1 列余量 —— 终端对“恰好占满宽度”的行有时会折行(各终端行为不一致)。
460
- lines.push(fg('dim', '─'.repeat(Math.max(0, width - 1))))
466
+ // 上描边:用 `thinkingOff` —— 跟 Pi 输入框描边**完全同一个色**。
467
+ // 排查过程(2026-10-03 用户两轮反馈):
468
+ // ① 用 dim = okhsl(229 8% 56%) → 用户说“太亮”(比输入框亮 7%)
469
+ // ② 改 border = okhsl(231 57% 65%) → 用户说“怎么是蓝色的”(高饱和度蓝紫)
470
+ // ③ 真相:输入框的描边是 theme.getThinkingBorderColor(level),level=off 时
471
+ // 就是 thinkingOff = okhsl(229 8% 49%)(纯灰低饱和)。
472
+ // 教训:看到“跟宿主某个 UI 元素一致”的需求,必须去宿主源码查那个元素
473
+ // **实际**用了哪个主题色,不能按名字猜(border 听着像框线,其实不是输入框那个)。
474
+ // 满宽(不 -1)—— 对齐 pi 自己的 DynamicBorder.render:`"─".repeat(Math.max(1, width))`。
475
+ // 曾写 width-1 怕折行,但 pi 自己就满宽画,用户实报“右侧缺一小块”。
476
+ // 且我们传入的 fg() 包了一层 ANSI,ANSI 不占列宽,不会因此溢出。
477
+ lines.push(fg('thinkingOff', '─'.repeat(Math.max(1, width))))
461
478
  // 昵称附在作者名后(会话内固定,启动时随机抽)。
462
479
  const whoPart = who ? who + (nickname ? ` · ${nickname}` : '') : nickname
463
480
  const title = whoPart ? `📋 todo (${data.total}) — ${whoPart}` : `📋 todo (${data.total})`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.10.0",
3
+ "version": "1.10.3",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/lock.js CHANGED
@@ -8,6 +8,12 @@ import { join, dirname, basename } from 'node:path';
8
8
  export const SKIP = Symbol('editFile.skip');
9
9
 
10
10
  export class LockTimeout extends Error {}
11
+
12
+ /** 写入侧格式闸门(由 todo.js 注入,避免循环依赖)。
13
+ * 对 .brain 的 index/todo/log 三个文件,写盘前统一过一遍格式校验。
14
+ * 返回 null = 该文件不归闸门管;否则返回整理后的 text。 */
15
+ let formatGate = null;
16
+ export function setFormatGate(fn) { formatGate = fn; }
11
17
  const LOCK_WAIT_BASE_MS = 15; // 指数退避起始重试间隔
12
18
  const LOCK_WAIT_MAX_MS = 150; // 指数退避上限
13
19
  // 抢锁总预算:排队等锁的进程须依次排完。多进程高并发(CLI/MCP/hook 同刻抢一文件)下,
@@ -67,7 +73,10 @@ export async function editFile(file, mutator, { maxWaitMs = LOCK_MAX_WAIT_MS } =
67
73
  try { current = await fs.readFile(file, 'utf8'); } catch { /* 尚无文件 */ }
68
74
  const res = await mutator(current);
69
75
  if (res === SKIP) return SKIP;
70
- const write = typeof res === 'string' ? res : res && typeof res.text === 'string' ? res.text : null;
76
+ let write = typeof res === 'string' ? res : res && typeof res.text === 'string' ? res.text : null;
77
+ // 格式闸门:所有写 index/todo/log 的路径(CLI/MCP/hook 共 ~15 处)都在此收口,
78
+ // 不必逐个改调用点 —— 破坏格式的写入在这里被整理回标准形态(见 todo.js)。
79
+ if (write && formatGate) write = formatGate(file, write) ?? write;
71
80
  if (write && write !== current) {
72
81
  await fs.writeFile(file, write, 'utf8');
73
82
  }
package/src/store.js CHANGED
@@ -4,8 +4,8 @@ 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, placeholderWarn } from './userconfig.js';
7
- import { stripStateMark, ensureStateMark, normalizeTodo, addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, setStateMark, TASK_STATES, insertDoneGrouped, idOfTaskLine, archiveDoneInText, upsertArchiveSection, DONE_KINDS, withDoneKind, doneKindOf, doneDateOf, collapseDone, SEC, rebuildStructure } from './todo.js';
8
- import { editFile, SKIP } from './lock.js';
7
+ import { stripStateMark, ensureStateMark, normalizeTodo, addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, setStateMark, TASK_STATES, insertDoneGrouped, idOfTaskLine, archiveDoneInText, upsertArchiveSection, DONE_KINDS, withDoneKind, doneKindOf, doneDateOf, collapseDone, SEC, rebuildStructure, enforceBrainFormat } from './todo.js';
8
+ import { setFormatGate, editFile, SKIP } from './lock.js';
9
9
  import { appendWrapup, strandedFor } from './wrapup.js';
10
10
  import { keywords, pickRelevant, renderRelevant, recentFiles, rankPage, topicStrength } from './relevant.js';
11
11
  import { impactOf } from './codegraph.js';
@@ -113,6 +113,26 @@ function resolveProjectDir(dir) {
113
113
  return resolve(dir || process.cwd());
114
114
  }
115
115
 
116
+ /** 全套旧标记 → 标准标记(含 H1)。enforceBrainFormat 按整行精确匹配,
117
+ * 所以 H1 也得在表里 —— 曾经只传 `## `/`### ` 前缀的那部分,
118
+ * 因为 H1 归一当时归 rebuildStructure 管;但 log.md 的 order 为空、不走 rebuildStructure,
119
+ * 于是它的旧 H1 成了唯一没人归一的路径(实测「# 🗒 操作日志」永不修正)。
120
+ * 现在统一由 enforceBrainFormat 归一,表就给全套。 */
121
+ const legacyRenames = () => LEGACY_MARKS;
122
+
123
+ /** 注册写入侧格式闸门:lock.js 的 editFile 是所有写入的唯一收口,
124
+ * 这里把「哪个文件名用哪套标准」告诉它 —— 三个目标文件之外的写入一律放行。
125
+ * 直接 mutate 传入的 text 再返回;非目标文件返回 null 表示不管。 */
126
+ setFormatGate((file, text) => {
127
+ const name = file.split('/').pop();
128
+ const spec = BRAIN_SHAPE[name];
129
+ if (!spec) return null;
130
+ const r = enforceBrainFormat(text, { ...spec, renames: legacyRenames() });
131
+ return name === 'todo.md'
132
+ ? r.text.split('\n').map((l) => (l.startsWith('- [ ] ') ? ensureStateMark(l, '进行中') : l)).join('\n')
133
+ : r.text;
134
+ });
135
+
116
136
  export function indexTemplate() {
117
137
  // 同 todoTemplate:由 rebuildStructure 生成,模板 = 重排结果,不会来回抖。
118
138
  return rebuildStructure(
@@ -220,27 +240,17 @@ export async function checkBrainShape(root) {
220
240
  let changed = [];
221
241
  await editFile(p, (cur) => {
222
242
  if (cur === null) return SKIP;
223
- let next2 = cur;
224
- if (spec.order.length) {
225
- // todo.md 的四区 → 两区迁移**必须先走 normalizeTodo**:只有它知道
226
- // `## Blocked` 区的任务该标 [滞留中](语义信息),而 rebuildStructure 只按
227
- // renames 改标题、看不到来源分区,只能一律给 [进行中]。
228
- // 2026-09-13 实测坑:cmdLoad 先跑 checkBrainShape、后跑 readTodo,于是
229
- // normalizeTodo 的 [滞留中] 映射在 load 路径上永远走不到 → 卡住的任务
230
- // 被静默标成进行中,两条路径语义不一致。
231
- const srcText = file === 'todo.md' ? normalizeTodo(cur) : cur;
232
- const r0 = rebuildStructure(srcText, { ...spec, renames: LEGACY_MARKS.filter(([o]) => o.startsWith('## ') || o.startsWith('### ')) });
233
- // 兜底:仍无状态标记的未完成任务补默认值(新格式文件本就有标记,此处不触发)。
234
- next2 = file === 'todo.md'
235
- ? r0.text.split('\n').map((l) => (l.startsWith('- [ ] ') ? ensureStateMark(l, '进行中') : l)).join('\n')
236
- : r0.text;
237
- } else {
238
- next2 = fixMarks(cur, spec).text;
239
- }
240
- const r = { text: next2, changed: next2 === cur ? [] : ['结构按标准重排'] };
241
- if (!r.changed.length) return SKIP;
242
- changed = r.changed;
243
- return { text: r.text };
243
+ // 格式闸门收口在一处(见 todo.js enforceBrainFormat):样式/空行/无头 三条规则
244
+ // 与写入侧共用同一实现,写盘前顺手把破坏格式的部分整理回标准形态。
245
+ // 走 normalizeTodo 的语义(Blocked → [滞留中])由 enforceBrainFormat 内部保证。
246
+ const r = enforceBrainFormat(cur, { ...spec, renames: legacyRenames() });
247
+ // 兜底:仍无状态标记的未完成任务补默认值(新格式文件本就有标记,此处不触发)。
248
+ const next2 = file === 'todo.md'
249
+ ? r.text.split('\n').map((l) => (l.startsWith('- [ ] ') ? ensureStateMark(l, '进行中') : l)).join('\n')
250
+ : r.text;
251
+ if (next2 === cur) return SKIP;
252
+ changed = r.fixed.length ? r.fixed : ['结构按标准重排'];
253
+ return { text: next2 };
244
254
  }).catch(() => {});
245
255
  if (changed.length) fixed.push(`${file}: ${changed.join('; ')}`);
246
256
  if (!knownH1) warn.push(`${file}: 标题非标准(读到 "${clip(l1, 24) || '(空)'}")`);
@@ -1350,7 +1360,10 @@ export async function cmdConcept({ dir, slug, title, tags, desc }) {
1350
1360
  }
1351
1361
  const oneLine = String(desc || '').trim() || clip(head, 60);
1352
1362
  await registerInIndex(root, 'Concepts', name, oneLine);
1353
- await cmdLog({ dir: root, title: `新建概念页 ${name}`, kind: 'concept' });
1363
+ // 不写 log.md(2026-10-05 用户定):`新建概念页 x` 是**命令的副作用**不是成果 ——
1364
+ // 38 字符、零信息量,且「该页存在」已由 registerInIndex 落在 index.md 的 Concepts 区
1365
+ // (那是 index 的职责)。同件事落两处,且建 10 个页 = 10 行流水噪声自动重现,
1366
+ // 靠事后清理治不了。故删掉这次调用,不加开关(没人需要读「某页被创建了」)。
1354
1367
  return `✓ 概念页骨架 → .brain/concepts/${name}.md ${atTag(who)}\n` +
1355
1368
  ' 已给好四段位置;填完内容后:删掉 <!-- --> 占位、按需改 status: active、挂双链。\n' +
1356
1369
  ' 尾部「## 验证」必须填(留空会被 abs lint 报 NO-TAIL)。';
package/src/todo.js CHANGED
@@ -161,10 +161,15 @@ export function rebuildStructure(text, spec) {
161
161
  // 空分区之间不插空行(否则每次首跑都会“把空行加进去”而写盘一次,
162
162
  // 而 load 是好读命令 —— 不该因纯排版差异去改文件)。
163
163
  // 有内容的第一个分区与前言之间保留一个空行(排版),其余紧凑。
164
+ // 但 H1 后必须恒有一个空行(2026-10-05 实测漏网):前言为空且首个分区为空时
165
+ // (如 H1 缺失被补回、而 `## Rules` 还没条目),两个条件都不满足 → `# H1`
166
+ // 和 `## Rules` 直接相贴。首行是这个文件的门面,不容忍这种粘贴。
167
+ let first = true;
164
168
  for (const [idx, name] of spec.order.entries()) {
165
169
  const body = trimBlank(bucket.get(name) || []);
166
- if (body.length || (idx === 0 && pre.length)) out.push('', name, ...body);
170
+ if (body.length || first) out.push('', name, ...body);
167
171
  else out.push(name);
172
+ first = false;
168
173
  }
169
174
  for (const e of extras) {
170
175
  const body = trimBlank(e.lines);
@@ -183,6 +188,125 @@ function trimBlank(arr) {
183
188
  return a;
184
189
  }
185
190
 
191
+ /** 叶子条目行:log 的 `## [时间] …` 条目、todo/index 的 `- …` 行。
192
+ * 空行落在两个叶子条目之间 = 人为排版漂移,会随条目增长把文件撑成两倍行数。
193
+ * 注意(踩坑):标题行不算 —— `## Done` 与 `### 日期` 之间的空行是分区排版,
194
+ * 删了会把 Done 区挤成一片(首版误删)。 */
195
+ const isLeafEntry = (l) => /^- |^#{1,3}\s+\[/.test(l);
196
+
197
+ /** 已废弃的标准分区(2026-10-05 用户定,白名单只一条)。
198
+ * `## Roadmap` 是 2026-09-13 用户亲手删的分区(`store.js` 注释写明原因:
199
+ * “AI 自己写的方向总结,会被 load 反复读到并带偏后续会话”)。
200
+ * 但 `rebuildStructure` 的规矩 3 是「非标准分区原样保留在末尾」——那条护栏是为了
201
+ * 防丢失人自加的区(如 `## 备忘`),机器不猜语义。结果是 Roadmap 被当成“人自加的区”
202
+ * 留了下来,而且 AI 每次重写 index 都能把它加回来:删一个分区的决策根本没生效。
203
+ * 所以这里单列一张白名单,只放**被正式删过的标准分区**——它们进闸门就被整段丢弃;
204
+ * 从没见过的(`## 备忘` 类)仍按护栏原样保留。不做通用机制,出现第二个再添。 */
205
+ export const RETIRED_SECTIONS = ['## Roadmap'];
206
+
207
+ /** 删除废弃分区的整段(标题到下一个同级/更高级标题前)。
208
+ * 返回 { text, removed:[区名] };只按整行精确匹配标题,不碰正文。 */
209
+ export function dropRetiredSections(text) {
210
+ const lines = String(text ?? '').split('\n');
211
+ const removed = [];
212
+ const out = [];
213
+ let dropping = false;
214
+ let dropLevel = 0;
215
+ for (const l of lines) {
216
+ const m = l.match(/^(#{2,3})\s+(.*)$/);
217
+ if (m) {
218
+ const title = `${m[1]} ${m[2].trim()}`;
219
+ if (RETIRED_SECTIONS.includes(title)) {
220
+ removed.push(title);
221
+ dropping = true;
222
+ dropLevel = m[1].length;
223
+ continue;
224
+ }
225
+ // 遇到同级或更高级的标题 = 废弃区结束
226
+ if (dropping && m[1].length <= dropLevel) dropping = false;
227
+ }
228
+ if (!dropping) out.push(l);
229
+ }
230
+ return { text: out.join('\n'), removed };
231
+ }
232
+
233
+ /** 写入侧格式闸门:三个文件在**每次写盘前**都过这里,不是只在 load 时修。
234
+ *
235
+ * 规则(用户 2026-10-05 定):
236
+ * 1. 固定样式 — 标题/分区名必须逐字对标准,不符按 LEGACY_MARKS 归一;
237
+ * 2. 不允许空行 — 条目之间不留空行(正文段落内的空行保留);
238
+ * 3. 结构固定 — 无 H1 的「无头文件」补回标准 H1,分区按标准顺序重排(rebuildStructure)。
239
+ *
240
+ * 入参 spec 与 checkBrainShape 的 BRAIN_SHAPE 同源(见 store.js)。
241
+ * log.md 无分区(order 为空)→ 只做 H1/归一/去空行,不重排条目顺序(时间倒序自带语义)。
242
+ * 返回 { text, fixed:[描述] };fixed 为空 = 无需改盘。 */
243
+ export function enforceBrainFormat(text, spec) {
244
+ const src = String(text ?? '');
245
+ if (!src.trim()) return { text: src, fixed: [] };
246
+ const fixed = [];
247
+ // 旧标记 → 标准标记(H1 与分区/分组标题)。与 store.js 的 LEGACY_MARKS 同源,
248
+ // 由调用方通过 spec.renames 注入(todo.js 不反向依赖 store.js)。
249
+
250
+ // (0) 废弃分区:被正式删过的标准分区(如 Roadmap)整段丢弃,不等 rebuildStructure
251
+ // 把它当“人自加的区”留到末尾 —— 否则删分区的决策每次都被 AI 重写覆盖回去。
252
+ const retired = dropRetiredSections(src);
253
+ if (retired.removed.length) {
254
+ fixed.push(`删除已废弃分区: ${retired.removed.join(', ')}`);
255
+ }
256
+ // (1) 样式:旧标题名先归一到标准(LOG 无分区、不走 rebuildStructure,这条是它的唯一归一者)。
257
+ let body = retired.text;
258
+ const renames = spec.renames || [];
259
+ if (renames.length) {
260
+ const ls = body.split('\n');
261
+ for (let i = 0; i < ls.length; i++) {
262
+ const t = ls[i].trim();
263
+ const hit = renames.find(([o]) => o === t);
264
+ if (hit && ls[i] !== hit[1]) { ls[i] = hit[1]; fixed.push(`${hit[0]} → ${hit[1]}`); }
265
+ }
266
+ body = ls.join('\n');
267
+ }
268
+
269
+ // (3) 结构:先补 H1,再按标准重排分区。
270
+ const hasH1 = body.split('\n').some((l) => l.trim().startsWith('# '));
271
+ if (!hasH1) {
272
+ body = [spec.h1, '', body.replace(/^\n+/, '')].join('\n');
273
+ fixed.push(`补回缺失的 H1: ${spec.h1}`);
274
+ }
275
+ if (spec.order?.length) {
276
+ const r = rebuildStructure(normalizeTodoIf(body, spec), spec);
277
+ if (r.changed.length) fixed.push(...r.changed);
278
+ body = r.text;
279
+ }
280
+
281
+ // (2) 去条目间空行:只在「空行两侧都是条目行/标题行」时删,正文分段保留。
282
+ const lines = body.split('\n');
283
+ const kept = [];
284
+ for (let i = 0; i < lines.length; i++) {
285
+ const l = lines[i];
286
+ if (!l.trim()) {
287
+ const prev = kept[kept.length - 1];
288
+ // 前瞻要跳过连续空行,否则「两个空行」里只有第一个被删(两个空行是常见形态,
289
+ // 首版留下一个 → 规则形同虚设)。
290
+ let j = i + 1;
291
+ while (j < lines.length && !lines[j].trim()) j++;
292
+ const next = lines[j];
293
+ if (prev !== undefined && next !== undefined && isLeafEntry(prev) && isLeafEntry(next)) {
294
+ fixed.push('删除条目之间的空行');
295
+ continue;
296
+ }
297
+ }
298
+ kept.push(l);
299
+ }
300
+ const out = kept.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '') + '\n';
301
+ return { text: out, fixed };
302
+ }
303
+
304
+ /** todo.md 在重排前必须先走 normalizeTodo(知道旧的 Blocked → 滞留中 语义),
305
+ * 其余文件原样进(rebuildStructure 自带旧分区名归一)。 */
306
+ function normalizeTodoIf(body, spec) {
307
+ return spec.h1 === '# 📋 Todo Board' && spec.order?.[0] === '## Todo' ? normalizeTodo(body) : body;
308
+ }
309
+
186
310
  export function normalizeTodo(text) {
187
311
  const lines = text.split('\n');
188
312
  const has = (name) => lines.some((l) => l.trim() === `## ${name}`);