@fanchao8609/agent_brain_sync 1.3.2 → 1.5.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.
package/README.md CHANGED
@@ -72,6 +72,7 @@ abs log "完成 X" # 记一行流水;abs log 无参 =
72
72
  abs query <词> # 检索图谱(多词 OR)
73
73
  abs status # 当前项目 + 图谱概要
74
74
  abs lint # 体检:死链/孤岛/超尺寸/堆积
75
+ abs todo archive # 归档 Done 区旧日期组(默认留近 3 天)
75
76
  ```
76
77
 
77
78
  > `abs todo start` 与 `abs todo add` 等价(都登记任务)。
package/bin/abs.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // bin/abs.js — abs CLI 入口。
3
3
  // abs <cmd> [args]
4
4
  // 命令: init / board / status / load / task / install / uninstall / help
5
- import { cmdInit, cmdBoard, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdShow, cmdRepair, cmdWrapup, cmdTeardownCheck } from '../src/store.js';
5
+ import { cmdInit, cmdBoard, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdShow, cmdRepair, cmdWrapup, cmdTeardownCheck, cmdTodoArchive } from '../src/store.js';
6
6
  import { runInstall, runUninstall, installSummary } from '../src/install.js';
7
7
  import { readFileSync } from 'node:fs';
8
8
  import { join, dirname, resolve } from 'node:path';
@@ -70,6 +70,8 @@ function parseArgv(args) {
70
70
  else if (a === '--section') { o.section = args[++i]; }
71
71
  else if (a === '--note') { o.note = args[++i]; }
72
72
  else if (a === '--payload') { o.payload = args[++i]; }
73
+ else if (a === '--keep-days') { o.keepDays = args[++i]; }
74
+ else if (a === '--dry-run') { o.dryRun = true; }
73
75
  else if (a === '--yes') { o.yes = true; }
74
76
  else if (a === '--repair') { o.repair = true; }
75
77
  else if (a === '--no-mcp') { o.mcp = false; }
@@ -99,6 +101,9 @@ const usage = `abs — agent-brain-sync 记忆工具
99
101
 
100
102
  维护:
101
103
  abs query <词1> [词2 …] 检索 .brain/ 知识页 (多词 OR)
104
+ abs todo archive [--keep-days N] [--dry-run]
105
+ 归档 Done 区旧日期组 → sessions/<日期>-todo归档.md
106
+ (默认保留近 3 天; 任一天有未完成则整天不归档)
102
107
  abs lint 图谱体检 (死链/孤岛/超尺寸/堆积)
103
108
  abs init [--repair] 建 .brain/ 图谱; 结构不完整时报明细, --repair 只补缺不覆盖
104
109
  abs install [--agent <宿主>] 安装 MCP+hook+skill (宿主: claude-code/codex/opencode/pi)
@@ -181,10 +186,17 @@ async function main() {
181
186
  break;
182
187
  }
183
188
  const action = TODO_ACTIONS[sub];
