@fanchao8609/agent_brain_sync 1.16.3 → 1.17.0

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.
@@ -0,0 +1,218 @@
1
+ // src/todo/spec.js — 三份根文件的「规格数据」:分区名、标签白名单、条目形状、行数上限。
2
+ // 这里是纯数据 + 只读谓词。抽出来专为断开 structure ⇄ validate 的环:
3
+ // structure 要靠它重排、validate 要靠它校验,两边都只依赖它。
4
+
5
+ import { SEC } from './common.js';
6
+
7
+ export const LEGACY_SECTION_RENAMES = [
8
+ // H1(文件标题)
9
+ ['# 🗂 图谱索引', '# 🗂 Graph Index'],
10
+ ['# 🗒 操作日志', '# 🗒 Activity Log'],
11
+ ['# 📋 Todo 看板', '# 📋 Todo Board'],
12
+ // ## 分区
13
+ // 四区 → 两区(2026-09-13 用户定:Backlog/Today 常年为空,进行时分区与实际工作流错配)。
14
+ // 未完成的一律归 Todo,状态改由**行首标记**表达(见 TASK_STATES)。
15
+ ['## Backlog', '## Todo'],
16
+ ['## Today / In Progress', '## Todo'],
17
+ ['## Blocked', '## Todo'],
18
+ ['## Done(只留近期,旧的迁 log.md/快照)', '## Done'],
19
+ // ### 区内分组标题
20
+ ['### 归档', '### Archived'],
21
+ ['### (未标日期)', '### Undated'],
22
+ ];
23
+
24
+ export const TODO_SECTIONS = ['Todo', 'Done'];
25
+ // todo 的 `###` 级标签(实测真实形态:`## Done` 下按日期分组 `### 2026-09-29`,
26
+ // 归位的进 `### Archived`)。白名单只限这两类:日期组 + Archived。
27
+ // 为什么必须列:`### ` 是 AI 最爱的「自建小节」位置(### 备忘 / ### 计划),
28
+ // 只查 `## ` 会把它放过去。
29
+
30
+ export const TODO_SUBSECTIONS = ['Archived'];
31
+ /** `###` 标签是否放行:归档区 / 未标日期组 / 日期组 `YYYY-MM-DD`。
32
+ * `Undated` 是 LEGACY_MARKS 里的正式标准名(旧 `### (未标日期)` → `### Undated`),
33
+ * 漏了它会把存量文件卡死(实测)。 */
34
+
35
+ export function isAllowedTodoSub(name) {
36
+ return TODO_SUBSECTIONS.includes(name) || name === 'Undated' || /^\d{4}-\d{2}-\d{2}$/.test(name);
37
+ }
38
+
39
+ // ---------- 三份根文件的标签白名单与条目形状(2026-10-05 用户定)----------
40
+ // 用户原话:「index Rules Concepts Entities Sources Syntheses Sessions 包含这些标签
41
+ // 不允许增加新的标签,每个标签写内容的规则都是 - [[xx]]123 不允许乱写,同理 log todo 也是」
42
+ //
43
+ // 为什么需要:闸门只管骨架(分区顺序/H1/空行),**标签白名单与条目形状都没人管**。
44
+ // rebuildStructure 的规矩 3 更是「非标准分区原样保留在末尾」(为了防丢人自加的内容)
45
+ // → AI 新加一个 `## 备忘` 或 `## 计划` 永远合法,且会一直留在文件里。
46
+ //
47
+ // 注意 Rules 区是例外:它放的是**规矩短句**(「写代码前先读 docs/STRUCTURE.md」),
48
+ // 不是页链接 —— 实测 13 条规则全是这个形态。不能拿 [[页名]] 去卡它。
49
+
50
+ export const INDEX_SECTIONS = ['Rules', 'Concepts', 'Entities', 'Sources', 'Syntheses', 'Sessions'];
51
+ /** 需要 `- [[页名]] …` 形状的区(Rules 除外)。 */
52
+
53
+ export const INDEX_LINK_SECTIONS = ['Concepts', 'Entities', 'Sources', 'Syntheses', 'Sessions'];
54
+ /** log 行的 kind 枚举(与 cmdLog 写入端同源;实测 238 条只有这 4 种)。 */
55
+
56
+ export const LOG_KINDS = ['note', 'dev', 'concept', 'ingest'];
57
+
58
+ /** 条目形状规则表:文件 → 每行的预期形状。
59
+ * 校验器只报告「不像这个形状」的行,不自动改写内容(机器不猜语义)。 */
60
+ /** 条目形状规则表:文件 → 每行的预期形状。
61
+ * 校验器只报告「不像这个形状」的行,不自动改写内容(机器不猜语义)。 */
62
+
63
+ export const H1_TO_FILE = {
64
+ '# 🗂 Graph Index': 'index.md',
65
+ '# 🗒 Activity Log': 'log.md',
66
+ '# 📋 Todo Board': 'todo.md',
67
+ };
68
+
69
+ export const ENTRY_SHAPES = {
70
+ 'index.md': [
71
+ {
72
+ name: 'INDEX-ENTRY',
73
+ // 条目行:`- [[页名]] — 一句话`(是 6 个链接区里的一行)
74
+ test: (l) => {
75
+ if (!/^-\s/.test(l)) return null; // 非条目行(规则短句/说明)由分区规则另判
76
+ if (!/^-\s*\[\[[^\]]+\]\]/.test(l)) return '应以 `- [[页名]] …` 开头';
77
+ if (!/^-\s*\[\[[^\]]+\]\]\s*[—-]\s*\S/.test(l)) return '`[[页名]]` 后要跟 `— 一句话` 描述';
78
+ return null;
79
+ },
80
+ },
81
+ ],
82
+ 'log.md': [
83
+ {
84
+ name: 'LOG-ENTRY',
85
+ test: (l) => {
86
+ if (!/^##\s/.test(l)) return null; // 非 `## ` 行(H1/注释)不管
87
+ // ★ log 没有分区概念:`## ` 开头的只能是条目。
88
+ // 否则 `## 2026-10-05 随便写点`(缺方括号/作者/竖线)会被当成「分区标题」滑过去 ——
89
+ // 而 log.md 的标签白名单是 null(不校验),这就是一个漏网口(实测)。
90
+ // 形状:## [YYYY-MM-DD HH:MM] [[作者]] kind | 正文
91
+ // ★ 放行历史旧模板的占位行 `## [YYYY-MM-DD] ingest | 沉淀 <slug>`:
92
+ // 它是早版 logTemplate 造出来的,不是人写的乱,而是模板自身的遗留。
93
+ // 新模板已改成 HTML 注释(见 store.js logTemplate)。存量这行由 lint 报。
94
+ if (/^##\s*\[YYYY-MM-DD\]/.test(l)) return null;
95
+ const m = l.match(/^##\s*\[(\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2})?)\]\s*\[\[([^\]]+)\]\]\s*([A-Za-z]+)\s*\|\s*(\S.*)$/);
96
+ if (!m) return '应为 `## [YYYY-MM-DD HH:MM] [[作者]] kind | 正文`(log 只能有这种行)';
97
+ if (!LOG_KINDS.includes(m[3])) return `kind 只能是 ${LOG_KINDS.join('/')}(收到 \`${m[3]}\`)`;
98
+ // 「正文只有个任务 id」不在这里拒(见下 lintOnly 规则):它是**内容质量**
99
+ // 问题不是**结构格式**问题,当写入闸门会把存量脏库锁死(实测:真库仅一条
100
+ // 就让所有 abs log 失败)。留在此处只会误伤正常短标题(如 `log-0`)。
101
+ return null;
102
+ },
103
+ // 仅在 lint 阶段生效的额外规则(不阻写入,只报存量)。
104
+ lintOnly: (l) => {
105
+ const m = l.match(/^##\s*\[\d{4}-\d{2}-\d{2}[^\]]*\]\s*\[\[[^\]]+\]\]\s*[A-Za-z]+\s*\|\s*(\S.*)$/);
106
+ if (!m) return null;
107
+ if (/^[A-Z][A-Z0-9-]{2,}$/.test(m[1].trim())) return '正文只写了个任务 id(等于没记内容),补一句发生了什么';
108
+ return null;
109
+ },
110
+ },
111
+ ],
112
+ 'todo.md': [
113
+ {
114
+ name: 'TODO-ENTRY',
115
+ // 任务行形状(实测):
116
+ // 新写入(cmdTask):`- [ ] [状态] id [[作者]] — 描述 (认领 日期)`
117
+ // 旧存量(四区制迁移):`- [ ] 中文描述 (认领 日期)` ← 没有 id / 作者
118
+ // 已完成:`- [x] id …`(markDone 会 stripStateMark 去掉状态)
119
+ //
120
+ // ★ 为什么不强制要求 id:旧存量的 id 缺失是**历史事实**,机器不能凭空编造
121
+ // (编了就是静默改写语义)。闸门只确保「结构可解析」:
122
+ // 未完成必须有 `[状态]`(归一后的标志);已完成必须有内容。
123
+ // 而「新写入必须带 id」由 cmdTask 拼装保证 —— 那是入口的职责,
124
+ // 不是事后逐行猜(猜不出哪行是新的、哪行是旧的)。
125
+ test: (l) => {
126
+ if (/^#{1,3}\s/.test(l)) return null; // 标题行(## Done / ### 日期)不是条目
127
+ if (!/^-\s/.test(l)) return null; // 非条目行(如缩进的 ↳ 断点)由下面单独判
128
+ // Done 区的归档行:`- [[log-日期]] 完成任务 N 条`。
129
+ // ★ 历史命名不统一:还有 `[[2026-09-12-todo归档]]` 这种旧写法(实测),
130
+ // 只认 `log-` 前缀会把它误报为「缺状态标记」。状归档行与任务行的
131
+ // 区别不是命名而是**形态**:没有 `[ ]`/`[x]` 复选框的 [[链接]] 行。
132
+ if (/^-\s*\[\[[^\]]+\]\]\s+完成任务/.test(l)) return null;
133
+ if (/^-\s*\[\[log-\d{4}-\d{2}-\d{2}\]\]/.test(l)) return null;
134
+ const m = l.match(/^-\s*\[([ x])\]\s*(.*)$/);
135
+ if (!m) return '应以 `- [ ] ` 或 `- [x] ` 开头';
136
+ const rest = m[2].trim();
137
+ if (!rest) return '任务行不能只有复选框,要写做什么(已拒)';
138
+ // 未完成:必须有状态标记(新写法)——无标记的旧行由 enforceBrainFormat 自动补上,
139
+ // 走到这里还没标记 = 补不了(不是 `- [ ] ` 形状),报错。
140
+ if (m[1] === ' ' && !/^\[[^\]]+\]\s*\S/.test(rest)) {
141
+ return '未完成任务应为 `- [ ] [状态] …`(状态:进行中/讨论中/滞留中)';
142
+ }
143
+ // 断点附属行不能挤在同一行(应另起 ` ↳ 断点: `)
144
+ if (/↳\s*断点:/.test(rest)) return '断点要另起一行写 ` ↳ 断点: …`';
145
+ return null;
146
+ },
147
+ },
148
+ ], // 任务行/断点行另由 assertTodoContent 卡长度与报告体
149
+ };
150
+
151
+ /** 校验单个文件的分区标签白名单 + 条目形状。返回问题列表(不抛错,供 lint 与写入闸门共用)。
152
+ * reason 文案直接把出路写清楚 —— 报错不给出路等于让人挖坑。 */
153
+
154
+ export function indexLinkSectionNeeds(section) {
155
+ return section !== null && INDEX_LINK_SECTIONS.includes(section);
156
+ }
157
+
158
+ /** 任务状态标记(行首,方括号)。替代原 Backlog/Today/Blocked 三区的区分作用。
159
+ * 放在 id **之前**,与 Done 结语的 `【落地】` 形态区分开(那是行尾、结语用)。
160
+ *
161
+ * 「搁置」与「滞留中」的区别(2026-10-05 用户定,别合并):
162
+ * 滞留中 = 还要做,只是卡住了(等外部/等信息) → 下会话该捡起来
163
+ * 搁置 = 不做了 / 用户改方向了 → 下会话不该捡,除非用户又提
164
+ * 混在一起会让下会话分不清「该不该接着干」。 */
165
+
166
+ export const TASK_STATES = ['进行中', '讨论中', '滞留中', '搁置'];
167
+
168
+ /** 结构重建(B 档):以标准分区表为准重排整个文件。
169
+ *
170
+ * 规则(保证内容不丢、不挪错):
171
+ * 1. 按 spec.order 顺序输出标准分区,每个分区下放**归属于它**的内容行;
172
+ * 2. 归属判定:按行所处的原分区归入对应标准分区;旧名先按 spec.renames 归一;
173
+ * 3. **非标准分区**(人自加的,如 `## 备忘`)→ 内容连同它自己的标题一起**原样保留在末尾**,
174
+ * 绝不合入已有标准分区(机器不知道它的语义,猜错就是挪错内容);
175
+ * 4. 自由正文(不属于任何分区的行,如 H1 后的说明句)保留在 H1 之后;
176
+ * 5. 幂等:已是标准结构 → 输出逐字节相同。
177
+ *
178
+ * 返回 { text, changed };changed 为空的描述列表(空=无需改盘)。 */
179
+
180
+ export const RETIRED_SECTIONS = ['## Roadmap'];
181
+
182
+ /** 删除废弃分区的整段(标题到下一个同级/更高级标题前)。
183
+ * 返回 { text, removed:[区名] };只按整行精确匹配标题,不碰正文。 */
184
+
185
+ export const BREAKPOINT_MAX = 200;
186
+
187
+ export const TASK_LINE_MAX = 400;
188
+
189
+ /** 一行的「违规签名」:去空白、截前 120 字。
190
+ * 用途:比对「这条违规是不是写入前就有的」——新增的拒,存量的放。
191
+ * 不按行号比对:插入一行会让后面所有行号位移,按行号会误判。 */
192
+
193
+ export const DONE_KINDS = ['落地', '否决', '仅方案'];
194
+
195
+ /** 从 Done 任务行提取结语标记;无标记返回 ''。 */
196
+
197
+ export const ARCHIVE_HEADING = `### ${SEC.archived}`;
198
+
199
+ /** 把 Done 主体行切成 { groupLines, archiveLines }:把 `### 归档` 区当"不透明块"原样保留。
200
+ * 注意不能简单"从标题切到文件尾" —— 否则一旦有日期组落在归档区之后(手改/旧数据),
201
+ * 它会被当不透明内容除在分组之外,永远不参与归档。故只取到下一个 ###/## 标题为止。 */
202
+
203
+ export const ARCHIVE_SECTION = '## 📦 任务归档';
204
+
205
+ /** 把「任务归档」段合并进已有正文。**纯函数**(不碰磁盘)。
206
+ *
207
+ * 为何不再「每天一个文件」(2026-09-15 用户定):
208
+ * 归档页与 `log-<日期>.md` 会话快照是**同一天的记录**,分两处查着要开两个文件。
209
+ * 改为:归档写进当天快照的 `ARCHIVE_SECTION` 段。
210
+ *
211
+ * 两个边界(都有测试钉住):
212
+ * ① 文件不存在 → 建一个**只有归档段**的页(那天可能没写快照,仍只落这一个文件)。
213
+ * ② 文件已存在(含 AI 手写的快照)→ **只替换归档段**,段外内容逐字保留。
214
+ * 同日重复归档(罕见)则把新日期组接在段内已有内容之后,不重复建段。
215
+ *
216
+ * @param body 现有全文(null = 文件不存在)
217
+ * @param group { date, lines, count }
218
+ */
@@ -0,0 +1,324 @@
1
+ // src/todo/structure.js — 结构重排与格式归一(rebuildStructure / enforceBrainFormat)。
2
+
3
+ import { TODO_SECTIONS, H1_TO_FILE, TASK_STATES, RETIRED_SECTIONS } from './spec.js';
4
+ import { checkFileShape, violationSignatures, assertNoStrayBlank, assertTodoContent } from './validate.js';
5
+ import { trimBlank, sigOfLine, isPlaceholder } from './common.js';
6
+
7
+ export function todoTemplate() {
8
+ // 由 rebuildStructure 生成,保证“模板”与“重排结果”逐字节一致
9
+ // (否则 load 会把新建的模板又重排一次 = 无意义的写盘)。
10
+ // 注意(2026-09-13 踩坑):rebuildStructure 的 order 要**带 `## ` 前缀**,
11
+ // 而 TODO_SECTIONS 是裸名(两者用途不同,不能复用 —— 曾误传裸名导致
12
+ // 生成出没有 `##` 的裸标题行,模板直接损坏、Backlog 分区消失)。
13
+ return rebuildStructure(
14
+ ['# 📋 Todo Board', ...TODO_SECTIONS.map((s) => `## ${s}`)].join('\n'),
15
+ { h1: '# 📋 Todo Board', order: TODO_SECTIONS.map((s) => `## ${s}`) },
16
+ ).text;
17
+ }
18
+
19
+ // 只有两区(2026-09-13 用户定:精简)。为什么砍掉 Backlog/Today/Blocked:
20
+ // 实测跨 4 个项目,Backlog 与 Today 常年为 **0 条** —— 而 log.md 有 120 条。
21
+ // 根因:AI 的工作方式是「一口气做完」,任务从开始到完成都在同一会话内走完,
22
+ // 中间那个「挂到进行时分区上」的动作既来不及也不需要发生。
23
+ // 而 log 是在**结束时**写的,那个时点真实存在,所以它记满了。
24
+ // 结论:进行时分区是符合直觉但不符合实际工作流的抽象 → 删掉,
25
+ // 未完成的一律进 Todo,状态用**行首标记**表达(不靠分区区分)。
26
+
27
+ export function rebuildStructure(text, spec) {
28
+ const s = String(text ?? '');
29
+ const lines = s.split('\n');
30
+ const h1At = lines.findIndex((l) => l.trim().startsWith('# '));
31
+ const bodyStart = h1At === -1 ? 0 : h1At + 1;
32
+ const bucket = new Map(); // 标准分区名 -> 内容行
33
+ const extras = []; // [{ title, lines }] 非标准分区,原样保留到末尾
34
+ const preamble = []; // H1 与第一个 ## 之间的自由正文
35
+ let curStd = null; // 当前在的标准分区名(null = 前言)
36
+ let curExtra = null; // 当前在的额外分区对象
37
+ for (let i = bodyStart; i < lines.length; i++) {
38
+ const l = lines[i];
39
+ if (/^#{2,3}\s/.test(l)) {
40
+ const t = l.trim();
41
+ const renamed = (spec.renames?.find(([o]) => o === t) || [, t])[1];
42
+ if (spec.order.includes(renamed)) {
43
+ curStd = renamed;
44
+ curExtra = null;
45
+ if (!bucket.has(curStd)) bucket.set(curStd, []);
46
+ } else {
47
+ curExtra = { title: renamed, lines: [] };
48
+ extras.push(curExtra);
49
+ curStd = null;
50
+ }
51
+ continue;
52
+ }
53
+ if (curExtra) curExtra.lines.push(l);
54
+ else if (curStd) bucket.get(curStd).push(l);
55
+ else preamble.push(l);
56
+ }
57
+ const out = [spec.h1];
58
+ const pre = trimBlank(preamble);
59
+ if (pre.length) out.push('', ...pre);
60
+ // 每个分区标题前恒有一个空行(含空分区)。
61
+ // 坑(2026-10-06 定位):旧写法对空分区走 `else out.push(name)` —— 不补空行,
62
+ // 于是「有内容分区 → 空分区」的接缝被挤成 `- [[页]] …` 紧贴 `## Sources`。
63
+ // 表现成「index 里那个空行反复丢」:人工补回 → 下次任意写操作(过 enforceBrainFormat)
64
+ // 又归一掉,来回拉锯(本机 corp_agent 实测,17961e5 补、f3f7e84 又丢)。
65
+ // 原注释担心「每次首跑都写盘一次」——实测不会:归一后自身就是稳定形态,
66
+ // 第二次跑 fixed 为空、零字节改动(幂等,见 test/todo 的自检)。
67
+ // H1 后必须恒有空行:`# H1` 与首个分区相贴是门面事故,不容忍。
68
+ for (const name of spec.order) {
69
+ const body = trimBlank(bucket.get(name) || []);
70
+ out.push('', name, ...body);
71
+ }
72
+ for (const e of extras) {
73
+ const body = trimBlank(e.lines);
74
+ out.push('', e.title);
75
+ if (body.length) out.push(...body);
76
+ }
77
+ const next = out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '') + '\n';
78
+ return { text: next, changed: next === s ? [] : ['结构按标准重排'] };
79
+ }
80
+
81
+ /** 去掉首尾空行,不动中间(保留用户的分段)。 */
82
+
83
+ const isLeafEntry = (l) => /^- |^#{1,3}\s+\[/.test(l);
84
+
85
+ /** 已废弃的标准分区(2026-10-05 用户定,白名单只一条)。
86
+ * `## Roadmap` 是 2026-09-13 用户亲手删的分区(`store.js` 注释写明原因:
87
+ * “AI 自己写的方向总结,会被 load 反复读到并带偏后续会话”)。
88
+ * 但 `rebuildStructure` 的规矩 3 是「非标准分区原样保留在末尾」——那条护栏是为了
89
+ * 防丢失人自加的区(如 `## 备忘`),机器不猜语义。结果是 Roadmap 被当成“人自加的区”
90
+ * 留了下来,而且 AI 每次重写 index 都能把它加回来:删一个分区的决策根本没生效。
91
+ * 所以这里单列一张白名单,只放**被正式删过的标准分区**——它们进闸门就被整段丢弃;
92
+ * 从没见过的(`## 备忘` 类)仍按护栏原样保留。不做通用机制,出现第二个再添。 */
93
+
94
+ export function dropRetiredSections(text) {
95
+ const lines = String(text ?? '').split('\n');
96
+ const removed = [];
97
+ const out = [];
98
+ let dropping = false;
99
+ let dropLevel = 0;
100
+ for (const l of lines) {
101
+ const m = l.match(/^(#{2,3})\s+(.*)$/);
102
+ if (m) {
103
+ const title = `${m[1]} ${m[2].trim()}`;
104
+ if (RETIRED_SECTIONS.includes(title)) {
105
+ removed.push(title);
106
+ dropping = true;
107
+ dropLevel = m[1].length;
108
+ continue;
109
+ }
110
+ // 遇到同级或更高级的标题 = 废弃区结束
111
+ if (dropping && m[1].length <= dropLevel) dropping = false;
112
+ }
113
+ if (!dropping) out.push(l);
114
+ }
115
+ return { text: out.join('\n'), removed };
116
+ }
117
+
118
+ /** 写入侧格式闸门:三个文件在**每次写盘前**都过这里,不是只在 load 时修。
119
+ *
120
+ * 规则(用户 2026-10-05 定):
121
+ * 1. 固定样式 — 标题/分区名必须逐字对标准,不符按 LEGACY_MARKS 归一;
122
+ * 2. 不允许空行 — 条目之间不留空行(正文段落内的空行保留);
123
+ * 3. 结构固定 — 无 H1 的「无头文件」补回标准 H1,分区按标准顺序重排(rebuildStructure)。
124
+ *
125
+ * 入参 spec 与 checkBrainShape 的 BRAIN_SHAPE 同源(见 store.js)。
126
+ * log.md 无分区(order 为空)→ 只做 H1/归一/去空行,不重排条目顺序(时间倒序自带语义)。
127
+ * 返回 { text, fixed:[描述] };fixed 为空 = 无需改盘。 */
128
+ // ---------- 条目级闸门(2026-10-05 加)----------
129
+ // 为什么加:闸门此前只管**骨架形状**(分区/H1/空行),不管条目**内容**。
130
+ // 于是 AI 把 todo 当笔记本 —— 实施报告、测试数据、需求清单、架构分析全塞进
131
+ // 断点行,文件涨到 232 行/上万字,而闸门与 lint 双双报「0 问题」。
132
+ // 这里补上内容约束:todo 只能放 todo。长内容走 abs note(经验)/ abs log(流水)/ sources 页。
133
+ //
134
+ // 注意:**超长一律抛错,不静默截断**。截断会丢数据且无声 —— 正是要防的那种失效。
135
+
136
+ function stripStrayBlanks(body, fixed) {
137
+ const lines = String(body).split('\n');
138
+ const kept = [];
139
+ for (let i = 0; i < lines.length; i++) {
140
+ const l = lines[i];
141
+ if (!l.trim()) {
142
+ const prev = kept[kept.length - 1];
143
+ // 前瞻要跳过连续空行,否则「两个空行」里只有第一个被删(两个空行是常见形态,
144
+ // 首版留下一个 → 规则形同虚设)。
145
+ let j = i + 1;
146
+ while (j < lines.length && !lines[j].trim()) j++;
147
+ const next = lines[j];
148
+ if (prev !== undefined && next !== undefined && isLeafEntry(prev) && isLeafEntry(next)) {
149
+ fixed.push('删除条目之间的空行');
150
+ continue;
151
+ }
152
+ }
153
+ kept.push(l);
154
+ }
155
+ return kept.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '') + '\n';
156
+ }
157
+
158
+ export function enforceBrainFormat(text, spec, prev) {
159
+ const src = String(text ?? '');
160
+ if (!src.trim()) return { text: src, fixed: [] };
161
+ // 内容闸门:形状归一之前先拒掉乱塞(超长断点/报告体),否则下面的 rebuild
162
+ // 会把它当「人自加的正文」好好保留下来 —— 越规整越难发现。
163
+ // ★ 存量宽容(2026-10-05 本机实测踩到):只拒「本次写入新引入」的违规;
164
+ // 写入前就存在的违规放行(由 lint 报出)。
165
+ // 为什么:不做这个区分,库里只要有一条历史脏行,**所有写入全部失败** ——
166
+ // 实测真库(corp_agent)就因一条超长断点 + 8 条 `[]()` 条目,
167
+ // 连 `abs rule add` 都做不到,整个图谱变成只读。
168
+ const prevSig = prev == null ? null : violationSignatures(String(prev));
169
+ if (spec?.h1 === '# 📋 Todo Board') assertTodoContent(src, prevSig);
170
+ // ★ 标签白名单闸门放在**旧标记归一之后**(见下面 legacy 段之后)——
171
+ // 旧标签(`## Backlog` / `## Today / In Progress`)是**待迁移**的,不是非法新标签。
172
+ // 首版把校验放在归一之前,把存量老文件全卡死(7 个测试红,2026-10-05 实测)。
173
+ const fname = H1_TO_FILE[spec?.h1];
174
+ let checked = false;
175
+ const assertShape = (t) => {
176
+ if (!fname || checked) return;
177
+ checked = true;
178
+ // ★ 空行:直接拒绝(用户 2026-10-05:「绝对不允许乱空行」)。
179
+ // 存量库里本来就有空行的,在**写入前**先被归一清除(见 enforceBrainFormat 尾部),
180
+ // 所以走到这里仍有空行 = 本次写入引入的 → 拒。
181
+ assertNoStrayBlank(t, fname);
182
+ // ★ 标签/条目形状:存量脏行放行(不因历史数据锁死库),新增的拒。
183
+ // 为什么这类要宽容而空行不要:超长断点/`[]()` 条目**没有安全的自动修法**
184
+ // (截断会丢数据、改格式会改语义),只能报给人看;而空行删了不丢任何信息。
185
+ const tLines = t.split('\n');
186
+ const bad = checkFileShape(fname, t).filter(
187
+ (it) => it.code !== 'STRAY-BLANK-LINE' &&
188
+ !(prevSig !== null && prevSig.has(sigOfLine(tLines[it.line - 1] || ''))),
189
+ );
190
+ if (bad.length) {
191
+ throw new Error(
192
+ `✗ ${fname} 格式不合规(写入被拒):\n` +
193
+ bad.map((it) => ` · ${it.msg}`).join('\n') +
194
+ `\n 出路:内容归入现有标签,或走对应入口(页 → concepts/ 等子目录 + index 登记;流水 → abs log;经验 → abs note)。`,
195
+ );
196
+ }
197
+ };
198
+ const fixed = [];
199
+ // 旧标记 → 标准标记(H1 与分区/分组标题)。与 store.js 的 LEGACY_MARKS 同源,
200
+ // 由调用方通过 spec.renames 注入(todo.js 不反向依赖 store.js)。
201
+
202
+ // (0) 废弃分区:被正式删过的标准分区(如 Roadmap)整段丢弃,不等 rebuildStructure
203
+ // 把它当“人自加的区”留到末尾 —— 否则删分区的决策每次都被 AI 重写覆盖回去。
204
+ const retired = dropRetiredSections(src);
205
+ if (retired.removed.length) {
206
+ fixed.push(`删除已废弃分区: ${retired.removed.join(', ')}`);
207
+ }
208
+ // (1) 样式:旧标题名先归一到标准(LOG 无分区、不走 rebuildStructure,这条是它的唯一归一者)。
209
+ let body = retired.text;
210
+ const renames = spec.renames || [];
211
+ if (renames.length) {
212
+ const ls = body.split('\n');
213
+ for (let i = 0; i < ls.length; i++) {
214
+ const t = ls[i].trim();
215
+ const hit = renames.find(([o]) => o === t);
216
+ if (hit && ls[i] !== hit[1]) { ls[i] = hit[1]; fixed.push(`${hit[0]} → ${hit[1]}`); }
217
+ }
218
+ body = ls.join('\n');
219
+ }
220
+
221
+ // ★ 旧格式自动升级(2026-10-05):闸门不能只会拒,还得会**修**。
222
+ // 用户要求「旧版本旧样式要能自动更新为新样式」。
223
+ // 这里把已知的旧形态归一为新形态,**归一之后再校验白名单** ——
224
+ // 否则存量老文件(无状态标记的裸任务行)会被当成违规而卡死(实测:
225
+ // 底层 addTask/upsertTask 传的就是裸文本,它们是内部 API,不是 AI 入口)。
226
+ if (fname === 'todo.md') {
227
+ const ls = body.split('\n');
228
+ for (let i = 0; i < ls.length; i++) {
229
+ // 裸任务行(`- [ ] id …` 无状态标记)→ 补默认「进行中」
230
+ if (/^- \[ \] /.test(ls[i]) && !/^- \[ \] \[[^\]]+\]/.test(ls[i])) {
231
+ const fixed2 = ensureStateMark(ls[i], '进行中');
232
+ if (fixed2 !== ls[i]) { ls[i] = fixed2; fixed.push('补回缺失的状态标记'); }
233
+ }
234
+ }
235
+ body = ls.join('\n');
236
+ }
237
+
238
+ // ★ 存量空行先清掉(2026-10-05):必须跑在 assertShape **之前** ——
239
+ // 否则 assertShape 会看到存量空行而拒绝写入(实测把整个脏库锁成只读)。
240
+ // 删空行不丢任何信息,所以这里直接修,而不是报错。
241
+ body = stripStrayBlanks(body, fixed);
242
+
243
+ // ★ 白名单闸门在这里跑:旧格式已升级、旧标记已归一、存量空行已清,
244
+ // 剩下的违规才是真的本次引入。
245
+ assertShape(body);
246
+
247
+ // (3) 结构:先补 H1,再按标准重排分区。
248
+ const hasH1 = body.split('\n').some((l) => l.trim().startsWith('# '));
249
+ if (!hasH1) {
250
+ body = [spec.h1, '', body.replace(/^\n+/, '')].join('\n');
251
+ fixed.push(`补回缺失的 H1: ${spec.h1}`);
252
+ }
253
+ if (spec.order?.length) {
254
+ const r = rebuildStructure(normalizeTodoIf(body, spec), spec);
255
+ if (r.changed.length) fixed.push(...r.changed);
256
+ body = r.text;
257
+ }
258
+
259
+ // (2) 去条目间空行:同 stripStrayBlanks,但重建后再跑一次(重排会新建邻接关系)。
260
+ const out = stripStrayBlanks(body, fixed);
261
+ return { text: out, fixed };
262
+ }
263
+
264
+ /** todo.md 在重排前必须先走 normalizeTodo(知道旧的 Blocked → 滞留中 语义),
265
+ * 其余文件原样进(rebuildStructure 自带旧分区名归一)。 */
266
+
267
+ function normalizeTodoIf(body, spec) {
268
+ return spec.h1 === '# 📋 Todo Board' && spec.order?.[0] === '## Todo' ? normalizeTodo(body) : body;
269
+ }
270
+
271
+ export function normalizeTodo(text) {
272
+ const lines = text.split('\n');
273
+ const has = (name) => lines.some((l) => l.trim() === `## ${name}`);
274
+ // 已是标准两区制 = 有 Todo 且**没有**任何进行时老分区。
275
+ // 坑(2026-09-13):曾只判 has('Todo') 就 return —— 而最老格式里也可能有 `## Todo`
276
+ // (与 `## In Progress` 混用),于是旧文件永不迁移。
277
+ const legacy = ['Backlog', 'Today / In Progress', 'In Progress', 'Blocked'].some(has);
278
+ if (has('Todo') && !legacy) return text;
279
+ const out = ['# 📋 Todo Board'];
280
+ const grab = (name) => {
281
+ const items = [];
282
+ let inSec = false;
283
+ for (const l of lines) {
284
+ if (l.startsWith('## ')) { inSec = l.trim() === `## ${name}`; continue; }
285
+ if (inSec && l.trim() && !isPlaceholder(l)) items.push(l);
286
+ }
287
+ return items;
288
+ };
289
+ const done = grab('Done');
290
+ // 未完成的一切(不论原来在 Backlog/In Progress/Todo/Blocked)→ 一律进 Todo。
291
+ // 状态信息由行首标记承接:原 Blocked 区的任务补 [滞留中](其余默认 [进行中],写入时补)。
292
+ const open = [];
293
+ for (const name of ['Backlog', 'Today / In Progress', 'In Progress', 'Todo']) {
294
+ open.push(...grab(name).map((l) => ensureStateMark(l, '进行中')));
295
+ }
296
+ open.push(...grab('Blocked').map((l) => ensureStateMark(l, '滞留中')));
297
+ out.push('## Todo', ...open);
298
+ out.push('## Done', ...done);
299
+ return out.join('\n');
300
+ }
301
+
302
+ /** 给任务行补行首状态标记(已有则不覆盖)。`- [ ] id …` → `- [ ] [状态] id …` */
303
+
304
+ export function ensureStateMark(line, state) {
305
+ const m = String(line).match(/^(- \[[ x]\] )(\[[^\]]+\]\s+)?(.*)$/);
306
+ if (!m) return line;
307
+ if (m[2] && TASK_STATES.includes(m[2].trim().replace(/[\x5B\x5D]/g, ''))) return line;
308
+ return `${m[1]}[${state}] ${m[3]}`;
309
+ }
310
+
311
+ /** 取任务行的状态标记;无标记返回 null。 */
312
+
313
+ export function stateOfTaskLine(line) {
314
+ const m = String(line).match(/^- \[[ x]\] \[([^\]]+)\]/);
315
+ return m && TASK_STATES.includes(m[1]) ? m[1] : null;
316
+ }
317
+
318
+ /** 改任务行的状态标记(原地)。未找到或非法状态返回原样。 */
319
+
320
+ export function stripStateMark(line) {
321
+ return String(line).replace(/^(- \[[ x]\] )\[[^\]]+\] /, '$1');
322
+ }
323
+
324
+ /** 从已完成任务行提取 `(完成 YYYY-MM-DD)` 日期;无则返回 ''。兼容中英文括号。 */