@fanchao8609/agent_brain_sync 1.16.4 → 1.17.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/src/todo.js CHANGED
@@ -1,1319 +1,10 @@
1
- // src/todo.js — todo.md 读写。CLI 的核心纯读写层(被 MCP 转接、可被 hook 直接调)。
2
- // 格式:两区 `## Todo`(未完成,行首 `[进行中|讨论中|滞留中]`)+ `## Done`;半成品附属行 `↳ 断点:`。
3
- import { promises as fs } from 'node:fs';
4
- import { brainPath } from './index.js';
5
- import { editFile, SKIP } from './lock.js';
6
-
7
- // 根文件行数上限(2026-10-05 从 lint.js 移来):lint 用它报 ROOT-OVER-SIZE,
8
- // archive 用它在超限时放宽归档窗口。**放 todo.js 而非 lint.js** ——
9
- // lint 依赖 todo(checkFileShape),反向导入会成环。
10
- export const TODO_MAX_LINES = 60;
11
- export const LOG_MAX_LINES = 2000;
12
- export const INDEX_MAX_LINES = 200;
13
-
14
- // ---------- 本地日期 ----------
15
- export function today() {
16
- // 用本地时区取 YYYY-MM-DD(toISOString 是 UTC, 会跨天错一天)
17
- const d = new Date();
18
- const p = (n) => String(n).padStart(2, '0');
19
- return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
20
- }
21
-
22
- // 本地日期时间 YYYY-MM-DD HH:MM(log 流水用)
23
- export function localStamp() {
24
- const d = new Date();
25
- const p = (n) => String(n).padStart(2, '0');
26
- return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
27
- }
28
-
29
- // ---------- 读取 todo.md ----------
30
- export async function readTodo(brainRoot) {
31
- const p = brainPath(brainRoot, 'todo.md');
32
- let text;
33
- try {
34
- text = await fs.readFile(p, 'utf8');
35
- } catch {
36
- return ''; // 尚未创建,按空处理(不在此创建,避免读操作写文件)
37
- }
38
- const norm = normalizeTodo(text);
39
- const grouped = groupDoneSection(norm); // 平铺旧 Done → 按日期分组(幂等)
40
- if (grouped !== text) {
41
- // 惰性迁移也是写:走锁,避免与并发写互相覆盖(锁内 re-read 已是权威最新内容)
42
- await editFile(p, (cur) => (cur === text ? { text: grouped } : SKIP));
43
- return grouped;
44
- }
45
- return text;
46
- }
47
-
48
- export async function ensureTodo(brainRoot) {
49
- const p = brainPath(brainRoot, 'todo.md');
50
- // 缺失才建模板;经锁写盘保证原子性(避免与并发 editFile 读到半写内容)
51
- await editFile(p, (cur) => (cur === null ? { text: todoTemplate() } : SKIP));
52
- return p;
53
- }
54
-
55
- /** 分区名常量(单一真源)。改名时只改这里 —— 之前散在 20+ 处,一改就漏。
56
- * 旧名(中文)留在 LEGACY_SECTION_RENAMES 作迁移用。 */
57
- export const SEC = {
58
- todo: 'Todo',
59
- done: 'Done',
60
- archived: 'Archived', // Done 区内部的归档标记区(原 '### 归档')
61
- undated: 'Undated', // Done 区内部无完成日期的尾组(原 '### (未标日期)')
62
- };
63
-
64
- /** 旧名 → 新名。供 `abs init --repair` 一次性迁移(幂等)。
65
- * 只改匹配整行的标题,不动正文;不做模糊替换(防误改正文里提到的旧名)。 */
66
- // 分区标题归一(旧名 → 标准名)
67
- export const LEGACY_SECTION_RENAMES = [
68
- // H1(文件标题)
69
- ['# 🗂 图谱索引', '# 🗂 Graph Index'],
70
- ['# 🗒 操作日志', '# 🗒 Activity Log'],
71
- ['# 📋 Todo 看板', '# 📋 Todo Board'],
72
- // ## 分区
73
- // 四区 → 两区(2026-09-13 用户定:Backlog/Today 常年为空,进行时分区与实际工作流错配)。
74
- // 未完成的一律归 Todo,状态改由**行首标记**表达(见 TASK_STATES)。
75
- ['## Backlog', '## Todo'],
76
- ['## Today / In Progress', '## Todo'],
77
- ['## Blocked', '## Todo'],
78
- ['## Done(只留近期,旧的迁 log.md/快照)', '## Done'],
79
- // ### 区内分组标题
80
- ['### 归档', '### Archived'],
81
- ['### (未标日期)', '### Undated'],
82
- ];
83
-
84
- /** 把文件里的旧分区名就地改成新名(只改匹配整行的标题,不动正文)。
85
- * 返回 { text, changed:[旧名→新名] }。幂等:已改过的再跑 changed 为空。 */
86
- export function renameLegacySections(text) {
87
- const lines = String(text ?? '').split('\n');
88
- const changed = [];
89
- const out = lines.map((l) => {
90
- const t = l.trim();
91
- for (const [oldN, newN] of LEGACY_SECTION_RENAMES) {
92
- if (t === oldN) { changed.push(`${oldN} → ${newN}`); return newN; }
93
- }
94
- return l;
95
- });
96
- return { text: out.join('\n'), changed };
97
- }
98
-
99
- export function todoTemplate() {
100
- // 由 rebuildStructure 生成,保证“模板”与“重排结果”逐字节一致
101
- // (否则 load 会把新建的模板又重排一次 = 无意义的写盘)。
102
- // 注意(2026-09-13 踩坑):rebuildStructure 的 order 要**带 `## ` 前缀**,
103
- // 而 TODO_SECTIONS 是裸名(两者用途不同,不能复用 —— 曾误传裸名导致
104
- // 生成出没有 `##` 的裸标题行,模板直接损坏、Backlog 分区消失)。
105
- return rebuildStructure(
106
- ['# 📋 Todo Board', ...TODO_SECTIONS.map((s) => `## ${s}`)].join('\n'),
107
- { h1: '# 📋 Todo Board', order: TODO_SECTIONS.map((s) => `## ${s}`) },
108
- ).text;
109
- }
110
-
111
- // 只有两区(2026-09-13 用户定:精简)。为什么砍掉 Backlog/Today/Blocked:
112
- // 实测跨 4 个项目,Backlog 与 Today 常年为 **0 条** —— 而 log.md 有 120 条。
113
- // 根因:AI 的工作方式是「一口气做完」,任务从开始到完成都在同一会话内走完,
114
- // 中间那个「挂到进行时分区上」的动作既来不及也不需要发生。
115
- // 而 log 是在**结束时**写的,那个时点真实存在,所以它记满了。
116
- // 结论:进行时分区是符合直觉但不符合实际工作流的抽象 → 删掉,
117
- // 未完成的一律进 Todo,状态用**行首标记**表达(不靠分区区分)。
118
- export const TODO_SECTIONS = ['Todo', 'Done'];
119
- // todo 的 `###` 级标签(实测真实形态:`## Done` 下按日期分组 `### 2026-09-29`,
120
- // 归位的进 `### Archived`)。白名单只限这两类:日期组 + Archived。
121
- // 为什么必须列:`### ` 是 AI 最爱的「自建小节」位置(### 备忘 / ### 计划),
122
- // 只查 `## ` 会把它放过去。
123
- export const TODO_SUBSECTIONS = ['Archived'];
124
- /** `###` 标签是否放行:归档区 / 未标日期组 / 日期组 `YYYY-MM-DD`。
125
- * `Undated` 是 LEGACY_MARKS 里的正式标准名(旧 `### (未标日期)` → `### Undated`),
126
- * 漏了它会把存量文件卡死(实测)。 */
127
- export function isAllowedTodoSub(name) {
128
- return TODO_SUBSECTIONS.includes(name) || name === 'Undated' || /^\d{4}-\d{2}-\d{2}$/.test(name);
129
- }
130
-
131
- // ---------- 三份根文件的标签白名单与条目形状(2026-10-05 用户定)----------
132
- // 用户原话:「index Rules Concepts Entities Sources Syntheses Sessions 包含这些标签
133
- // 不允许增加新的标签,每个标签写内容的规则都是 - [[xx]]123 不允许乱写,同理 log todo 也是」
134
- //
135
- // 为什么需要:闸门只管骨架(分区顺序/H1/空行),**标签白名单与条目形状都没人管**。
136
- // rebuildStructure 的规矩 3 更是「非标准分区原样保留在末尾」(为了防丢人自加的内容)
137
- // → AI 新加一个 `## 备忘` 或 `## 计划` 永远合法,且会一直留在文件里。
138
- //
139
- // 注意 Rules 区是例外:它放的是**规矩短句**(「写代码前先读 docs/STRUCTURE.md」),
140
- // 不是页链接 —— 实测 13 条规则全是这个形态。不能拿 [[页名]] 去卡它。
141
- export const INDEX_SECTIONS = ['Rules', 'Concepts', 'Entities', 'Sources', 'Syntheses', 'Sessions'];
142
- /** 需要 `- [[页名]] …` 形状的区(Rules 除外)。 */
143
- export const INDEX_LINK_SECTIONS = ['Concepts', 'Entities', 'Sources', 'Syntheses', 'Sessions'];
144
- /** log 行的 kind 枚举(与 cmdLog 写入端同源;实测 238 条只有这 4 种)。 */
145
- export const LOG_KINDS = ['note', 'dev', 'concept', 'ingest'];
146
-
147
- /** 条目形状规则表:文件 → 每行的预期形状。
148
- * 校验器只报告「不像这个形状」的行,不自动改写内容(机器不猜语义)。 */
149
- /** 条目形状规则表:文件 → 每行的预期形状。
150
- * 校验器只报告「不像这个形状」的行,不自动改写内容(机器不猜语义)。 */
151
- export const H1_TO_FILE = {
152
- '# 🗂 Graph Index': 'index.md',
153
- '# 🗒 Activity Log': 'log.md',
154
- '# 📋 Todo Board': 'todo.md',
155
- };
156
-
157
- export const ENTRY_SHAPES = {
158
- 'index.md': [
159
- {
160
- name: 'INDEX-ENTRY',
161
- // 条目行:`- [[页名]] — 一句话`(是 6 个链接区里的一行)
162
- test: (l) => {
163
- if (!/^-\s/.test(l)) return null; // 非条目行(规则短句/说明)由分区规则另判
164
- if (!/^-\s*\[\[[^\]]+\]\]/.test(l)) return '应以 `- [[页名]] …` 开头';
165
- if (!/^-\s*\[\[[^\]]+\]\]\s*[—-]\s*\S/.test(l)) return '`[[页名]]` 后要跟 `— 一句话` 描述';
166
- return null;
167
- },
168
- },
169
- ],
170
- 'log.md': [
171
- {
172
- name: 'LOG-ENTRY',
173
- test: (l) => {
174
- if (!/^##\s/.test(l)) return null; // 非 `## ` 行(H1/注释)不管
175
- // ★ log 没有分区概念:`## ` 开头的只能是条目。
176
- // 否则 `## 2026-10-05 随便写点`(缺方括号/作者/竖线)会被当成「分区标题」滑过去 ——
177
- // 而 log.md 的标签白名单是 null(不校验),这就是一个漏网口(实测)。
178
- // 形状:## [YYYY-MM-DD HH:MM] [[作者]] kind | 正文
179
- // ★ 放行历史旧模板的占位行 `## [YYYY-MM-DD] ingest | 沉淀 <slug>`:
180
- // 它是早版 logTemplate 造出来的,不是人写的乱,而是模板自身的遗留。
181
- // 新模板已改成 HTML 注释(见 store.js logTemplate)。存量这行由 lint 报。
182
- if (/^##\s*\[YYYY-MM-DD\]/.test(l)) return null;
183
- const m = l.match(/^##\s*\[(\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2})?)\]\s*\[\[([^\]]+)\]\]\s*([A-Za-z]+)\s*\|\s*(\S.*)$/);
184
- if (!m) return '应为 `## [YYYY-MM-DD HH:MM] [[作者]] kind | 正文`(log 只能有这种行)';
185
- if (!LOG_KINDS.includes(m[3])) return `kind 只能是 ${LOG_KINDS.join('/')}(收到 \`${m[3]}\`)`;
186
- // 「正文只有个任务 id」不在这里拒(见下 lintOnly 规则):它是**内容质量**
187
- // 问题不是**结构格式**问题,当写入闸门会把存量脏库锁死(实测:真库仅一条
188
- // 就让所有 abs log 失败)。留在此处只会误伤正常短标题(如 `log-0`)。
189
- return null;
190
- },
191
- // 仅在 lint 阶段生效的额外规则(不阻写入,只报存量)。
192
- lintOnly: (l) => {
193
- const m = l.match(/^##\s*\[\d{4}-\d{2}-\d{2}[^\]]*\]\s*\[\[[^\]]+\]\]\s*[A-Za-z]+\s*\|\s*(\S.*)$/);
194
- if (!m) return null;
195
- if (/^[A-Z][A-Z0-9-]{2,}$/.test(m[1].trim())) return '正文只写了个任务 id(等于没记内容),补一句发生了什么';
196
- return null;
197
- },
198
- },
199
- ],
200
- 'todo.md': [
201
- {
202
- name: 'TODO-ENTRY',
203
- // 任务行形状(实测):
204
- // 新写入(cmdTask):`- [ ] [状态] id [[作者]] — 描述 (认领 日期)`
205
- // 旧存量(四区制迁移):`- [ ] 中文描述 (认领 日期)` ← 没有 id / 作者
206
- // 已完成:`- [x] id …`(markDone 会 stripStateMark 去掉状态)
207
- //
208
- // ★ 为什么不强制要求 id:旧存量的 id 缺失是**历史事实**,机器不能凭空编造
209
- // (编了就是静默改写语义)。闸门只确保「结构可解析」:
210
- // 未完成必须有 `[状态]`(归一后的标志);已完成必须有内容。
211
- // 而「新写入必须带 id」由 cmdTask 拼装保证 —— 那是入口的职责,
212
- // 不是事后逐行猜(猜不出哪行是新的、哪行是旧的)。
213
- test: (l) => {
214
- if (/^#{1,3}\s/.test(l)) return null; // 标题行(## Done / ### 日期)不是条目
215
- if (!/^-\s/.test(l)) return null; // 非条目行(如缩进的 ↳ 断点)由下面单独判
216
- // Done 区的归档行:`- [[log-日期]] 完成任务 N 条`。
217
- // ★ 历史命名不统一:还有 `[[2026-09-12-todo归档]]` 这种旧写法(实测),
218
- // 只认 `log-` 前缀会把它误报为「缺状态标记」。状归档行与任务行的
219
- // 区别不是命名而是**形态**:没有 `[ ]`/`[x]` 复选框的 [[链接]] 行。
220
- if (/^-\s*\[\[[^\]]+\]\]\s+完成任务/.test(l)) return null;
221
- if (/^-\s*\[\[log-\d{4}-\d{2}-\d{2}\]\]/.test(l)) return null;
222
- const m = l.match(/^-\s*\[([ x])\]\s*(.*)$/);
223
- if (!m) return '应以 `- [ ] ` 或 `- [x] ` 开头';
224
- const rest = m[2].trim();
225
- if (!rest) return '任务行不能只有复选框,要写做什么(已拒)';
226
- // 未完成:必须有状态标记(新写法)——无标记的旧行由 enforceBrainFormat 自动补上,
227
- // 走到这里还没标记 = 补不了(不是 `- [ ] ` 形状),报错。
228
- if (m[1] === ' ' && !/^\[[^\]]+\]\s*\S/.test(rest)) {
229
- return '未完成任务应为 `- [ ] [状态] …`(状态:进行中/讨论中/滞留中)';
230
- }
231
- // 断点附属行不能挤在同一行(应另起 ` ↳ 断点: `)
232
- if (/↳\s*断点:/.test(rest)) return '断点要另起一行写 ` ↳ 断点: …`';
233
- return null;
234
- },
235
- },
236
- ], // 任务行/断点行另由 assertTodoContent 卡长度与报告体
237
- };
238
-
239
- /** 校验单个文件的分区标签白名单 + 条目形状。返回问题列表(不抛错,供 lint 与写入闸门共用)。
240
- * reason 文案直接把出路写清楚 —— 报错不给出路等于让人挖坑。 */
241
- export function checkFileShape(file, text, { lintMode = false } = {}) {
242
- const issues = [];
243
- const lines = String(text ?? '').split('\n');
244
- const allowed = file === 'index.md' ? INDEX_SECTIONS : file === 'todo.md' ? TODO_SECTIONS : null;
245
-
246
- if (allowed) {
247
- for (let i = 0; i < lines.length; i++) {
248
- const l = lines[i];
249
- // `## [` 开头是 log 的条目行,不是分区标签(否则会把 238 条流水全判非法)
250
- if (/^##\s+\[/.test(l)) continue;
251
- const m = l.match(/^##\s+(\S.*)$/);
252
- const sub = l.match(/^###\s+(\S.*)$/);
253
- if (!m && !sub) continue;
254
- const name = (m ? m[1] : sub[1]).trim();
255
- // todo 的 `###` 标签单独判(只许日期组 + Archived)
256
- if (sub && file === 'todo.md') {
257
- if (!isAllowedTodoSub(name)) {
258
- issues.push({
259
- code: 'SECTIONS-NOT-ALLOWED',
260
- line: i + 1,
261
- msg: `todo.md:${i + 1} 不允许的小节 \`### ${name}\`;` +
262
- `todo.md 的 ### 只允许 \`### YYYY-MM-DD\`(Done 区日期组)、\`### Undated\` 与 \`### Archived\``,
263
- });
264
- }
265
- continue;
266
- }
267
- if (!allowed.includes(name)) {
268
- issues.push({
269
- code: 'SECTIONS-NOT-ALLOWED',
270
- line: i + 1,
271
- msg: `${file}:${i + 1} 不允许的标签 \`${m ? '##' : '###'} ${name}\`;` +
272
- `${file} 只允许 ${allowed.map((s) => `\`## ${s}\``).join(' / ')}${
273
- file === 'todo.md' ? '(`###` 只许日期组与 `### Archived`)' : ''}。` +
274
- `内容按语义归入现有标签,确实需要例外请先改白名单(见 src/todo.js 的 SECTIONS 常量)`,
275
- });
276
- }
277
- }
278
- }
279
-
280
- // 条目形状
281
- const shapes = ENTRY_SHAPES[file] || [];
282
- if (shapes.length) {
283
- // 先算每个行所属分区(形状只对被要求的区生效)
284
- let section = null;
285
- for (let i = 0; i < lines.length; i++) {
286
- const l = lines[i];
287
- // ★ 分区标题的识别不能吃掉 log 的条目行:log 的条目就是 `## [时间] …`,
288
- // 若把它当分区标题就会 continue 掉本行,形状校验形同虚设(实测:
289
- // kind=node / 缺竖线 全部漏放)。判据:`## [` 开头 = 条目,不是分区。
290
- // ★ 分区标题的识别不能吃掉 log 的条目行:log 的条目就是 `## [时间] …`,
291
- // 若把它当分区标题就会 continue 掉本行,形状校验形同虚设(实测:
292
- // kind=node / 缺竖线 全部漏放)。
293
- // ★ log.md 特殊:它**没有分区**,`## ` 开头的只能是条目(含写错的),
294
- // 所以 log 里绝不能把 `## ` 行当分区标题 —— 全交给形状规则判。
295
- const sm = file === 'log.md' ? null : (/^##\s+\[/.test(l) ? null : l.match(/^##\s+(\S.*)$/));
296
- if (sm) { section = sm[1].trim(); continue; }
297
- if (!l.trim()) continue;
298
- const needShape = file === 'log.md'
299
- ? true
300
- : file === 'todo.md'
301
- ? true // todo 的任务行形状与分区无关(Todo/Done 都要 `- [ ] …`)
302
- : indexLinkSectionNeeds(section);
303
- if (!needShape) continue;
304
- for (const rule of shapes) {
305
- const bad = rule.test(l);
306
- if (bad) issues.push({ code: rule.name, line: i + 1, msg: `${file}:${i + 1} ${bad}` });
307
- // lintOnly 规则只在体检时跑(不阻写入)—— 用于「内容质量」类问题,
308
- // 那些问题当闸门会把存量脏库锁死。
309
- if (!lintMode) continue;
310
- const bad2 = rule.lintOnly ? rule.lintOnly(l) : null;
311
- if (bad2) issues.push({ code: `${rule.name}-QUALITY`, line: i + 1, msg: `${file}:${i + 1} ${bad2}` });
312
- }
313
- }
314
- }
315
- // 条目之间的空行(与写入闸门 assertNoStrayBlank 同判据)。
316
- // lint 只能「报」,不能抛 —— 体检不能因格式坏而挂。
317
- const isEntryLine = (l) => /^-\s/.test(l) || /^#{1,3}\s+\[/.test(l);
318
- const isHeadingLine = (l) => /^#{1,3}\s/.test(l);
319
- for (let i = 0; i < lines.length; i++) {
320
- if (lines[i].trim()) continue;
321
- if (i > 0 && !lines[i - 1].trim()) continue; // 连续空行只报一次
322
- let p = i - 1;
323
- while (p >= 0 && !lines[p].trim()) p--;
324
- let n = i + 1;
325
- while (n < lines.length && !lines[n].trim()) n++;
326
- const prev = p >= 0 ? lines[p] : null;
327
- const next = n < lines.length ? lines[n] : null;
328
- if (!prev || !next) continue;
329
- if (isHeadingLine(prev)) continue; // 标签与首条目之间的空行是排版,不算乱
330
- if (isEntryLine(prev) && isEntryLine(next)) {
331
- issues.push({ code: 'STRAY-BLANK-LINE', line: i + 1, msg: `${file}:${i + 1} 条目之间不允许空行(条目必须紧贴)` });
332
- }
333
- }
334
- return issues;
335
- }
336
-
337
- /** index 的某个区是否要求 `- [[页名]]` 形状(Rules 与未知区不要求)。 */
338
- function indexLinkSectionNeeds(section) {
339
- return section !== null && INDEX_LINK_SECTIONS.includes(section);
340
- }
341
-
342
- /** 任务状态标记(行首,方括号)。替代原 Backlog/Today/Blocked 三区的区分作用。
343
- * 放在 id **之前**,与 Done 结语的 `【落地】` 形态区分开(那是行尾、结语用)。
344
- *
345
- * 「搁置」与「滞留中」的区别(2026-10-05 用户定,别合并):
346
- * 滞留中 = 还要做,只是卡住了(等外部/等信息) → 下会话该捡起来
347
- * 搁置 = 不做了 / 用户改方向了 → 下会话不该捡,除非用户又提
348
- * 混在一起会让下会话分不清「该不该接着干」。 */
349
- export const TASK_STATES = ['进行中', '讨论中', '滞留中', '搁置'];
350
-
351
- /** 结构重建(B 档):以标准分区表为准重排整个文件。
352
- *
353
- * 规则(保证内容不丢、不挪错):
354
- * 1. 按 spec.order 顺序输出标准分区,每个分区下放**归属于它**的内容行;
355
- * 2. 归属判定:按行所处的原分区归入对应标准分区;旧名先按 spec.renames 归一;
356
- * 3. **非标准分区**(人自加的,如 `## 备忘`)→ 内容连同它自己的标题一起**原样保留在末尾**,
357
- * 绝不合入已有标准分区(机器不知道它的语义,猜错就是挪错内容);
358
- * 4. 自由正文(不属于任何分区的行,如 H1 后的说明句)保留在 H1 之后;
359
- * 5. 幂等:已是标准结构 → 输出逐字节相同。
360
- *
361
- * 返回 { text, changed };changed 为空的描述列表(空=无需改盘)。 */
362
- export function rebuildStructure(text, spec) {
363
- const s = String(text ?? '');
364
- const lines = s.split('\n');
365
- const h1At = lines.findIndex((l) => l.trim().startsWith('# '));
366
- const bodyStart = h1At === -1 ? 0 : h1At + 1;
367
- const bucket = new Map(); // 标准分区名 -> 内容行
368
- const extras = []; // [{ title, lines }] 非标准分区,原样保留到末尾
369
- const preamble = []; // H1 与第一个 ## 之间的自由正文
370
- let curStd = null; // 当前在的标准分区名(null = 前言)
371
- let curExtra = null; // 当前在的额外分区对象
372
- for (let i = bodyStart; i < lines.length; i++) {
373
- const l = lines[i];
374
- if (/^#{2,3}\s/.test(l)) {
375
- const t = l.trim();
376
- const renamed = (spec.renames?.find(([o]) => o === t) || [, t])[1];
377
- if (spec.order.includes(renamed)) {
378
- curStd = renamed;
379
- curExtra = null;
380
- if (!bucket.has(curStd)) bucket.set(curStd, []);
381
- } else {
382
- curExtra = { title: renamed, lines: [] };
383
- extras.push(curExtra);
384
- curStd = null;
385
- }
386
- continue;
387
- }
388
- if (curExtra) curExtra.lines.push(l);
389
- else if (curStd) bucket.get(curStd).push(l);
390
- else preamble.push(l);
391
- }
392
- const out = [spec.h1];
393
- const pre = trimBlank(preamble);
394
- if (pre.length) out.push('', ...pre);
395
- // 每个分区标题前恒有一个空行(含空分区)。
396
- // 坑(2026-10-06 定位):旧写法对空分区走 `else out.push(name)` —— 不补空行,
397
- // 于是「有内容分区 → 空分区」的接缝被挤成 `- [[页]] …` 紧贴 `## Sources`。
398
- // 表现成「index 里那个空行反复丢」:人工补回 → 下次任意写操作(过 enforceBrainFormat)
399
- // 又归一掉,来回拉锯(本机 corp_agent 实测,17961e5 补、f3f7e84 又丢)。
400
- // 原注释担心「每次首跑都写盘一次」——实测不会:归一后自身就是稳定形态,
401
- // 第二次跑 fixed 为空、零字节改动(幂等,见 test/todo 的自检)。
402
- // H1 后必须恒有空行:`# H1` 与首个分区相贴是门面事故,不容忍。
403
- for (const [idx, name] of spec.order.entries()) {
404
- const body = trimBlank(bucket.get(name) || []);
405
- out.push('', name, ...body);
406
- }
407
- for (const e of extras) {
408
- const body = trimBlank(e.lines);
409
- out.push('', e.title);
410
- if (body.length) out.push(...body);
411
- }
412
- const next = out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '') + '\n';
413
- return { text: next, changed: next === s ? [] : ['结构按标准重排'] };
414
- }
415
-
416
- /** 去掉首尾空行,不动中间(保留用户的分段)。 */
417
- function trimBlank(arr) {
418
- const a = [...arr];
419
- while (a.length && !a[0].trim()) a.shift();
420
- while (a.length && !a[a.length - 1].trim()) a.pop();
421
- return a;
422
- }
423
-
424
- /** 叶子条目行:log 的 `## [时间] …` 条目、todo/index 的 `- …` 行。
425
- * 空行落在两个叶子条目之间 = 人为排版漂移,会随条目增长把文件撑成两倍行数。
426
- * 注意(踩坑):标题行不算 —— `## Done` 与 `### 日期` 之间的空行是分区排版,
427
- * 删了会把 Done 区挤成一片(首版误删)。 */
428
- const isLeafEntry = (l) => /^- |^#{1,3}\s+\[/.test(l);
429
-
430
- /** 已废弃的标准分区(2026-10-05 用户定,白名单只一条)。
431
- * `## Roadmap` 是 2026-09-13 用户亲手删的分区(`store.js` 注释写明原因:
432
- * “AI 自己写的方向总结,会被 load 反复读到并带偏后续会话”)。
433
- * 但 `rebuildStructure` 的规矩 3 是「非标准分区原样保留在末尾」——那条护栏是为了
434
- * 防丢失人自加的区(如 `## 备忘`),机器不猜语义。结果是 Roadmap 被当成“人自加的区”
435
- * 留了下来,而且 AI 每次重写 index 都能把它加回来:删一个分区的决策根本没生效。
436
- * 所以这里单列一张白名单,只放**被正式删过的标准分区**——它们进闸门就被整段丢弃;
437
- * 从没见过的(`## 备忘` 类)仍按护栏原样保留。不做通用机制,出现第二个再添。 */
438
- export const RETIRED_SECTIONS = ['## Roadmap'];
439
-
440
- /** 删除废弃分区的整段(标题到下一个同级/更高级标题前)。
441
- * 返回 { text, removed:[区名] };只按整行精确匹配标题,不碰正文。 */
442
- export function dropRetiredSections(text) {
443
- const lines = String(text ?? '').split('\n');
444
- const removed = [];
445
- const out = [];
446
- let dropping = false;
447
- let dropLevel = 0;
448
- for (const l of lines) {
449
- const m = l.match(/^(#{2,3})\s+(.*)$/);
450
- if (m) {
451
- const title = `${m[1]} ${m[2].trim()}`;
452
- if (RETIRED_SECTIONS.includes(title)) {
453
- removed.push(title);
454
- dropping = true;
455
- dropLevel = m[1].length;
456
- continue;
457
- }
458
- // 遇到同级或更高级的标题 = 废弃区结束
459
- if (dropping && m[1].length <= dropLevel) dropping = false;
460
- }
461
- if (!dropping) out.push(l);
462
- }
463
- return { text: out.join('\n'), removed };
464
- }
465
-
466
- /** 写入侧格式闸门:三个文件在**每次写盘前**都过这里,不是只在 load 时修。
467
- *
468
- * 规则(用户 2026-10-05 定):
469
- * 1. 固定样式 — 标题/分区名必须逐字对标准,不符按 LEGACY_MARKS 归一;
470
- * 2. 不允许空行 — 条目之间不留空行(正文段落内的空行保留);
471
- * 3. 结构固定 — 无 H1 的「无头文件」补回标准 H1,分区按标准顺序重排(rebuildStructure)。
472
- *
473
- * 入参 spec 与 checkBrainShape 的 BRAIN_SHAPE 同源(见 store.js)。
474
- * log.md 无分区(order 为空)→ 只做 H1/归一/去空行,不重排条目顺序(时间倒序自带语义)。
475
- * 返回 { text, fixed:[描述] };fixed 为空 = 无需改盘。 */
476
- // ---------- 条目级闸门(2026-10-05 加)----------
477
- // 为什么加:闸门此前只管**骨架形状**(分区/H1/空行),不管条目**内容**。
478
- // 于是 AI 把 todo 当笔记本 —— 实施报告、测试数据、需求清单、架构分析全塞进
479
- // 断点行,文件涨到 232 行/上万字,而闸门与 lint 双双报「0 问题」。
480
- // 这里补上内容约束:todo 只能放 todo。长内容走 abs note(经验)/ abs log(流水)/ sources 页。
481
- //
482
- // 注意:**超长一律抛错,不静默截断**。截断会丢数据且无声 —— 正是要防的那种失效。
483
- export const BREAKPOINT_MAX = 200;
484
- export const TASK_LINE_MAX = 400;
485
-
486
- /** 一行的「违规签名」:去空白、截前 120 字。
487
- * 用途:比对「这条违规是不是写入前就有的」——新增的拒,存量的放。
488
- * 不按行号比对:插入一行会让后面所有行号位移,按行号会误判。 */
489
- function sigOfLine(l) {
490
- return String(l).replace(/\s+/g, ' ').trim().slice(0, 120);
491
- }
492
-
493
- /** 把一份文本里所有「违规行」的签名收成集合(供存量/新增比对)。
494
- * 只有三个根文件用得上;非目标文件直接返回空集。
495
- * ★ 空行特殊:签名取「上下两行拼接」不可靠 —— 插入一行就会让空行的
496
- * 相邻行变样,导致存量空行被当成新引入(实测:cmdNote 插一行后,
497
- * 同一处空行的签名就对不上了)。所以空行只记一个存在标记:
498
- * 入库前就有「条目间空行」这个毛病 → 后续写入全放行(lint 报)。 */
499
- export function violationSignatures(text) {
500
- const set = new Set();
501
- const lines = String(text ?? '').split('\n');
502
- for (const l of lines) set.add(sigOfLine(l));
503
- const isEntry = (l) => /^-\s/.test(l) || /^#{1,3}\s+\[/.test(l);
504
- for (let i = 1; i < lines.length - 1; i++) {
505
- if (lines[i].trim()) continue;
506
- if (isEntry(lines[i - 1]) && isEntry(lines[i + 1])) {
507
- set.add('HAS-STRAY-BLANK');
508
- break;
509
- }
510
- }
511
- return set;
512
- }
513
- /** 断点里出现这些 = 明显是在塞报告(列表/代码块/表格)。 */
514
- const REPORT_MARKERS = [
515
- /^\s{0,4}[-*]\s+\S/m, // 列表项
516
- /^\s{0,4}\d+[.)]\s+\S/m, // 有序列表
517
- /```/, // 代码块
518
- /^\s*\|.*\|\s*$/m, // 表格行
519
- ];
520
-
521
- /** 数一个断点里有几个「句子」—— 断点是一件事,多句 = 在塞报告。
522
- * 分隔符只用**句末标点**(。!?!?)与带圈编号 ①②③。
523
- * ★ 分号(;/;)**不算**分隔符:中文技术写作里它常用来连接同一个意思的两部分
524
- * (「改到哪个文件;同类约束见 xx」),算成两句会误伤正常断点(实测:
525
- * 一条正常的「改到哪 + 同类约束 + 待确认」被判 3 句而拒绝写入)。
526
- * 为什么不用关键词表:关键词命中不稳(「功能/方案」正常描述也会出现),
527
- * 而「一句还是一段」是结构判据 —— 结构判据才可靠。 */
528
- function sentenceCount(s) {
529
- return String(s)
530
- .split(/[。!?!?]+|\s*[\u2460-\u2473]\s*/)
531
- .map((x) => x.trim())
532
- .filter(Boolean).length;
533
- }
534
-
535
- /** 「绝对不允许乱空行」(2026-10-05 用户定):条目之间不能有空行。
536
- * 为什么必须拒而不只是修:旧实现是「自动删」(fixed.push('删除条目之间的空行')),
537
- * 但那是**默默改盘** —— 写进去的东西被改了而 AI 不知道,下次又写一遍。
538
- * 现在直接报错,让写入方自己写对。
539
- * 允许的位置:H1 与首个标签之间、标签与首条目之间、区与区之间(排版需要)。 */
540
- export function assertNoStrayBlank(text, file, prevSig = null) {
541
- const lines = String(text ?? '').split('\n');
542
- const isEntry = (l) => /^-\s/.test(l) || /^#{1,3}\s+\[/.test(l);
543
- const isHeading = (l) => /^#{1,3}\s/.test(l);
544
- for (let i = 0; i < lines.length; i++) {
545
- if (lines[i].trim()) continue;
546
- // 找上一个/下一个非空行
547
- let p = i - 1;
548
- while (p >= 0 && !lines[p].trim()) p--;
549
- let n = i + 1;
550
- while (n < lines.length && !lines[n].trim()) n++;
551
- const prev = p >= 0 ? lines[p] : null;
552
- const next = n < lines.length ? lines[n] : null;
553
- if (prev === null || next === null) continue; // 文件头尾空行不算
554
- // 连续多个空行:报一次就够
555
- if (i > 0 && !lines[i - 1].trim()) continue;
556
- if (!isEntry(prev) && !isEntry(next)) continue; // 正文段落之间允许
557
- // ★ 标题与首个条目之间必留一个空行(排版必需)—— H1/标签后紧跟条目反而难看。
558
- // 实测踩坑:log 模板就是 `# H1` + 空行 + `## [日期] …`,不放行会把模板本身卡死。
559
- if (isHeading(prev)) continue;
560
- // 两个条目之间(含「条目 → 空行 → 条目」)不允许
561
- if (isEntry(prev) && isEntry(next)) {
562
- // 存量空行(写入前就有)→ 放行,交给 lint 报
563
- if (prevSig !== null && prevSig.has('HAS-STRAY-BLANK')) continue;
564
- throw new Error(
565
- `✗ ${file} 不允许条目之间的空行(第 ${i + 1} 行):\n` +
566
- ` 上一行: ${prev.slice(0, 50)}\n` +
567
- ` 下一行: ${next.slice(0, 50)}\n` +
568
- ` 条目必须紧贴;标签与首条目之间才留一个空行。已拒绝写入 —— 请删掉这个空行再写。`,
569
- );
570
- }
571
- }
572
- return true;
573
- }
574
-
575
- /** 校验 todo 全文的条目内容。抛错 = 拒绝写入(不是修正)。 */
576
- export function assertTodoContent(text, prevSig = null) {
577
- const lines = String(text ?? '').split('\n');
578
- /** 存量行(写入前就存在)→ 放行,交给 lint 报。 */
579
- const isLegacy = (l) => prevSig !== null && prevSig.has(sigOfLine(l));
580
- for (let i = 0; i < lines.length; i++) {
581
- const l = lines[i];
582
- const lineNo = i + 1;
583
- if (l.startsWith('## ')) continue;
584
- // 任务行
585
- if (isTaskLine(l)) {
586
- if (l.length > TASK_LINE_MAX && !isLegacy(l)) {
587
- throw new Error(
588
- `✗ 任务行太长(第 ${lineNo} 行 ${l.length} 字 > ${TASK_LINE_MAX})—— todo 只放「要做什么」,一句话。\n` +
589
- ` 这么长的内容要么拆成多条任务,要么走 abs note(经验)/ abs log(流水)。\n` +
590
- ` 原文开头: ${l.slice(0, 60)}...`,
591
- );
592
- }
593
- continue;
594
- }
595
- // 断点附属行
596
- if (l.trimStart().startsWith('↳ 断点:')) {
597
- if (isLegacy(l)) continue; // 存量超长断点:不阻写入(lint 报)
598
- const body = l.trimStart().slice('↳ 断点:'.length).trim();
599
- if (body.length > BREAKPOINT_MAX) {
600
- throw new Error(
601
- `✗ 断点太长(第 ${lineNo} 行 ${body.length} 字 > ${BREAKPOINT_MAX})—— 断点只写「改到哪个文件哪一步」。\n` +
602
- ` 写不下的内容请分流:实施报告/验证数据 → abs log;经验与坑 → abs note;长设计 → .brain/sources/ 页。\n` +
603
- ` 原文开头: ${body.slice(0, 60)}...`,
604
- );
605
- }
606
- if (REPORT_MARKERS.some((re) => re.test(body)) || sentenceCount(body) > 2) {
607
- throw new Error(
608
- `✗ 断点里像是塞了报告/清单(第 ${lineNo} 行)—— 断点是一句话(最多两句),不是文档。\n` +
609
- ` todo 只能加 todo:要做的拆成任务,做完的写 abs log,经验写 abs note。\n` +
610
- ` 原文开头: ${body.slice(0, 60)}...`,
611
- );
612
- }
613
- continue;
614
- }
615
- }
616
- return true;
617
- }
618
-
619
- /** 删掉「条目之间的空行」(条目前后紧贴才是标准形态)。
620
- * 在 enforceBrainFormat 里跑两次:assertShape 前(清存量,否则存量空行会把写入全卡死)、
621
- * 重建后(重排会新建邻接关系)。幂等 —— 第二次通常零改动。
622
- * 为什么删而不是拒:空行不携带信息,删了不丢东西;而断点/条目形状带着内容,
623
- * 没有安全的自动修法,只能拒并让人处理。 */
624
- function stripStrayBlanks(body, fixed) {
625
- const lines = String(body).split('\n');
626
- const kept = [];
627
- for (let i = 0; i < lines.length; i++) {
628
- const l = lines[i];
629
- if (!l.trim()) {
630
- const prev = kept[kept.length - 1];
631
- // 前瞻要跳过连续空行,否则「两个空行」里只有第一个被删(两个空行是常见形态,
632
- // 首版留下一个 → 规则形同虚设)。
633
- let j = i + 1;
634
- while (j < lines.length && !lines[j].trim()) j++;
635
- const next = lines[j];
636
- if (prev !== undefined && next !== undefined && isLeafEntry(prev) && isLeafEntry(next)) {
637
- fixed.push('删除条目之间的空行');
638
- continue;
639
- }
640
- }
641
- kept.push(l);
642
- }
643
- return kept.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '') + '\n';
644
- }
645
-
646
- export function enforceBrainFormat(text, spec, prev) {
647
- const src = String(text ?? '');
648
- if (!src.trim()) return { text: src, fixed: [] };
649
- // 内容闸门:形状归一之前先拒掉乱塞(超长断点/报告体),否则下面的 rebuild
650
- // 会把它当「人自加的正文」好好保留下来 —— 越规整越难发现。
651
- // ★ 存量宽容(2026-10-05 本机实测踩到):只拒「本次写入新引入」的违规;
652
- // 写入前就存在的违规放行(由 lint 报出)。
653
- // 为什么:不做这个区分,库里只要有一条历史脏行,**所有写入全部失败** ——
654
- // 实测真库(corp_agent)就因一条超长断点 + 8 条 `[]()` 条目,
655
- // 连 `abs rule add` 都做不到,整个图谱变成只读。
656
- const prevSig = prev == null ? null : violationSignatures(String(prev));
657
- if (spec?.h1 === '# 📋 Todo Board') assertTodoContent(src, prevSig);
658
- // ★ 标签白名单闸门放在**旧标记归一之后**(见下面 legacy 段之后)——
659
- // 旧标签(`## Backlog` / `## Today / In Progress`)是**待迁移**的,不是非法新标签。
660
- // 首版把校验放在归一之前,把存量老文件全卡死(7 个测试红,2026-10-05 实测)。
661
- const fname = H1_TO_FILE[spec?.h1];
662
- let checked = false;
663
- const assertShape = (t) => {
664
- if (!fname || checked) return;
665
- checked = true;
666
- // ★ 空行:直接拒绝(用户 2026-10-05:「绝对不允许乱空行」)。
667
- // 存量库里本来就有空行的,在**写入前**先被归一清除(见 enforceBrainFormat 尾部),
668
- // 所以走到这里仍有空行 = 本次写入引入的 → 拒。
669
- assertNoStrayBlank(t, fname);
670
- // ★ 标签/条目形状:存量脏行放行(不因历史数据锁死库),新增的拒。
671
- // 为什么这类要宽容而空行不要:超长断点/`[]()` 条目**没有安全的自动修法**
672
- // (截断会丢数据、改格式会改语义),只能报给人看;而空行删了不丢任何信息。
673
- const tLines = t.split('\n');
674
- const bad = checkFileShape(fname, t).filter(
675
- (it) => it.code !== 'STRAY-BLANK-LINE' &&
676
- !(prevSig !== null && prevSig.has(sigOfLine(tLines[it.line - 1] || ''))),
677
- );
678
- if (bad.length) {
679
- throw new Error(
680
- `✗ ${fname} 格式不合规(写入被拒):\n` +
681
- bad.map((it) => ` · ${it.msg}`).join('\n') +
682
- `\n 出路:内容归入现有标签,或走对应入口(页 → concepts/ 等子目录 + index 登记;流水 → abs log;经验 → abs note)。`,
683
- );
684
- }
685
- };
686
- const fixed = [];
687
- // 旧标记 → 标准标记(H1 与分区/分组标题)。与 store.js 的 LEGACY_MARKS 同源,
688
- // 由调用方通过 spec.renames 注入(todo.js 不反向依赖 store.js)。
689
-
690
- // (0) 废弃分区:被正式删过的标准分区(如 Roadmap)整段丢弃,不等 rebuildStructure
691
- // 把它当“人自加的区”留到末尾 —— 否则删分区的决策每次都被 AI 重写覆盖回去。
692
- const retired = dropRetiredSections(src);
693
- if (retired.removed.length) {
694
- fixed.push(`删除已废弃分区: ${retired.removed.join(', ')}`);
695
- }
696
- // (1) 样式:旧标题名先归一到标准(LOG 无分区、不走 rebuildStructure,这条是它的唯一归一者)。
697
- let body = retired.text;
698
- const renames = spec.renames || [];
699
- if (renames.length) {
700
- const ls = body.split('\n');
701
- for (let i = 0; i < ls.length; i++) {
702
- const t = ls[i].trim();
703
- const hit = renames.find(([o]) => o === t);
704
- if (hit && ls[i] !== hit[1]) { ls[i] = hit[1]; fixed.push(`${hit[0]} → ${hit[1]}`); }
705
- }
706
- body = ls.join('\n');
707
- }
708
-
709
- // ★ 旧格式自动升级(2026-10-05):闸门不能只会拒,还得会**修**。
710
- // 用户要求「旧版本旧样式要能自动更新为新样式」。
711
- // 这里把已知的旧形态归一为新形态,**归一之后再校验白名单** ——
712
- // 否则存量老文件(无状态标记的裸任务行)会被当成违规而卡死(实测:
713
- // 底层 addTask/upsertTask 传的就是裸文本,它们是内部 API,不是 AI 入口)。
714
- if (fname === 'todo.md') {
715
- const ls = body.split('\n');
716
- for (let i = 0; i < ls.length; i++) {
717
- // 裸任务行(`- [ ] id …` 无状态标记)→ 补默认「进行中」
718
- if (/^- \[ \] /.test(ls[i]) && !/^- \[ \] \[[^\]]+\]/.test(ls[i])) {
719
- const fixed2 = ensureStateMark(ls[i], '进行中');
720
- if (fixed2 !== ls[i]) { ls[i] = fixed2; fixed.push('补回缺失的状态标记'); }
721
- }
722
- }
723
- body = ls.join('\n');
724
- }
725
-
726
- // ★ 存量空行先清掉(2026-10-05):必须跑在 assertShape **之前** ——
727
- // 否则 assertShape 会看到存量空行而拒绝写入(实测把整个脏库锁成只读)。
728
- // 删空行不丢任何信息,所以这里直接修,而不是报错。
729
- body = stripStrayBlanks(body, fixed);
730
-
731
- // ★ 白名单闸门在这里跑:旧格式已升级、旧标记已归一、存量空行已清,
732
- // 剩下的违规才是真的本次引入。
733
- assertShape(body);
734
-
735
- // (3) 结构:先补 H1,再按标准重排分区。
736
- const hasH1 = body.split('\n').some((l) => l.trim().startsWith('# '));
737
- if (!hasH1) {
738
- body = [spec.h1, '', body.replace(/^\n+/, '')].join('\n');
739
- fixed.push(`补回缺失的 H1: ${spec.h1}`);
740
- }
741
- if (spec.order?.length) {
742
- const r = rebuildStructure(normalizeTodoIf(body, spec), spec);
743
- if (r.changed.length) fixed.push(...r.changed);
744
- body = r.text;
745
- }
746
-
747
- // (2) 去条目间空行:同 stripStrayBlanks,但重建后再跑一次(重排会新建邻接关系)。
748
- const out = stripStrayBlanks(body, fixed);
749
- return { text: out, fixed };
750
- }
751
-
752
- /** todo.md 在重排前必须先走 normalizeTodo(知道旧的 Blocked → 滞留中 语义),
753
- * 其余文件原样进(rebuildStructure 自带旧分区名归一)。 */
754
- function normalizeTodoIf(body, spec) {
755
- return spec.h1 === '# 📋 Todo Board' && spec.order?.[0] === '## Todo' ? normalizeTodo(body) : body;
756
- }
757
-
758
- export function normalizeTodo(text) {
759
- const lines = text.split('\n');
760
- const has = (name) => lines.some((l) => l.trim() === `## ${name}`);
761
- // 已是标准两区制 = 有 Todo 且**没有**任何进行时老分区。
762
- // 坑(2026-09-13):曾只判 has('Todo') 就 return —— 而最老格式里也可能有 `## Todo`
763
- // (与 `## In Progress` 混用),于是旧文件永不迁移。
764
- const legacy = ['Backlog', 'Today / In Progress', 'In Progress', 'Blocked'].some(has);
765
- if (has('Todo') && !legacy) return text;
766
- const out = ['# 📋 Todo Board'];
767
- const grab = (name) => {
768
- const items = [];
769
- let inSec = false;
770
- for (const l of lines) {
771
- if (l.startsWith('## ')) { inSec = l.trim() === `## ${name}`; continue; }
772
- if (inSec && l.trim() && !isPlaceholder(l)) items.push(l);
773
- }
774
- return items;
775
- };
776
- const done = grab('Done');
777
- // 未完成的一切(不论原来在 Backlog/In Progress/Todo/Blocked)→ 一律进 Todo。
778
- // 状态信息由行首标记承接:原 Blocked 区的任务补 [滞留中](其余默认 [进行中],写入时补)。
779
- const open = [];
780
- for (const name of ['Backlog', 'Today / In Progress', 'In Progress', 'Todo']) {
781
- open.push(...grab(name).map((l) => ensureStateMark(l, '进行中')));
782
- }
783
- open.push(...grab('Blocked').map((l) => ensureStateMark(l, '滞留中')));
784
- out.push('## Todo', ...open);
785
- out.push('## Done', ...done);
786
- return out.join('\n');
787
- }
788
-
789
- /** 给任务行补行首状态标记(已有则不覆盖)。`- [ ] id …` → `- [ ] [状态] id …` */
790
- export function ensureStateMark(line, state) {
791
- const m = String(line).match(/^(- \[[ x]\] )(\[[^\]]+\]\s+)?(.*)$/);
792
- if (!m) return line;
793
- if (m[2] && TASK_STATES.includes(m[2].trim().replace(/[\[\]]/g, ''))) return line;
794
- return `${m[1]}[${state}] ${m[3]}`;
795
- }
796
-
797
- /** 取任务行的状态标记;无标记返回 null。 */
798
- export function stateOfTaskLine(line) {
799
- const m = String(line).match(/^- \[[ x]\] \[([^\]]+)\]/);
800
- return m && TASK_STATES.includes(m[1]) ? m[1] : null;
801
- }
802
-
803
- /** 改任务行的状态标记(原地)。未找到或非法状态返回原样。 */
804
- export function setStateMark(text, id, state) {
805
- if (!TASK_STATES.includes(state)) return { text, changed: [] };
806
- const lines = String(text ?? '').split('\n');
807
- const idx = findTaskLine(lines, id);
808
- if (idx === -1) return { text, changed: [] };
809
- const cur = lines[idx];
810
- if (stateOfTaskLine(cur) === state) return { text, changed: [] };
811
- const raw = stripStateMark(cur); // 先剥旧标记,再补新的
812
- lines[idx] = raw.replace(/^(- \[[ x]\] )/, `$1[${state}] `);
813
- return { text: lines.join('\n'), changed: [`${id} 状态 → ${state}`] };
814
- }
815
-
816
- /** 剥掉行首状态标记。 */
817
- export function stripStateMark(line) {
818
- return String(line).replace(/^(- \[[ x]\] )\[[^\]]+\] /, '$1');
819
- }
820
-
821
- /** 从已完成任务行提取 `(完成 YYYY-MM-DD)` 日期;无则返回 ''。兼容中英文括号。 */
822
- export function doneDateOf(line) {
823
- const m = String(line).match(/\(完成\s*(\d{4}-\d{2}-\d{2})[^)]*\)/);
824
- return m ? m[1] : '';
825
- }
826
-
827
- // ---------- Done 结语契约 ----------
828
- // 为什么需要: `[x]` 原本同时表示「真落地」「评估后不做」「仅设计过」三种完全不同的状态,
829
- // 读的人(下一个会话/未来的自己)无法区分。实测翻车: 读 daemon 条的 [x] 当成已落地,
830
- // 实际它跑通后被撤销、代码全删 —— 基于假记录得出「daemon 是过配项」的错误结论。
831
- // 结论: 图谱的价值取决于「可被信任」,一条状态失真的记录比一百条冗长记录的危害大一个量级。
832
- export const DONE_KINDS = ['落地', '否决', '仅方案'];
833
-
834
- /** 从 Done 任务行提取结语标记;无标记返回 ''。 */
835
- export function doneKindOf(line) {
836
- const m = String(line).match(/【(落地|否决|仅方案)】/);
837
- return m ? m[1] : '';
838
- }
839
-
840
- /** 追写结语到任务行:插在 `(完成 …)` 之前,幂等(已有则原位替换)。 */
841
- export function withDoneKind(line, kind) {
842
- if (!kind) return line;
843
- const base = String(line).replace(/【(落地|否决|仅方案)】/g, '').replace(/\s{2,}/g, ' ').trimEnd();
844
- const i = base.lastIndexOf(' (完成 ');
845
- if (i === -1) return `${base} 【${kind}】`;
846
- return `${base.slice(0, i)} 【${kind}】${base.slice(i)}`;
847
- }
848
-
849
- /** 判断一行是否为任务行(- [x] / - [ ],允许缩进)。 */
850
- function isTaskLine(l) {
851
- return /^\s*- \[[ x]\]/.test(l);
852
- }
853
- /** 判断一行是否为任务附属行(↳ 开头)。 */
854
- function isChildLine(l) {
855
- return /^\s*↳/.test(l);
856
- }
857
-
858
- /** 把 Done 区文本(bodyLines,不含 `## Done` 标题)解析成任务单元 [{ date, lines:[main,...children] }]。
859
- * 剥 `### 日期` 分组标题与空行;任务行下紧跟的 ↳ 行并入该单元。 */
860
- function parseDoneUnits(bodyLines) {
861
- const units = [];
862
- let cur = null;
863
- for (const l of bodyLines) {
864
- if (/^### /.test(l.trim()) || l.trim() === '') { cur = null; continue; } // 分组标题/空行断开会话
865
- if (isTaskLine(l)) {
866
- cur = { date: doneDateOf(l), lines: [l] };
867
- units.push(cur);
868
- } else if (isChildLine(l) && cur) {
869
- cur.lines.push(l); // 附属行挂到上一个任务
870
- }
871
- }
872
- return units;
873
- }
874
-
875
- /** 按 (完成 date) 分组 Done 任务单元并重建文本行:新日期在前,未标日期归尾组。
876
- * 同组内保持输入顺序(幂等)。返回不含 `## Done` 标题的主体行。 */
877
- function renderDoneGroups(units) {
878
- const byDate = new Map();
879
- const undated = [];
880
- for (const u of units) {
881
- if (!u.date) undated.push(u);
882
- else {
883
- if (!byDate.has(u.date)) byDate.set(u.date, []);
884
- byDate.get(u.date).push(u);
885
- }
886
- }
887
- const dates = [...byDate.keys()].sort().reverse(); // 新日期在前
888
- const out = [];
889
- for (const d of dates) out.push(`### ${d}`, '', ...byDate.get(d).flatMap((u) => u.lines), '');
890
- if (undated.length) out.push(`### ${SEC.undated}`, '', ...undated.flatMap((u) => u.lines), '');
891
- return out;
892
- }
893
-
894
- /** 把 Done 区从「全量行」折叠为「按日期计数」,供 `abs load` / `abs todo` 这类
895
- * 给人(和 AI 上下文)看的视图用。
896
- *
897
- * 坑: 这两个命令此前直接 `todo.trim()` 全量打印,而 Done 区**无上限增长** ——
898
- * 本仓库一处就占 load 输出的 68.8%(19.6KB/28.4KB)。长历史机器上直接把
899
- * 上下文塞满(实报:「另一台机器 abs load 塞了 40%」)。
900
- * 这与 hooks.log / wrapup.log 同类问题(那两处有轮转),故这里只给计数,
901
- * 要看明细用 `abs todo` 加 `--full`,或直接看 todo.md / 归档页。
902
- *
903
- * `### 归档` 区(历史的「归档 N 条」标记行)体积小且是长期引用,原样保留。
904
- * 返回 { text, doneCount };text 不含 `## Done` 标题行。 */
905
- export function collapseDone(text) {
906
- const lines = String(text).split('\n');
907
- const di = lines.findIndex((l) => l.startsWith('## Done'));
908
- if (di === -1) return { text: String(text).trim(), doneCount: 0 };
909
- const head = lines.slice(0, di);
910
- const rest = lines.slice(di + 1);
911
- const { groupLines, archiveLines } = splitDoneBody(rest);
912
- const units = parseDoneUnits(groupLines);
913
- // 按日期归组计数(新日期在前),未标日期的归尾
914
- const byDate = new Map();
915
- let undated = 0;
916
- for (const u of units) {
917
- if (!u.date) undated++;
918
- else byDate.set(u.date, (byDate.get(u.date) || 0) + 1);
919
- }
920
- const counts = [...byDate.entries()]
921
- .sort((a, b) => (a[0] < b[0] ? 1 : -1))
922
- .map(([d, n]) => `${d} ${n} 条`);
923
- if (undated) counts.push(`未标日期 ${undated} 条`);
924
- // 只列最近 MAX_DONE_DATE_LINES 个日期组:日期组本身也随历史增长(实测 300 条跨 28 天
925
- // 会把 load 输出从 660B 拉到 1203B)。载入视图只需"最近做了多少",早期历史看归档页。
926
- const MAX_DONE_DATE_LINES = 7;
927
- const shown = counts.slice(0, MAX_DONE_DATE_LINES);
928
- if (counts.length > MAX_DONE_DATE_LINES) {
929
- const rest = counts.length - MAX_DONE_DATE_LINES;
930
- shown.push(`… 另有 ${rest} 个更早日期组(abs todo --full 看全量)`);
931
- }
932
- const doneLines = units.length
933
- ? [`## Done(${units.length} 条,按日期折叠)`, ...shown.map((c) => `- ${c}`)]
934
- : ['## Done(0 条)'];
935
- const out = [...head, ...doneLines, '', ...archiveLines].join('\n').replace(/\n{3,}/g, '\n\n').trim();
936
- return { text: out, doneCount: units.length };
937
- }
938
-
939
- /** 归档标记区标题。它在 Done 区内部、日期分组之后,形如:
940
- * ### Archived
941
- * - [[2026-09-10-todo归档]] 完成任务 10 条
942
- * 这区不是任务行,不能被 parseDoneUnits 吃挂,否则 markDone 重建 Done 时会把它丢掉。
943
- * 故先切出去当"不透明区域"原样保留。 */
944
- const ARCHIVE_HEADING = `### ${SEC.archived}`;
945
-
946
- /** 把 Done 主体行切成 { groupLines, archiveLines }:把 `### 归档` 区当"不透明块"原样保留。
947
- * 注意不能简单"从标题切到文件尾" —— 否则一旦有日期组落在归档区之后(手改/旧数据),
948
- * 它会被当不透明内容除在分组之外,永远不参与归档。故只取到下一个 ###/## 标题为止。 */
949
- function splitDoneBody(bodyLines) {
950
- const ai = bodyLines.findIndex((l) => l.trim() === ARCHIVE_HEADING);
951
- if (ai === -1) return { groupLines: bodyLines, archiveLines: [] };
952
- let end = ai + 1;
953
- while (end < bodyLines.length && !/^#{2,3} \S/.test(bodyLines[end].trim())) end++;
954
- return {
955
- groupLines: [...bodyLines.slice(0, ai), ...bodyLines.slice(end)],
956
- archiveLines: bodyLines.slice(ai, end),
957
- };
958
- }
959
-
960
- /** 合并归档标记行:**每天一行** `- [[<日期>-todo归档]] 完成任务 N 条`。
961
- * 计数按 slug **累计**(同一天分两批归档时叠加,否则标记行与归档页实际内容不符);
962
- * 已存在的其他天的标记行原样保留。返回含标题的行数组。 */
963
- function mergeArchiveLines(existing, entries) {
964
- const out = existing.length ? [...existing] : [ARCHIVE_HEADING];
965
- while (out.length && !out[out.length - 1].trim()) out.pop();
966
- for (const { slug, count } of entries) {
967
- const i = out.findIndex((l) => l.includes(`[[${slug}]]`));
968
- const prev = i !== -1 ? Number((out[i].match(/完成任务 (\d+) 条/) || [])[1] || 0) : 0;
969
- const line = `- [[${slug}]] 完成任务 ${prev + count} 条`;
970
- if (i !== -1) out[i] = line;
971
- else out.push(line);
972
- }
973
- out.push('');
974
- // 标记行按日期倒序(与 Done 日期组同风格:新的在上)。
975
- // 否则顺序 = 归档先后,多次归档后读起来是乱的(如 08/09/07)。
976
- const dateOf = (l) => (String(l).match(/(\d{4}-\d{2}-\d{2})/) || [''])[0];
977
- const body = out.slice(1).filter((l) => l.trim());
978
- body.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
979
- return [out[0], ...body, ''];
980
- }
981
-
982
- /** 本地日期减 n 天(YYYY-MM-DD)。纯字符串入出,避免时区漂移。 */
983
- function daysAgo(n, from) {
984
- const [y, m, d] = String(from).split('-').map(Number);
985
- const t = new Date(y, m - 1, d);
986
- t.setDate(t.getDate() - n);
987
- const pad = (x) => String(x).padStart(2, '0');
988
- return `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`;
989
- }
990
-
991
- /** 一行是否为未完成任务行(- [ ])。 */
992
- function isUndoneLine(l) {
993
- return /^\s*- \[ \]/.test(l);
994
- }
995
-
996
- /**
997
- * 把 Done 区里"可归档"的日期组摘出来,并追回归档标记行。**纯函数**(不碰磁盘)。
998
- * 规则(用户定):
999
- * ① 只保留近 keepDays 天(含今天);更早的才归档。
1000
- * ② 某一天只要还有未完成(- [ ])任务,**整天都不归档**(不拆半天)。
1001
- * ③ 每**天**一个文件,slug 为 `log-<日期>`(由 slugFor 给)—— 即**归进那天的会话快照**
1002
- * (同一天的东西放一处,别为归档另建文件)。本函数只负责从 todo 文本里移除
1003
- * + 在 Done 区尾部的 `### 归档` 区**每天记一行** `- [[log-<日期>]] 完成任务 N 条`。
1004
- * 无日期组(### (未标日期))无法判天数,**保守不归档**。
1005
- * @returns {{text:string, archived:{date:string,lines:string[],slug:string,count:number}[], skipped:{date:string,reason:string}[], count:number}}
1006
- */
1007
- export function archiveDoneInText(text, { keepDays = 3, from = today(), maxLines = 0, slugFor = (d) => `log-${d}` } = {}) {
1008
- const lines = String(text || '').split('\n');
1009
- const di = lines.findIndex((l) => l.startsWith('## Done'));
1010
- if (di === -1) return { text, archived: [], skipped: [], count: 0 };
1011
- const head = lines.slice(0, di + 1);
1012
- const { groupLines, archiveLines } = splitDoneBody(lines.slice(di + 1));
1013
- const units = parseDoneUnits(groupLines);
1014
- if (!units.length) return { text, archived: [], skipped: [], count: 0 };
1015
-
1016
- const keep = Math.max(1, Number(keepDays) || 3);
1017
- // 保留 cutoff..from 这 keep 天;比 cutoff 更早的才归档
1018
- let cutoff = daysAgo(keep - 1, from);
1019
- // 超限放宽(2026-10-05 加):ROOT-OVER-SIZE 限看板 ≤60 行,而 keepDays≥1 意味着
1020
- // 密集工作日(一天几十条 Done)必然超限 —— 两条规则打架,且 archive 拒绝动,
1021
- // 于是每天提交都被 lint 拦一次。修法:**看板已超行数上限时,把 cutoff 推到当天**,
1022
- // 让「整天已完成」的组可以立刻归档(仍有 ② 未完成则整天不归档 的保护)。
1023
- // 注意裁到「今天之后」:判断是 `date >= cutoff → 保留`,若 cutoff = 今天,
1024
- // 今天的组仍被保留(首版就栽在这,dry-run 一看当天没进归档列表)。
1025
- if (maxLines && lines.length > maxLines) cutoff = daysAgo(-1, from);
1026
-
1027
- const byDate = new Map();
1028
- const undated = [];
1029
- for (const u of units) {
1030
- if (!u.date) { undated.push(u); continue; }
1031
- if (!byDate.has(u.date)) byDate.set(u.date, []);
1032
- byDate.get(u.date).push(u);
1033
- }
1034
-
1035
- const archived = [];
1036
- const skipped = [];
1037
- const keepUnits = [...undated];
1038
- if (undated.length) skipped.push({ date: '(未标日期)', reason: '无完成日期,无法判断天数(保守不归档)' });
1039
-
1040
- for (const date of [...byDate.keys()].sort()) {
1041
- const us = byDate.get(date);
1042
- if (date >= cutoff) { keepUnits.push(...us); continue; } // ① 近 N 天:保留
1043
- const undone = us.filter((u) => isUndoneLine(u.lines[0]));
1044
- if (undone.length) { // ② 有未完成:整天不归档
1045
- skipped.push({ date, reason: `有 ${undone.length} 条未完成,整天不归档` });
1046
- keepUnits.push(...us);
1047
- continue;
1048
- }
1049
- const gLines = us.flatMap((u) => u.lines); // ③ 可归档
1050
- archived.push({
1051
- date,
1052
- lines: gLines,
1053
- slug: slugFor(date),
1054
- count: gLines.filter((l) => /^\s*- \[x\]/.test(l)).length,
1055
- });
1056
- }
1057
-
1058
- if (!archived.length) return { text, archived: [], skipped, count: 0 };
1059
-
1060
- const count = archived.reduce((n, g) => n + g.count, 0);
1061
- const entries = archived.map((g) => ({ slug: g.slug, count: g.count }));
1062
- const rebuilt = [...head, ...renderDoneGroups(keepUnits), ...mergeArchiveLines(archiveLines, entries)];
1063
- return { text: rebuilt.join('\n').replace(/\n+$/, '\n'), archived, skipped, count };
1064
- }
1065
-
1066
- /** 归档页正文(按日期分组,原文保留)。供同日追写复用。 */
1067
- export function renderArchiveBody(groups) {
1068
- const out = [];
1069
- for (const g of groups) out.push(`### ${g.date}`, '', ...g.lines, '');
1070
- return out.join('\n').replace(/\n+$/, '\n');
1071
- }
1072
-
1073
- /** 归档区标题。归档内容全部落在这个二级标题下,**与 AI 手写的会话快照共存**:
1074
- * 同一天的记录(快照 + 任务明细)放一个文件里,而不是分两个文件。
1075
- * 写入侧只动这一段:标题之前的内容原样保留(已存在的人工内容一律不覆盖)。 */
1076
- export const ARCHIVE_SECTION = '## 📦 任务归档';
1077
-
1078
- /** 把「任务归档」段合并进已有正文。**纯函数**(不碰磁盘)。
1079
- *
1080
- * 为何不再「每天一个文件」(2026-09-15 用户定):
1081
- * 归档页与 `log-<日期>.md` 会话快照是**同一天的记录**,分两处查着要开两个文件。
1082
- * 改为:归档写进当天快照的 `ARCHIVE_SECTION` 段。
1083
- *
1084
- * 两个边界(都有测试钉住):
1085
- * ① 文件不存在 → 建一个**只有归档段**的页(那天可能没写快照,仍只落这一个文件)。
1086
- * ② 文件已存在(含 AI 手写的快照)→ **只替换归档段**,段外内容逐字保留。
1087
- * 同日重复归档(罕见)则把新日期组接在段内已有内容之后,不重复建段。
1088
- *
1089
- * @param body 现有全文(null = 文件不存在)
1090
- * @param group { date, lines, count }
1091
- */
1092
- export function upsertArchiveSection(body, group) {
1093
- const chunk = renderArchiveBody([group]);
1094
- if (body == null) {
1095
- // ① 新文件:只有归档段(无手写快照也合法 —— 那天本来就只有任务记录)
1096
- const fm = [
1097
- '---',
1098
- 'tags: [session-log, todo-archive, 历史]',
1099
- `updated: ${group.date}`,
1100
- 'status: reviewed',
1101
- '---',
1102
- '',
1103
- `# ${group.date} 记录`,
1104
- '',
1105
- '> 本页 = 该日的会话快照(若有)+ 从 `todo.md` Done 区迁出的任务明细。',
1106
- '',
1107
- ARCHIVE_SECTION,
1108
- '',
1109
- chunk.trimEnd(),
1110
- '',
1111
- ];
1112
- return fm.join('\n');
1113
- }
1114
- // ② 已存在:只动归档段,段外原样
1115
- const lines = body.split('\n');
1116
- const i = lines.findIndex((l) => l.trim() === ARCHIVE_SECTION);
1117
- if (i === -1) {
1118
- // 有文件但还没归档段 → 追加到末尾(不搅动已有内容)
1119
- return `${body.replace(/\s*$/, '')}\n\n${ARCHIVE_SECTION}\n\n${chunk.trimEnd()}\n`;
1120
- }
1121
- // 找到本段结束(下一个同级或更高级标题)
1122
- let j = i + 1;
1123
- while (j < lines.length && !/^#{1,2} \S/.test(lines[j].trim())) j++;
1124
- const head = lines.slice(0, i + 1);
1125
- const tailPart = lines.slice(j);
1126
- const existingInner = lines.slice(i + 1, j).join('\n').trim();
1127
- const merged = existingInner ? `${existingInner}\n\n${chunk.trimEnd()}` : chunk.trimEnd();
1128
- return [...head, '', merged, '', ...tailPart].join('\n').replace(/\n{3,}/g, '\n\n');
1129
- }
1130
-
1131
- /** 幂等:把 todo 全文里平铺的旧 Done 区按日期分组(新日期在前,未标日期归尾)。
1132
- * 已是分组态(### date)则原样返回——惰性迁移用,避免每次读都动文件。 */
1133
- export function groupDoneSection(text) {
1134
- const lines = String(text || '').split('\n');
1135
- const di = lines.findIndex((l) => l.startsWith('## Done'));
1136
- if (di === -1) return text;
1137
- const body = lines.slice(di + 1);
1138
- const first = body.find((l) => l.trim());
1139
- if (first && /^### /.test(first.trim())) return text; // 已分组,幂等不动
1140
- const { groupLines, archiveLines } = splitDoneBody(body); // 归档标记区原样保留
1141
- const units = parseDoneUnits(groupLines);
1142
- if (!units.length) return text;
1143
- return [...lines.slice(0, di + 1), ...renderDoneGroups(units), ...archiveLines]
1144
- .join('\n').replace(/\n+$/, '\n');
1145
- }
1146
-
1147
- /** 把 moved 单元(已完成任务行+附属行)放入 Done 区并整体按日期分组重建。
1148
- * 供 markDone 用,锁内一次完成「归位 + 分组」。无 Done 区则文件尾补建。 */
1149
- export function insertDoneGrouped(text, movedLines) {
1150
- const date = doneDateOf(movedLines[0]) || today();
1151
- const lines = String(text || '').split('\n');
1152
- const di = lines.findIndex((l) => l.startsWith('## Done'));
1153
- const newUnit = { date, lines: movedLines };
1154
- if (di === -1) {
1155
- const header = `## ${SEC.done}`;
1156
- return [...lines, '', header, '', ...renderDoneGroups([newUnit])].join('\n').replace(/\n+$/, '\n');
1157
- }
1158
- const head = lines.slice(0, di + 1);
1159
- const body = lines.slice(di + 1);
1160
- const { groupLines, archiveLines } = splitDoneBody(body); // 归档标记区原样保留
1161
- // 新完成单元置前:同日期组内新在最上(renderDoneGroups 按 encounter 顺序保持,日期再倒序排)
1162
- const units = [newUnit, ...parseDoneUnits(groupLines)];
1163
- return [...head, ...renderDoneGroups(units), ...archiveLines].join('\n').replace(/\n+$/, '\n');
1164
- }
1165
-
1166
- // 占位/空任务行(老模板的 "(无)"/"无"/纯 - [ ])不迁移
1167
- function isPlaceholder(line) {
1168
- const t = line.trim();
1169
- if (!/^- \[[ x]\]/.test(t)) return false; // 只判任务行
1170
- const body = t.replace(/^- \[[ x]\]\s*/, '').replace(/\(认领[^)]*\)/g, '').trim();
1171
- return !body || /^(无|(无)|None|null)$/.test(body);
1172
- }
1173
- /** 在指定分区段落后插入一行任务;找不到分区则在文件末尾追加回退。写前惰性迁移老格式。 */
1174
- export async function addTask(brainRoot, { section, text }) {
1175
- const p = await ensureTodo(brainRoot);
1176
- await editFile(p, (orig) => ({ text: insertTask(orig, section, text) }));
1177
- return { file: p, text };
1178
- }
1179
-
1180
- /** 纯函数:把任务行插到分区标题后。供 editFile mutator 复用(upsert 也用它)。 */
1181
- function insertTask(orig, section, text) {
1182
- const base = normalizeTodo(orig);
1183
- const lines = base.split('\n');
1184
- const header = `## ${section}`;
1185
- let idx = lines.findIndex((l) => l.startsWith(header));
1186
- if (idx === -1) {
1187
- lines.push('', header, `- [ ] ${text}`);
1188
- } else {
1189
- // 在该分区标题后、下一个分区标题前插入
1190
- let insertAt = idx + 1;
1191
- while (insertAt < lines.length && !lines[insertAt].startsWith('## ')) insertAt++;
1192
- lines.splice(insertAt, 0, `- [ ] ${text}`);
1193
- }
1194
- return lines.join('\n');
1195
- }
1196
-
1197
- /** 幂等登记:同 id 已有未完成任务行则原位更新(新 note/新认领日期),否则插入。 */
1198
- export async function upsertTask(brainRoot, { section, text }) {
1199
- const p = await ensureTodo(brainRoot);
1200
- const id = extractId(text);
1201
- const note = extractNote(text);
1202
- const out = await editFile(p, (orig) => {
1203
- const base = normalizeTodo(orig);
1204
- const lines = base.split('\n');
1205
- // 在所有分区中找该 id 对应的未完成任务行(Done 的已完成行不重复动);全等比对
1206
- const idx = findTaskLine(lines, id);
1207
- // 状态标记:新建用 text 里带的(默认 进行中),更新保留原有的
1208
- const wanted = String(text).match(/^\[([^\]]+)\]/)?.[1];
1209
- const state = idx !== -1
1210
- ? (stateOfTaskLine(lines[idx]) || wanted || '进行中')
1211
- : (TASK_STATES.includes(wanted) ? wanted : '进行中');
1212
- if (idx !== -1) {
1213
- // 原位更新:保留断点附属行 + 原@作者 + 原状态,只换任务行本体
1214
- const raw = stripStateMark(lines[idx]).replace(/^- \[ \] /, '');
1215
- const author = extractAuthor(raw);
1216
- const legacy = isLegacyAuthorTag(raw); // 旧行保持旧形态,不静默改写
1217
- const oldNote = extractNote(raw);
1218
- const merged = note && oldNote && oldNote.startsWith(note) ? `${note}${oldNote.slice(note.length)}` : note || oldNote;
1219
- const at = author ? ` ${legacy ? '@' + author : '[['
1220
- + author + ']]'}` : '';
1221
- const newLine = `- [ ] [${state}] ${id}${at}${merged ? ' — ' + merged : ''} (认领 ${today()})`;
1222
- lines[idx] = newLine;
1223
- return { text: lines.join('\n'), updated: true };
1224
- }
1225
- return { text: insertTask(base, section, text), updated: false };
1226
- });
1227
- return out.updated
1228
- ? { file: p, text, updated: true }
1229
- : { file: p, text, updated: false };
1230
- }
1231
-
1232
- /** 从任务文本提取幂等键(行首 id,如 TASK-1 / T-2 / fix-hook)。 */
1233
- export function extractId(text) {
1234
- // 先剥行首状态标记(`[进行中] <id>`),否则 id 会取成 "[进行中]"(2026-09-13 实测)。
1235
- const t = String(text).replace(/^\[[^\]]+\]\s+/, '');
1236
- const m = t.match(/^([A-Za-z][\w-]*)\b/);
1237
- return m ? m[1] : null;
1238
- }
1239
-
1240
- /** 提取 " — " 后的 note 部分(剥掉紧跟在 id 后的 `@author` 标记及尾部 `(认领 ...)`)。
1241
- * 格式: `<id> @author — <note> (认领 date)`。@author 可选(旧行/未设置姓名时无)。 */
1242
- export function extractNote(text) {
1243
- const i = String(text).indexOf(' — ');
1244
- if (i === -1) return '';
1245
- return String(text).slice(i + 3).replace(/\s*\(认领[^)]*\)\s*$/, '').trim();
1246
- }
1247
-
1248
- /** 提取 id 后的作者标记(无则空)。供 upsertTask 原位重建行时保留作者。
1249
- * 两种形态都要认:
1250
- * - 新: `- [ ] ID [[fanchao]] — note` (wikilink 到人页)
1251
- * - 旧: `- [ ] ID @fanchao — note` (裸 at 标记, 历史行)
1252
- * 旧行只在原位更新时按原形态保留, 不做批量回填(原文/现场已不在, 回填等于编造)。
1253
- * 返回的是**纯姓名**(不含 [[ ]] 与 @),由调用方决定用什么形态重写。 */
1254
- export function extractAuthor(text) {
1255
- const s = String(text);
1256
- const link = s.match(/^\S+\s+\[\[([^\]]+)\]\]/);
1257
- if (link) return link[1].trim();
1258
- const at = s.match(/^\S+\s+@([\w\u4e00-\u9fff.-]+)/);
1259
- return at ? at[1] : '';
1260
- }
1261
-
1262
- /** 判断行里的作者标记是旧裸 `@name` 形态(保留旧形态,不擅自升级)。 */
1263
- export function isLegacyAuthorTag(text) {
1264
- return !/^\S+\s+\[\[/.test(String(text)) && /^\S+\s+@/.test(String(text));
1265
- }
1266
-
1267
- /**
1268
- * 从任务行取出行首 id token(`- [ ] <id> …` 里的 <id>)。
1269
- * 坑: 不能用 l.includes(id) 定位任务 —— 那是子串匹配,`T1` 会命中 `T11`(同理
1270
- * TASK-1/TASK-10、fix-hook/fix-hook-2),导致 upsert 误判"已存在"而原地改写别的任务、
1271
- * 新任务静默消失。必须取出 id token 做全等比对。
1272
- * id 里可能混入零宽字符(历史瑕疵),比对前先剥掉。
1273
- */
1274
- export function idOfTaskLine(line) {
1275
- if (!line || !line.startsWith('- [ ]')) return null;
1276
- // 两区制下行首可带状态标记(`- [ ] [进行中] <id> …`)—— 必须先剥掉再取 id,
1277
- // 否则 id 会被读成 "[进行中]",findTaskLine 全等比对永远落空(2026-09-13 实测)。
1278
- const m = line.match(/^- \[ \] (?:\[[^\]]+\] )?(\S+)/);
1279
- return m ? m[1].replace(/\u200b/g, '') : null;
1280
- }
1281
-
1282
- /** 定位 id 对应的未完成任务行下标;附属断点行(↳ 开头)不算任务行。
1283
- * 全等比对,不用 includes(否则 T1 会误命中 T11)。 */
1284
- export function findTaskLine(lines, id) {
1285
- if (!id) return -1;
1286
- const want = String(id).replace(/\u200b/g, '');
1287
- return lines.findIndex((l) => idOfTaskLine(l) === want);
1288
- }
1289
-
1290
- /** 实时断点: 在 id 任务行下原位补/换 `↳ 断点:` 附属行(不挪任务位置)。 */
1291
- export async function setBreakpoint(brainRoot, { id, text }) {
1292
- const p = await ensureTodo(brainRoot);
1293
- const bp = ` ↳ 断点: ${text}`;
1294
- const res = await editFile(p, (orig) => {
1295
- const lines = orig.split('\n');
1296
- const idx = findTaskLine(lines, id);
1297
- if (idx === -1) return SKIP;
1298
- if (lines[idx + 1] && lines[idx + 1].trimStart().startsWith('↳ 断点:')) {
1299
- lines[idx + 1] = bp; // 幂等: 更新原附属行
1300
- } else {
1301
- lines.splice(idx + 1, 0, bp);
1302
- }
1303
- return { text: lines.join('\n'), ok: true };
1304
- });
1305
- return res === SKIP
1306
- ? { ok: false, msg: `[NO_MATCH] 未找到含 "${id}" 的未完成任务行` }
1307
- : { ok: true, msg: `✓ 断点已落 → ${id}\n ${bp.trim()}` };
1308
- }
1309
-
1310
- // ---------- 看板输出 ----------
1311
- export async function boardText(brainRoot, textOverride, { full = false } = {}) {
1312
- const text = textOverride !== undefined ? textOverride : await readTodo(brainRoot);
1313
- const head = `📂 abs → 项目: ${brainRoot}`;
1314
- if (!text.trim()) return `${head}\n\n(todo.md 为空,先 abs todo add 登记任务)`;
1315
- // Done 区折叠:它无上限增长,全量打印会把上下文塞满(见 collapseDone 注释)
1316
- const body = full ? text.trim() : collapseDone(text).text;
1317
- const hint = full ? '' : '\n\n(Done 只给计数;看明细: abs todo --full)';
1318
- return `${head}\n\n${body}${hint}`;
1319
- }
1
+ // src/todo.js — 对外入口(re-export)。真正的实现在 src/todo/*.js。
2
+ // 保留这一层是为了对外接口稳定:store.js / lint.js / note.js / wrapup.js / 测试的
3
+ // import 路径与名字都不用改。
4
+
5
+ export { TODO_MAX_LINES, LOG_MAX_LINES, INDEX_MAX_LINES, today, localStamp, SEC, extractId, extractNote, extractAuthor, isLegacyAuthorTag, claimOf, sessionOf } from './todo/common.js';
6
+ export { LEGACY_SECTION_RENAMES, TODO_SECTIONS, TODO_SUBSECTIONS, isAllowedTodoSub, INDEX_SECTIONS, INDEX_LINK_SECTIONS, LOG_KINDS, H1_TO_FILE, ENTRY_SHAPES, TASK_STATES, RETIRED_SECTIONS, BREAKPOINT_MAX, TASK_LINE_MAX, DONE_KINDS, ARCHIVE_SECTION } from './todo/spec.js';
7
+ export { todoTemplate, rebuildStructure, dropRetiredSections, enforceBrainFormat, normalizeTodo, ensureStateMark, stateOfTaskLine, stripStateMark } from './todo/structure.js';
8
+ export { checkFileShape, violationSignatures, assertNoStrayBlank, assertTodoContent } from './todo/validate.js';
9
+ export { doneDateOf, doneKindOf, withDoneKind, collapseDone, archiveDoneInText, renderArchiveBody, upsertArchiveSection, groupDoneSection, insertDoneGrouped } from './todo/archive.js';
10
+ export { readTodo, ensureTodo, setStateMark, addTask, upsertTask, idOfTaskLine, findTaskLine, setBreakpoint, setMemo, readMemo, boardText } from './todo/tasks.js';