189
+ // 归档:Done 区迁出旧日期组(非任务子命令,单独处理)
190
+ if (sub === 'archive') {
191
+ rejectExtra(rest2, 'abs todo archive [--keep-days N] [--dry-run]');
192
+ const a = { dir: opts.dir, keepDays: opts.keepDays, dryRun: opts.dryRun };
193
+ console.log(await cmdTodoArchive(a));
194
+ break;
195
+ }
184
196
  if (!action) {
185
197
  throw new Error(
186
198
  `✗ 未知子命令 "${sub}"\n` +
187
- ` 可用: add / start / note / blocked / done\n` +
199
+ ` 可用: add / start / note / blocked / done / archive\n` +
188
200
  ` 看板: abs todo(不带参数)\n` +
189
201
  ` 登记任务: abs todo add <id> --note "做什么"`,
190
202
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.3.2",
3
+ "version": "1.5.0",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/skill/SKILL.md CHANGED
@@ -29,12 +29,33 @@ Obsidian 可直接打开的 Markdown 图谱(`.brain/`)做统一落点。
29
29
  | 总结经验 / 结束了 / "把这次记下来" | 收尾 Teardown(沉淀) |
30
30
  | 查询坑 / "我上次怎么解决 X" | `abs query` 检索 |
31
31
  | 体检图谱 / `abs lint` | `abs lint` 跑体检 |
32
- | 开新任务 / 继续开发 / "帮我做 X" | 开场 Init Sync(续接),之后正常做 |
32
+ | 开新任务 / 继续开发 / "帮我做 X" | 开场 Init Sync(续接);**新需求先走下面的受理协议** |
33
+ | 报 bug / 要求加功能 | **受理协议**:先方案 → 再登记 → 问开工(下一节) |
33
34
  | 意图不明 / 默认 | **续 todo**:读未完成项开工续做 |
34
35
 
35
36
  **主心骨**:意图不明且项目有 `.brain/` 时,默认**续 todo**——任何情况下先接上未完成工作,
36
37
  不是停在闲聊。
37
38
 
39
+ ## 新需求受理协议(bug / 新功能:先方案 → 再登记 → 问开工)
40
+
41
+ 用户报 bug 或要求加功能时,**先别动代码**。三步:
42
+
43
+ 1. **总结方案**(一屏内,给用户过目)
44
+ - 需求的准确复述;不确定就写明假设,别猜着做
45
+ - bug → 根因;功能 → 做法。**有证据给证据,没查到就直说"未定位"**
46
+ - 要改哪些文件、怎么验证(跑什么、看什么)
47
+ 2. **登记 todo**:`abs todo add <id> --note "<一句话需求+关键约束>"`
48
+ 方案里的关键结论(根因/取舍)再 `abs todo note <id> --note ...` 落到断点。
49
+ 3. **问是否开工**:明确问一句,**等确认再改代码**。
50
+
51
+ **例外(可直接开工,但回复里须说明援引哪一条)**
52
+ - 用户已说"开工 / 直接做 / 修它" —— 那就是授权
53
+ - 一行级修字、纯查询、纯收尾沉淀 —— 无方案可言
54
+ - 同一需求已登记且用户确认过 —— 接着做即可
55
+
56
+ > 为什么:方案先过目能省掉整轮返工;登记让跨会话可续;"问开工"把决定权留在用户手里。
57
+ > **这不是拖延** —— 总结方案本身就是工作,做完再问。
58
+
38
59
  ## 图谱定位(一个项目一个 `.brain/`,abs 自动定位不用手工指定路径)
39
60
 
40
61
  `.brain/` 放项目根,一个项目只建一份。所有 `abs` 命令(`abs load/todo/note/task/query/lint...`)
@@ -115,6 +136,12 @@ monorepo 若多个子包各自独立交付,可各建一份 `.brain/`;横向
115
136
  | 碰壁/阻塞 | `abs todo blocked <id> --note "卡点原因"`(移 Blocked) |
116
137
  | 被打断/改向/干到一半停 | `abs todo note <id> --note "改到哪个文件/到哪步"`(补 ↳ 断点 行) |
117
138
 
139
+ **Done 区会自动收口**:会话结束时 hook 调 `abs wrapup`,顺手把「超过 3 天 且 整天都已完成」
140
+ 的日期组迁到 `.brain/sessions/<日期>-todo归档.md`,并在 Done 区尾部留一行
141
+ `### 归档` → `- [[<日期>-todo归档]] 完成任务 N 条`。
142
+ **任一天只要还有未完成任务(`- [ ]`),整天都不归档** —— 不会把半成品扫走。
143
+ 手动跑:`abs todo archive [--keep-days N] [--dry-run]`。
144
+
118
145
  > `abs todo start` 与 `abs todo add` 等价(老写法仍可用)。
119
146
  > 旧版 `abs task ...` / `abs board` 已改名,会报错并提示新写法。
120
147
  > 只读命令(`todo`/`status`/`lint`/`load`/`index`)遇多余参数会报错 —— 不再静默吞掉。
package/src/store.js CHANGED
@@ -4,7 +4,7 @@ import { promises as fs } from 'node:fs';
4
4
  import { join, resolve, dirname } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { requireBrain, findBrainRoot, brainPath } from './index.js';
7
- import { addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, moveBlocked, insertDoneGrouped, idOfTaskLine } from './todo.js';
7
+ import { addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, moveBlocked, insertDoneGrouped, idOfTaskLine, archiveDoneInText, renderArchivePage, renderArchiveBody } from './todo.js';
8
8
  import { editFile, SKIP } from './lock.js';
9
9
  import { appendWrapup, strandedFor, wrapupLogPath } from './wrapup.js';
10
10
 
@@ -190,7 +190,82 @@ export async function cmdLoad({ dir }) {
190
190
  // ---------- wrapup: 滞留快照(B)/ load 内展示由 cmdLoad 完成(A) ----------
191
191
  export async function cmdWrapup({ dir }) {
192
192
  const root = await requireBrain(dir || process.cwd());
193
- return appendWrapup(root);
193
+ const snap = await appendWrapup(root);
194
+ // 顺手做 Done 归档。**不引入 cron/定时器**:Stop hook 已经会在会话结束时调
195
+ // `abs wrapup`,直接复用这个触发点(零新基础设施、零新失败面)。
196
+ // 只对「超过保留天数 且 整天都已完成」的日期组动手,平时无动作。
197
+ // 本函数 stdout 会被 hook 追写到 ~/.abs/log/hooks.log → 归档动作自动留痕。
198
+ let arch = '';
199
+ try {
200
+ const a = await cmdTodoArchive({ dir: root, keepDays: 3 });
201
+ if (a.startsWith('✓')) arch = '\n' + a;
202
+ } catch { /* 归档失败不影响快照本身 */ }
203
+ return snap + arch;
204
+ }
205
+
206
+ /**
207
+ * `abs todo archive` —— 把 Done 区里「超过保留天数 且 整天都已完成」的日期组迁到归档页。
208
+ * 规则见 `archiveDoneInText`(① 只留近 N 天 ② 任一天有未完成则整天不归档 ③ 归档成一个文件
209
+ * 并在 Done 区尾部 `### 归档` 记 `- [[slug]] 完成任务 N 条`)。
210
+ * 幂等:同日重跑 = 追写到同一归档页 + 就地更新标记行。
211
+ */
212
+ export async function cmdTodoArchive({ dir, keepDays = 3, dryRun = false } = {}) {
213
+ let root;
214
+ try { root = await requireBrain(dir || process.cwd()); } catch { return '未找到 .brain/ 图谱。先在项目根运行: abs init'; }
215
+ const days = Math.max(1, Number(keepDays) || 3);
216
+ const todoP = brainPath(root, 'todo.md');
217
+ const raw = await fs.readFile(todoP, 'utf8').catch(() => '');
218
+ if (!raw.trim()) return '(todo.md 为空)';
219
+
220
+ const plan = archiveDoneInText(raw, { keepDays: days, from: today() });
221
+ const why = plan.skipped.length
222
+ ? `\n 跳过: ${plan.skipped.map((s) => `${s.date}(${s.reason})`).join(';')}`
223
+ : '';
224
+ if (!plan.archived.length) return `(无可归档:保留近 ${days} 天)${why}`;
225
+ const brief = plan.archived.map((g) => `${g.date}(${g.count})`).join(' ');
226
+ if (dryRun) {
227
+ return `[dry-run] 将归档 ${plan.archived.length} 天 / ${plan.count} 条(每天一个文件)\n ${brief}${why}`;
228
+ }
229
+
230
+ // 1) 归档页:**每天一个文件**,文件名用被归档那天的日期(便于按天回溯)。
231
+ // 同一天再次归档(罕见:该日组已被移走,除非有人重新补当天任务)则追写正文。
232
+ const sessDir = brainPath(root, 'sessions');
233
+ for (const g of plan.archived) {
234
+ const pageP = join(sessDir, `${g.slug}.md`);
235
+ let page = null;
236
+ try { page = await fs.readFile(pageP, 'utf8'); } catch { /* 首次 */ }
237
+ const nextPage = page == null
238
+ ? renderArchivePage({ group: g })
239
+ : page.replace(/\s*$/, '') + '\n\n' + renderArchiveBody([g]) + '\n';
240
+ const tmp = join(sessDir, `.${g.slug}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`);
241
+ await fs.writeFile(tmp, nextPage, 'utf8');
242
+ await fs.rename(tmp, pageP);
243
+ }
244
+
245
+ // 2) todo.md:锁内重算(拿最新内容,避免与并发 done 互相覆盖)
246
+ await editFile(todoP, (cur) => {
247
+ const p2 = archiveDoneInText(cur ?? '', { keepDays: days, from: today() });
248
+ return p2.archived.length ? { text: p2.text } : SKIP;
249
+ });
250
+
251
+ // 3) index 登记(每个日期页一行,幂等)
252
+ const iP = brainPath(root, 'index.md');
253
+ await editFile(iP, (index) => {
254
+ if (!index) return SKIP;
255
+ const missing = plan.archived.filter((g) => !index.includes(`[[${g.slug}]]`));
256
+ if (!missing.length) return SKIP;
257
+ const sIdx = index.indexOf('## Sources');
258
+ if (sIdx === -1) return SKIP;
259
+ const after = index.indexOf('\n## ', sIdx + 1);
260
+ const add = missing.map((g) => `- [[${g.slug}]] — Todo 归档:${g.date},共 ${g.count} 条已完成任务`).join('\n');
261
+ const next = after === -1
262
+ ? `${index.replace(/\s*$/, '')}\n${add}\n`
263
+ : index.slice(0, after) + `\n${add}` + index.slice(after);
264
+ return { text: next };
265
+ });
266
+
267
+ const files = plan.archived.map((g) => `sessions/${g.slug}.md`).join('\n ');
268
+ return `✓ 已归档 ${plan.archived.length} 天 / ${plan.count} 条\n ${brief}\n → ${files}${why}`;
194
269
  }
195
270
 
196
271
  /**
@@ -487,6 +562,12 @@ export async function cmdLint({ dir }) {
487
562
  const vault = brainPath(root);
488
563
  const pages = await listPages(vault);
489
564
  const names = new Set(pages.map((p) => p.slug));
565
+ // `.brain` 顶层文件(index / log / todo)也是真实页:从图谱看 [[todo]] 就是 todo.md。
566
+ // 坑: 以前只把子目录当页 → [[todo]] 被当成死链(误报),反而逼用户去删掉正确引用。
567
+ // 只用于「链接目标是否存在」判定;不参与 ORPHAN / INDEX-MISSING(它们只针对子目录页)。
568
+ for (const f of await fs.readdir(vault).catch(() => [])) {
569
+ if (f.endsWith('.md')) names.add(f.replace(/\.md$/, ''));
570
+ }
490
571
  const linkedNames = new Set(pages.flatMap((p) => p.links));
491
572
  // index.md 里列的 [[x]] —— 用于反向查死引用(列了但页不存在)
492
573
  let indexLinks = [];
@@ -502,8 +583,10 @@ export async function cmdLint({ dir }) {
502
583
  if (/slug|Name|name|Date|页面名$/.test(ln)) issues.push(`TEMPLATE-LINK: ${pg.rel} -> [[${ln}]]`);
503
584
  if (!names.has(ln)) issues.push(`DEAD-LINK: ${pg.rel} -> [[${ln}]]`);
504
585
  }
505
- // ORPHAN: sources/ 暂存页豁免(暂存线索天然孤立,提炼成 concept 前不强制挂链)
506
- if (pg.dir !== 'sources' && !pg.links.length && !linkedNames.has(pg.slug)) {
586
+ // ORPHAN: sources/ 暂存页与 todo 归档页豁免。前者是暂存线索(提炼成 concept 前天然孤立),
587
+ // 后者是历史数据倾倒(已登记在 index.md,就是图谱入口,无需再制造双链)。
588
+ const isTerminal = pg.dir === 'sources' || /todo归档$/.test(pg.slug);
589
+ if (!isTerminal && !pg.links.length && !linkedNames.has(pg.slug)) {
507
590
  issues.push(`ORPHAN-PAGE: ${pg.rel} (no links out, no links in)`);
508
591
  }
509
592
  if (/知识冲突/.test(pg.body) && /status: draft/.test(pg.frontmatter)) {
@@ -529,6 +612,22 @@ export async function cmdLint({ dir }) {
529
612
  const nsrc = pages.filter((p) => p.dir === 'sources').length;
530
613
  if (nsrc > 10) issues.push(`SOURCES-PILED-UP: sources/ has ${nsrc} files > 10; 提炼归档旧 source`);
531
614
 
615
+ // Done 区堆积:它无上限增长,且 `abs todo`/`abs load` 每次全量打印 → 越积越难用。
616
+ // (与 hooks.log/wrapup.log 同类问题;那两处有轮转,这里靠 `abs todo archive`。)
617
+ // 坑: 曾经写成 brainPath(vault, 'todo.md'),而 vault 已经是 .brain 目录
618
+ // → 拼出 .brain/.brain/todo.md(ENOENT),又被外层 try/catch 吞掉
619
+ // → 检查静默失效(lint 永远 0 problem)。故这里不用 try/catch 吞错,
620
+ // 只对 ENOENT 做缺省,写错路径这类编程错会直接暴露。
621
+ const todoTxt = await fs.readFile(join(vault, 'todo.md'), 'utf8').catch(() => '');
622
+ const di = todoTxt.split('\n').findIndex((l) => l.startsWith('## Done'));
623
+ if (di !== -1) {
624
+ const doneLines = todoTxt.split('\n').slice(di + 1).filter((l) => l.trim()).length;
625
+ const DONE_MAX = 60;
626
+ if (doneLines > DONE_MAX) {
627
+ issues.push(`DONE-PILED-UP: Done 区 ${doneLines} 行 > ${DONE_MAX}; 跑 \`abs todo archive\` 迁出旧日期组`);
628
+ }
629
+ }
630
+
532
631
  const n = issues.length;
533
632
  return [
534
633
  ...(issues.length ? issues : []),
package/src/todo.js CHANGED
@@ -138,6 +138,153 @@ function renderDoneGroups(units) {
138
138
  return out;
139
139
  }
140
140
 
141
+ /** 归档标记区标题。它在 Done 区内部、日期分组之后,形如:
142
+ * ### 归档
143
+ * - [[2026-09-10-todo归档]] 完成任务 10 条
144
+ * 这区不是任务行,不能被 parseDoneUnits 吃挂,否则 markDone 重建 Done 时会把它丢掉。
145
+ * 故先切出去当"不透明区域"原样保留。 */
146
+ const ARCHIVE_HEADING = '### 归档';
147
+
148
+ /** 把 Done 主体行切成 { groupLines, archiveLines }:把 `### 归档` 区当"不透明块"原样保留。
149
+ * 注意不能简单"从标题切到文件尾" —— 否则一旦有日期组落在归档区之后(手改/旧数据),
150
+ * 它会被当不透明内容除在分组之外,永远不参与归档。故只取到下一个 ###/## 标题为止。 */
151
+ function splitDoneBody(bodyLines) {
152
+ const ai = bodyLines.findIndex((l) => l.trim() === ARCHIVE_HEADING);
153
+ if (ai === -1) return { groupLines: bodyLines, archiveLines: [] };
154
+ let end = ai + 1;
155
+ while (end < bodyLines.length && !/^#{2,3} \S/.test(bodyLines[end].trim())) end++;
156
+ return {
157
+ groupLines: [...bodyLines.slice(0, ai), ...bodyLines.slice(end)],
158
+ archiveLines: bodyLines.slice(ai, end),
159
+ };
160
+ }
161
+
162
+ /** 合并归档标记行:**每天一行** `- [[<日期>-todo归档]] 完成任务 N 条`。
163
+ * 计数按 slug **累计**(同一天分两批归档时叠加,否则标记行与归档页实际内容不符);
164
+ * 已存在的其他天的标记行原样保留。返回含标题的行数组。 */
165
+ function mergeArchiveLines(existing, entries) {
166
+ const out = existing.length ? [...existing] : [ARCHIVE_HEADING];
167
+ while (out.length && !out[out.length - 1].trim()) out.pop();
168
+ for (const { slug, count } of entries) {
169
+ const i = out.findIndex((l) => l.includes(`[[${slug}]]`));
170
+ const prev = i !== -1 ? Number((out[i].match(/完成任务 (\d+) 条/) || [])[1] || 0) : 0;
171
+ const line = `- [[${slug}]] 完成任务 ${prev + count} 条`;
172
+ if (i !== -1) out[i] = line;
173
+ else out.push(line);
174
+ }
175
+ out.push('');
176
+ // 标记行按日期倒序(与 Done 日期组同风格:新的在上)。
177
+ // 否则顺序 = 归档先后,多次归档后读起来是乱的(如 08/09/07)。
178
+ const dateOf = (l) => (String(l).match(/(\d{4}-\d{2}-\d{2})/) || [''])[0];
179
+ const body = out.slice(1).filter((l) => l.trim());
180
+ body.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
181
+ return [out[0], ...body, ''];
182
+ }
183
+
184
+ /** 本地日期减 n 天(YYYY-MM-DD)。纯字符串入出,避免时区漂移。 */
185
+ function daysAgo(n, from) {
186
+ const [y, m, d] = String(from).split('-').map(Number);
187
+ const t = new Date(y, m - 1, d);
188
+ t.setDate(t.getDate() - n);
189
+ const pad = (x) => String(x).padStart(2, '0');
190
+ return `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`;
191
+ }
192
+
193
+ /** 一行是否为未完成任务行(- [ ])。 */
194
+ function isUndoneLine(l) {
195
+ return /^\s*- \[ \]/.test(l);
196
+ }
197
+
198
+ /**
199
+ * 把 Done 区里"可归档"的日期组摘出来,并追回归档标记行。**纯函数**(不碰磁盘)。
200
+ * 规则(用户定):
201
+ * ① 只保留近 keepDays 天(含今天);更早的才归档。
202
+ * ② 某一天只要还有未完成(- [ ])任务,**整天都不归档**(不拆半天)。
203
+ * ③ 每**天**归一个文件,slug 为 `<日期>-todo归档`(由 slugFor 给),本函数只负责
204
+ * 从 todo 文本里移除 + 在 Done 区尾部的 `### 归档` 区**每天记一行**
205
+ * `- [[<日期>-todo归档]] 完成任务 N 条`。
206
+ * 无日期组(### (未标日期))无法判天数,**保守不归档**。
207
+ * @returns {{text:string, archived:{date:string,lines:string[],slug:string,count:number}[], skipped:{date:string,reason:string}[], count:number}}
208
+ */
209
+ export function archiveDoneInText(text, { keepDays = 3, from = today(), slugFor = (d) => `${d}-todo归档` } = {}) {
210
+ const lines = String(text || '').split('\n');
211
+ const di = lines.findIndex((l) => l.startsWith('## Done'));
212
+ if (di === -1) return { text, archived: [], skipped: [], count: 0 };
213
+ const head = lines.slice(0, di + 1);
214
+ const { groupLines, archiveLines } = splitDoneBody(lines.slice(di + 1));
215
+ const units = parseDoneUnits(groupLines);
216
+ if (!units.length) return { text, archived: [], skipped: [], count: 0 };
217
+
218
+ const keep = Math.max(1, Number(keepDays) || 3);
219
+ // 保留 cutoff..from 这 keep 天;比 cutoff 更早的才归档
220
+ const cutoff = daysAgo(keep - 1, from);
221
+
222
+ const byDate = new Map();
223
+ const undated = [];
224
+ for (const u of units) {
225
+ if (!u.date) { undated.push(u); continue; }
226
+ if (!byDate.has(u.date)) byDate.set(u.date, []);
227
+ byDate.get(u.date).push(u);
228
+ }
229
+
230
+ const archived = [];
231
+ const skipped = [];
232
+ const keepUnits = [...undated];
233
+ if (undated.length) skipped.push({ date: '(未标日期)', reason: '无完成日期,无法判断天数(保守不归档)' });
234
+
235
+ for (const date of [...byDate.keys()].sort()) {
236
+ const us = byDate.get(date);
237
+ if (date >= cutoff) { keepUnits.push(...us); continue; } // ① 近 N 天:保留
238
+ const undone = us.filter((u) => isUndoneLine(u.lines[0]));
239
+ if (undone.length) { // ② 有未完成:整天不归档
240
+ skipped.push({ date, reason: `有 ${undone.length} 条未完成,整天不归档` });
241
+ keepUnits.push(...us);
242
+ continue;
243
+ }
244
+ const gLines = us.flatMap((u) => u.lines); // ③ 可归档
245
+ archived.push({
246
+ date,
247
+ lines: gLines,
248
+ slug: slugFor(date),
249
+ count: gLines.filter((l) => /^\s*- \[x\]/.test(l)).length,
250
+ });
251
+ }
252
+
253
+ if (!archived.length) return { text, archived: [], skipped, count: 0 };
254
+
255
+ const count = archived.reduce((n, g) => n + g.count, 0);
256
+ const entries = archived.map((g) => ({ slug: g.slug, count: g.count }));
257
+ const rebuilt = [...head, ...renderDoneGroups(keepUnits), ...mergeArchiveLines(archiveLines, entries)];
258
+ return { text: rebuilt.join('\n').replace(/\n+$/, '\n'), archived, skipped, count };
259
+ }
260
+
261
+ /** 归档页正文(按日期分组,原文保留)。供首次建页与同日追写复用。 */
262
+ export function renderArchiveBody(groups) {
263
+ const out = [];
264
+ for (const g of groups) out.push(`### ${g.date}`, '', ...g.lines, '');
265
+ return out.join('\n').replace(/\n+$/, '\n');
266
+ }
267
+
268
+ /** 归档页全文(带 frontmatter)。**每天一个文件**,故只收一组。 */
269
+ export function renderArchivePage({ group }) {
270
+ const { date, lines } = group;
271
+ const n = lines.filter((l) => /^\s*- \[x\]/.test(l)).length;
272
+ const head = [
273
+ '---',
274
+ 'tags: [todo-archive, 历史]',
275
+ `updated: ${date}`,
276
+ 'status: reviewed',
277
+ '---',
278
+ '',
279
+ `# Todo 归档 — ${date}`,
280
+ '',
281
+ `> 从 \`.brain/todo.md\` 的 Done 区迁出(该区只保留近期)。本页含 ${date} 的 ${n} 条已完成任务。`,
282
+ '> 原文完整保留(含 `↳ 断点/卡点`),查"某任务当时做到哪"看这里。',
283
+ '',
284
+ ];
285
+ return head.join('\n') + '\n' + renderArchiveBody([group]) + '\n';
286
+ }
287
+
141
288
  /** 幂等:把 todo 全文里平铺的旧 Done 区按日期分组(新日期在前,未标日期归尾)。
142
289
  * 已是分组态(### date)则原样返回——惰性迁移用,避免每次读都动文件。 */
143
290
  export function groupDoneSection(text) {
@@ -147,9 +294,11 @@ export function groupDoneSection(text) {
147
294
  const body = lines.slice(di + 1);
148
295
  const first = body.find((l) => l.trim());
149
296
  if (first && /^### /.test(first.trim())) return text; // 已分组,幂等不动
150
- const units = parseDoneUnits(body);
297
+ const { groupLines, archiveLines } = splitDoneBody(body); // 归档标记区原样保留
298
+ const units = parseDoneUnits(groupLines);
151
299
  if (!units.length) return text;
152
- return [...lines.slice(0, di + 1), ...renderDoneGroups(units)].join('\n').replace(/\n+$/, '\n');
300
+ return [...lines.slice(0, di + 1), ...renderDoneGroups(units), ...archiveLines]
301
+ .join('\n').replace(/\n+$/, '\n');
153
302
  }
154
303
 
155
304
  /** 把 moved 单元(已完成任务行+附属行)放入 Done 区并整体按日期分组重建。
@@ -165,9 +314,10 @@ export function insertDoneGrouped(text, movedLines) {
165
314
  }
166
315
  const head = lines.slice(0, di + 1);
167
316
  const body = lines.slice(di + 1);
317
+ const { groupLines, archiveLines } = splitDoneBody(body); // 归档标记区原样保留
168
318
  // 新完成单元置前:同日期组内新在最上(renderDoneGroups 按 encounter 顺序保持,日期再倒序排)
169
- const units = [newUnit, ...parseDoneUnits(body)];
170
- return [...head, ...renderDoneGroups(units)].join('\n').replace(/\n+$/, '\n');
319
+ const units = [newUnit, ...parseDoneUnits(groupLines)];
320
+ return [...head, ...renderDoneGroups(units), ...archiveLines].join('\n').replace(/\n+$/, '\n');
171
321
  }
172
322
 
173
323
  // 占位/空任务行(老模板的 "(无)"/"无"/纯 - [ ])不迁移