@fanchao8609/agent_brain_sync 1.7.8 → 1.7.9

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
@@ -60,11 +60,11 @@ abs init # 建 .brain/ 图谱,只需一次
60
60
 
61
61
  ```bash
62
62
  abs load # 开机读状态(Rules + 图谱计数 + 看板 + 最近流水)
63
- abs todo # 看板 Today / In Progress / Blocked / Done
63
+ abs todo # 看板 Todo / Done(行首带状态标记)
64
64
 
65
65
  abs todo add TASK-1 --note "要做什么"
66
66
  abs todo note TASK-1 --note "改到 X 文件 L40" # 实时断点
67
- abs todo blocked TASK-1 --note "卡在哪"
67
+ abs todo state TASK-1 --note "进行中|讨论中|滞留中" # 改行首状态标记
68
68
  abs todo done TASK-1
69
69
 
70
70
  abs note "一句话经验" --tags 坑,docker # 经验暂存 → sources/
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, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdShow, cmdRepair, cmdWrapup, cmdRule, cmdTeardownCheck, cmdTodoArchive, cmdTopic } from '../src/store.js';
5
+ import { cmdInit, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdShow, cmdRepair, cmdWrapup, cmdRule, cmdTeardownCheck, cmdTodoArchive } from '../src/store.js';
6
6
  import { setUser, getUser, userConfigPath } from '../src/userconfig.js';
7
7
  import { runInstall, runUninstall } from '../src/install.js';
8
8
  import { readFileSync } from 'node:fs';
@@ -167,8 +167,7 @@ const usage = `abs — agent-brain-sync 记忆工具
167
167
 
168
168
  读:
169
169
  abs load 开机读状态 (index/todo/log)
170
- abs todo 任务看板 Topics / Backlog / Today / Blocked / Done
171
- abs topic 话题树(讨论轨道,含已证伪);无参=查看
170
+ abs todo 任务看板 Todo / Done(未完成的都在这,行首带状态)
172
171
  abs index 图谱索引 index.md
173
172
  abs log 流水 log.md
174
173
  abs status 当前项目 + 图谱概要
@@ -176,11 +175,7 @@ const usage = `abs — agent-brain-sync 记忆工具
176
175
  写:
177
176
  abs todo add <id> [--note ..] [--section ..] 登记任务 (start 同义)
178
177
  abs todo note <id> --note "断点/进度" 实时落 ↳ 断点 行
179
- abs todo blocked <id> --note "卡点原因" 移入 Blocked 区
180
- abs topic new "#1 标题" [--state 状态] [--note 结论] 登记/更新话题
181
- 状态: 进行中|已结论|已否决|待验证|已落地|未落地
182
- 子话题: abs topic new "#1.1 标题"(父子由 id 点分层级自动推)
183
- abs topic promote <id> 讨论成熟、要动手 → 移进 Today(同 id 追踪,不复制)
178
+ abs todo state <id> --note "进行中|讨论中|滞留中" 改状态标记(原地,不搬区)
184
179
  abs todo done <id> [--as 落地|否决|仅方案] 完成;结语标明到底"做成了没有"
185
180
  默认 落地。否决=评估后不做(含做了又撤);仅方案=只设计过
186
181
  不加结语或结语失真会让下一个会话把"想过"当成"做完了"。
@@ -274,7 +269,7 @@ const TODO_ACTIONS = {
274
269
  add: 'start',
275
270
  start: 'start', // add 的别名(老习惯保留)
276
271
  note: 'note',
277
- blocked: 'blocked',
272
+ state: 'state', // 改行首状态标记:进行中|讨论中|滞留中(原地,不搬区)
278
273
  done: 'done',
279
274
  };
280
275
 
@@ -344,28 +339,6 @@ async function main() {
344
339
  rejectExtra(opts._, 'abs status');
345
340
  console.log(await cmdStatus({ dir: opts.dir }));
346
341
  break;
347
- // abs topic —— 分区(话题树):无子命令=树视图;带子命令=话题写操作
348
- case 'topic': {
349
- const [sub, id, ...rest2] = opts._;
350
- if (!sub) {
351
- rejectExtra([id, ...rest2].filter(Boolean), 'abs topic');
352
- console.log(await cmdTopic({ dir: opts.dir }));
353
- break;
354
- }
355
- const r = await cmdTopic({
356
- dir: opts.dir,
357
- action: sub,
358
- id,
359
- // 标题 = 第 3 个位置参数起的全部(含空格),未给则用 id 本身
360
- title: rest2.join(' ') || undefined,
361
- state: opts.state,
362
- conclusion: opts.note,
363
- full: opts.full,
364
- clear: opts.clear,
365
- });
366
- console.log(r);
367
- break;
368
- }
369
342
  // abs todo —— 无子命令=看板;带子命令=任务写操作
370
343
  case 'todo': {
371
344
  const [sub, id, ...rest2] = opts._;
@@ -385,7 +358,7 @@ async function main() {
385
358
  if (!action) {
386
359
  throw new Error(
387
360
  `✗ 未知子命令 "${sub}"\n` +
388
- ` 可用: add / start / note / blocked / done / archive\n` +
361
+ ` 可用: add / start / note / state / done / archive\n` +
389
362
  ` 看板: abs todo(不带参数)\n` +
390
363
  ` 登记任务: abs todo add <id> --note "做什么"`,
391
364
  );
package/bin/mcp.js CHANGED
@@ -104,12 +104,12 @@ tool(
104
104
 
105
105
  tool(
106
106
  'abs_task',
107
- '任务实时落盘(幂等键 = id)。add/start 登记 / note 补断点(改到哪个文件哪行) / blocked 碰壁 / done 完成归位。',
107
+ '任务实时落盘(幂等键 = id)。start 登记进 Todo / note 补断点(改到哪文件哪行) / state 改状态 / done 完成归位 Done。',
108
108
  {
109
- action: z.enum(['add', 'start', 'done', 'note', 'blocked']),
109
+ action: z.enum(['add', 'start', 'done', 'note', 'state']),
110
110
  id: z.string().describe('任务幂等键,如 TASK-xxx 或子任务名'),
111
111
  cwd: z.string().describe('项目根目录(.brain/ 所在处)'),
112
- note: z.string().optional().describe('add/start=做什么; note=断点(文件/到哪步); blocked=卡点原因'),
112
+ note: z.string().optional().describe('add/start=做什么; note=断点(文件/到哪步); state=进行中|讨论中|滞留中'),
113
113
  },
114
114
  async ({ action, id, cwd, note }) => {
115
115
  const root = await findBrainRoot(cwd || process.cwd());
@@ -144,7 +144,7 @@ tool(
144
144
 
145
145
  tool(
146
146
  'abs_rule',
147
- '读/写 index.md 的 ## Rules 硬规则区。action=list 列出;action=add 追加一条(只放一句话 + [[链接]],展开写概念页)。',
147
+ '读/写 index.md 的 ## Rules 硬规则区。action=list 列出;action=add 追加一条(只放一句话,≤42 字符、不带链接;展开写概念页)。',
148
148
  {
149
149
  cwd: z.string().describe('项目根目录(.brain/ 所在处)'),
150
150
  action: z.enum(['list', 'add']).optional().describe('list=列出(默认);add=追加一条'),
@@ -47,15 +47,10 @@ const server = async ({ client, directory }) => {
47
47
 
48
48
  const TEARDOWN_MSG =
49
49
  "[abs 收尾提醒] 本会话改过文件但 .brain/ 今天还没有记录。请立即走收尾循环:\n" +
50
- "1) 跑 abs load 看 Today 还有哪些未完成;\n" +
50
+ "1) 跑 abs load 看 Todo 还有哪些未完成;\n" +
51
51
  "2) 实际做完漏登记的 abs todo done <id>,做到一半的 abs todo note <id> --note \"断点\";\n" +
52
52
  "3) 值得留的经验 abs note \"...\"(宁少勿滥,能从代码 grep 到的不记);\n" +
53
53
  "4) abs log \"完成 X:...\" 记一行工作成果,新页同步进 index。\n" +
54
- // 话题对账:Topics 区只放「正在讨论」的话题。
55
- "5) 话题对账 abs topic:本会话在讨论什么→abs topic new \"#N 标题\" --state 进行中;" +
56
- "已收尾的话题用 --state 已结论/已否决(会自动移出树),停下来的事落 Blocked。\n" +
57
- " ⚠ Topics 区只放正在讨论的话题;五态(已结论/已否决/已落地/未落地/待验证)都会离开树," +
58
- "离开前先把结论写进 sources/(abs note),否则就丢了。\n" +
59
54
  "简洁执行,不要复述本条提醒。若本次确实没有可沉淀产出,直接回一句\"无可沉淀\"即可。"
60
55
 
61
56
  // bash 里只跑查询类命令不算改文件 (与 pi 侧 READONLY_CMD 同义, 但生成代码里要写进模板串)
package/hooks/abs.pi.ts CHANGED
@@ -12,7 +12,6 @@
12
12
  * - before_agent_start (用户发话 = 天然话题边界) → 记用户原话
13
13
  * - turn_end (每轮结束) → 记改过哪些文件
14
14
  * 累积在**内存**(下方 sessionNotes), 收尾时拼进注入提示 —— **不落盘**。
15
- * 用户明确不要新文件(曾做过 .brain/topics.log, 已拆)。
16
15
  */
17
16
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
18
17
  import { appendFile, mkdir, readFile, stat } from "node:fs/promises"
@@ -104,7 +103,7 @@ async function loggedToday(brain: string): Promise<boolean> {
104
103
  // 只认**工作成果**条目(kind=dev),不能只看“今天有没有行”。
105
104
  // 坑(2026-09-13 实测):`abs note` 也写 log.md(kind=note),
106
105
  // 旧判据 (^## [今天 HH:MM]) 把“沉淀了一条经验”当成“今天已收尾”→
107
- // 整天不再提醒 → 新冒的话题全部漏登(用户实报“话题还是没进 Topics”)。
106
+ // 整天不再提醒 → 会话后半场全部漏登。
108
107
  // 行格式: `## [YYYY-MM-DD HH:MM] [[name]] dev | 内容`。
109
108
  return new RegExp("^## \\[" + today + " \\d{2}:\\d{2}\\] (?:\\[\\[[^\\]]+\\]\\] )?dev \\|", "m").test(txt)
110
109
  } catch { return false }
@@ -160,8 +159,7 @@ export default function absPiHook(pi: ExtensionAPI): void {
160
159
  if (!brain) return // 无图谱=不在这项目沉淀, 不打扰
161
160
  if (!hasWriteWork(event?.messages ? collectToolResults(event.messages) : [])) return
162
161
  // 今日已收尾 → 只在**本会话还没真正干事**时才静默。
163
- // 旧行为:今天 log 有一行就整天闭口 —— 于是「收尾过之后新冒的话题」全部没人提醒
164
- // (2026-09-13 实报:web 版聊了一整轮,Topics 里一条没有)。
162
+ // 旧行为:今天 log 有一行就整天闭口 —— 于是收尾之后的产出全部没人提醒。
165
163
  // 注意不能用 sessionNotes.length 当判据:用户每说一句话就会 push 一条,
166
164
  // 那会让 nudge 每轮都触发(噪音)。判据保持「今天已收尾」但配合下面的笔记消费。
167
165
  if (await loggedToday(brain)) return
@@ -172,17 +170,10 @@ export default function absPiHook(pi: ExtensionAPI): void {
172
170
  try {
173
171
  pi.sendUserMessage(
174
172
  "[abs 收尾提醒] 本会话改过文件但 .brain/ 今天还没有记录。请立即走收尾循环:\n" +
175
- "1) 跑 abs load 看 Today 还有哪些未完成;\n" +
173
+ "1) 跑 abs load 看 Todo 还有哪些未完成;\n" +
176
174
  "2) 实际做完漏登记的 abs todo done <id>,做到一半的 abs todo note <id> --note \"断点\";\n" +
177
175
  "3) 值得留的经验 abs note \"...\"(宁少勿滥,能从代码 grep 到的不记);\n" +
178
176
  "4) abs log \"完成 X:...\" 记一行工作成果,新页同步进 index。\n" +
179
- // 话题对账:只放「正在讨论」的话题。终止态(含已否决/已结论/未落地/待验证)
180
- // 一律不入树 —— 结论落 abs note,暂停的事落 Blocked 当任务追踪。
181
- "5) 话题对账 abs topic:据下方素材归纳本会话讨论过什么→" +
182
- "abs topic new \"#N 标题\" --state 进行中;" +
183
- "已收尾的用 --state 已结论/已否决(会自动移出树),停下来的落 Blocked。\n" +
184
- " 正在动手的话题: `abs topic promote #N` 移进 Today(同 id 连续追踪)。\n" +
185
- " ⚠ Topics 区只放正在讨论的话题;五态都会离开树,离开前先把结论写进 sources/(abs note)。\n" +
186
177
  notesBlock(notes) +
187
178
  "简洁执行,不要复述本条提醒。若本次确实没有可沉淀产出,直接回一句\"无可沉淀\"即可。",
188
179
  { deliverAs: "followUp" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.7.8",
3
+ "version": "1.7.9",
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
@@ -25,7 +25,7 @@ abs lint # 图谱体检(死链/悬挂/超限/堆积/未提
25
25
  # 写
26
26
  abs todo add <id> --note "做什么" # 登记任务(start 同义)
27
27
  abs todo note <id> --note "断点/进度" # 实时落 ↳ 断点 行
28
- abs todo blocked <id> --note "卡点原因" # 移入 Blocked
28
+ abs todo state <id> --note "进行中|讨论中|滞留中" # 改状态标记(原地)
29
29
  abs todo done <id> [--as 落地|否决|仅方案] # 完成(默认 落地)
30
30
  abs log "完成 X:…" # 记一行工作成果(无参=查看)
31
31
  abs note "经验一句话" [--tags 坑,docker] # 经验实时暂存 → sources/
@@ -105,9 +105,9 @@ abs config [set user <名字>] # 使用者姓名(写操作
105
105
  | `## Concepts` `## Entities` `## Sources` `## Syntheses` `## Sessions` | 各类页的清单 | 每页一行 `- [[slug]] — 一句话`(`abs note`/建归档页会自动登记),load 里折成计数 |
106
106
 
107
107
  **`## Rules` 区**:铁律清单,`abs load` 每次都全量读(代码里明确不折它)。
108
- - 一句一条;有概念页就用 `[[链接]]` 指过去,**不在此展开**。
108
+ - 一句一条,**不带链接**(链接去概念页自己的「## 关联连接」挂),不展开。
109
109
  - 只有「违反会丢数据 / 静默失效 / 白干活」级才进 —— 普通经验进 `concepts/`。
110
- - 读写:`abs rule` / `abs rule add "一句话"`(>120 字符被拒);`abs lint` 超 30 条会报。
110
+ - 读写:`abs rule` / `abs rule add "一句话"`(>42 字符被拒;也不接受 `[[链接]]`/URL);`abs lint` 超 30 条会报。
111
111
 
112
112
  ### `log.md` —— 工作成果流水
113
113
 
@@ -116,13 +116,15 @@ load 只展示最新 5 条、每条按语义边界收口。
116
116
 
117
117
  ### `todo.md` —— 活看板(进度唯一真源)
118
118
 
119
+ **只有两区**(2026-09-13 精简):未完成的一切都进 `## Todo`,状态用**行首标记**表达。
120
+
119
121
  | 分区 | 放什么 |
120
122
  |---|---|
121
- | `## Backlog` | 想做但没开工 |
122
- | `## Today / In Progress` | 正在做 |
123
- | `## Blocked` | 卡住(附原因,`abs todo blocked`) |
123
+ | `## Todo` | 未完成的一切。行首 `[进行中]` / `[讨论中]` / `[滞留中]`(`abs todo state`) |
124
124
  | `## Done` | 已完成,**必须带结语 `【落地/否决/仅方案】` + `(完成 YYYY-MM-DD)`** |
125
125
 
126
+ 行形态:`- [ ] [进行中] <id> [[name]] — 说明 (认领 YYYY-MM-DD)`
127
+
126
128
  `abs todo done <id>` 会勾选并归位到 Done 的日期组顶部,断点(`↳` 行)随迁;
127
129
  Done 区由 `abs wrapup` 在会话结束时自动把「超 3 天且整天都已完成」的组迁到 `sessions/<日期>-todo归档.md`
128
130
  (任一天有未完成则整天不迁)。**什么时候动它见「进行中」一节。**
@@ -179,12 +181,24 @@ Done 区由 `abs wrapup` 在会话结束时自动把「超 3 天且整天都已
179
181
 
180
182
  **todo 不是收尾仪式,是随改随写的活看板。** 每个任务边界立即更新,与 git commit 同反射。
181
183
 
182
- | 时机 | 动作 |
183
- |---|---|
184
- | 认领新任务 | `abs todo add <id> --note "做什么"` |
185
- | 子任务做完 | `abs todo done <id>` |
186
- | 碰壁/阻塞 | `abs todo blocked <id> --note "卡点原因"` |
187
- | 被打断/干到一半 | `abs todo note <id> --note "改到哪个文件/到哪步"` |
184
+ **看板只有两区**:`## Todo`(未完成的一切)+ `## Done`(已完成)。
185
+ 未完成的状态用**行首标记**表达:`[进行中]` `[讨论中]` `[滞留中]`。
186
+
187
+ | 时机 | 动作 | 落到哪 |
188
+ |---|---|---|
189
+ | 认领新任务 / 聊出一个话题 | `abs_task {action:start, id:"T-1", note:"做什么"}` | Todo `[进行中]` |
190
+ | 只在讨论、还没动手 | `abs_task {action:state, id:"T-1", note:"讨论中"}` | 原地改标记 |
191
+ | 卡住了/等人等数据 | `abs_task {action:state, id:"T-1", note:"滞留中"}` | 原地改标记 |
192
+ | 被打断/干到一半 | `abs_task {action:note, id:"T-1", note:"改到哪个文件/到哪步"}` | 原地 ↳断点 |
193
+ | 子任务做完 | `abs_task {action:done, id:"T-1", note:"结语"}` | Done |
194
+ | 总结出经验/坑/规律 | `abs_note {text:"一句话", tags:"坑,docker"}` | sources/ |
195
+
196
+ > **为什么只有两区**(2026-09-13 实测):跨 4 个项目,Backlog/Today 常年 **0 条**,而 log.md
197
+ > 有 120 条。根因是 AI 的工作方式「一口气做完」——任务从开始到完成都在同一会话内走完,
198
+ > 中间那个「挂到进行时分区」的动作既来不及也不需要。**进行时分区是符合直觉但不符合实际
199
+ > 工作流的抽象**,故删掉,状态改用行首标记。
200
+
201
+ **核心:事件发生的那一刻就落,别攒到收尾。** 动作一变,扫一眼属于哪行,调对应工具。
188
202
 
189
203
  **经验刚冒出来就落**:`abs note "一句话经验" --tags 坑,docker` → 暂存 `sources/`(幂等去重)。宁少勿滥。
190
204
 
@@ -208,15 +222,15 @@ Done 区由 `abs wrapup` 在会话结束时自动把「超 3 天且整天都已
208
222
 
209
223
  收到 Stop / "结束/先这样/切别的事" / 长任务告一段落,立即执行:
210
224
 
211
- 1. **读 todo** → `abs load`,看 Today 还有哪些没完成。
225
+ 1. **读 todo** → `abs load`,看 Todo 还有哪些没完成。
212
226
  2. **判有没有做完没登记** → 实际完成了漏登记的 `abs todo done <id>`;做到一半补
213
- `abs todo note <id> --note 断点`;碰壁 `abs todo blocked`。别让干完的事还停 Today。
227
+ `abs todo note <id> --note 断点`;卡住的 `abs todo state <id> --note 滞留中`。别让干完的事还留在 Todo。
214
228
  3. **沉淀经验(该沉淀才沉淀)** → 踩了值得记的坑/有可复用技巧/跨会话判断 → `abs note`
215
229
  暂存;值得深提炼的(规律/坑/决策)按 Teardown 走完整流程。
216
230
  4. **更新 index/log/todo** → 新页同步进 index;`log.md` 倒序记一行**工作成果**摘要
217
231
  (`abs log "完成 X:..."`,不是工具动作);todo 对账。
218
232
  **新规律是「违反会丢数据/静默失效/白干活」级别 → 往 index 的 `## Rules` 加一行**
219
- (短句 + `[[概念页]]`,不展开)。普通经验不进 Rules —— 否则会长成第二份概念库。
233
+ (短句,不带链接;链接去概念页挂)。普通经验不进 Rules —— 否则会长成第二份概念库。
220
234
  跑 `abs lint` 确认自洽。
221
235
 
222
236
  **完成标准**:看板反映真实状态(Done 无滞留半成品)、该沉淀已落、index/log/todo 与事实一致。
@@ -228,7 +242,7 @@ Done 区由 `abs wrapup` 在会话结束时自动把「超 3 天且整天都已
228
242
  1. **暂存线索**:`abs note`(或建 `sources/YYYY-MM-DD-slug.md`)记做了什么、改哪些文件、验证命令。
229
243
  2. **抽规律**:值得留的 → `concepts/<kebab-slug>.md`:触发场景/❌表现/🛠根因+解法+验证。挂双链。
230
244
  3. **沉淀实体**:碰了重要未记录的事物 → `entities/<TitleCase>.md`。
231
- 4. **对账 todo**:滞留 Today 归位(Done 标日期 / Backlog 补断点);遗留 bug 写 Backlog/Blocked。
245
+ 4. **对账 todo**:做完的归位 Done(标结语+日期),做一半的补断点,卡住的改 `[滞留中]`。
232
246
  5. **综合(可选)**:推进了选型/取舍 → `syntheses/`。
233
247
  6. **收拢 sources**:提炼成规律的删 source,**同步清指向它的引用**(防死链)。
234
248
  7. **修 index + 记 log**:新页同步 index;**过 Rules 门槛的规律加一行到 `## Rules`**;
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 { requireBrain, brainPath, absLogDir, BRAIN_DIR } from './index.js';
6
6
  import { requireUser, atTag, getUser } from './userconfig.js';
7
- import { addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, moveBlocked, insertDoneGrouped, idOfTaskLine, archiveDoneInText, renderArchivePage, renderArchiveBody, DONE_KINDS, withDoneKind, doneKindOf, doneDateOf, collapseDone, SEC, rebuildStructure, topicTreeText, upsertTopicLine, TOPIC_STATES, TOPIC_CLOSED_STATES, parseTopicLine, topicTree, currentTopic, removeTopicLine, promoteTopic } from './todo.js';
7
+ import { stripStateMark, ensureStateMark, normalizeTodo, addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, setStateMark, TASK_STATES, insertDoneGrouped, idOfTaskLine, archiveDoneInText, renderArchivePage, renderArchiveBody, DONE_KINDS, withDoneKind, doneKindOf, doneDateOf, collapseDone, SEC, rebuildStructure } from './todo.js';
8
8
  import { editFile, SKIP } from './lock.js';
9
9
  import { appendWrapup, strandedFor } from './wrapup.js';
10
10
 
@@ -133,10 +133,7 @@ export function logTemplate() {
133
133
  export const BRAIN_SHAPE = {
134
134
  'todo.md': {
135
135
  h1: '# 📋 Todo Board',
136
- // 分区(话题树)置顶:讨论轨道先于执行轨道(见 TODO_SECTIONS 注释)
137
- order: ['## Topics', '## Backlog', '## Today / In Progress', '## Blocked', '## Done'],
138
- // 旧文件标题归一(已在 todo.js 的 LEGACY_SECTION_RENAMES 里给出,这里引用以免双源漂移)
139
- renames: [['## Topic', '## Topics'], ['## 话题', '## Topics'], ['## 话题树', '## Topics'], ['## 分区', '## Topics']],
136
+ order: ['## Todo', '## Done'],
140
137
  },
141
138
  'log.md': {
142
139
  h1: '# 🗒 Activity Log',
@@ -156,6 +153,13 @@ export const LEGACY_MARKS = [
156
153
  ['# 操作日志', '# 🗒 Activity Log'],
157
154
  ['# 图谱索引', '# 🗂 Graph Index'],
158
155
  ['# Todo 看板', '# 📋 Todo Board'],
156
+ // 四区 → 两区(2026-09-13 用户定)。**这条路径是 load 走的**(rebuildStructure),
157
+ // 与 todo.js 的 LEGACY_SECTION_RENAMES(normalizeTodo 用)是两条独立迁移路径 ——
158
+ // 只改一处会导致另一条认不出旧区名,把它们当"非标准分区"原样留末尾(未完成任务
159
+ // 留在文件里但不再被当 TODO)。测试 'load 也要能迁移' 钉住这一点。
160
+ ['## Backlog', '## Todo'],
161
+ ['## Today / In Progress', '## Todo'],
162
+ ['## Blocked', '## Todo'],
159
163
  ['## Done(只留近期,旧的迁 log.md/快照)', '## Done'],
160
164
  ['### 归档', '### Archived'],
161
165
  ['### (未标日期)', '### Undated'],
@@ -207,9 +211,24 @@ export async function checkBrainShape(root) {
207
211
  let changed = [];
208
212
  await editFile(p, (cur) => {
209
213
  if (cur === null) return SKIP;
210
- const r = spec.order.length
211
- ? rebuildStructure(cur, { ...spec, renames: LEGACY_MARKS.filter(([o]) => o.startsWith('## ') || o.startsWith('### ')) })
212
- : fixMarks(cur, spec);
214
+ let next2 = cur;
215
+ if (spec.order.length) {
216
+ // todo.md 的四区 → 两区迁移**必须先走 normalizeTodo**:只有它知道
217
+ // `## Blocked` 区的任务该标 [滞留中](语义信息),而 rebuildStructure 只按
218
+ // renames 改标题、看不到来源分区,只能一律给 [进行中]。
219
+ // 2026-09-13 实测坑:cmdLoad 先跑 checkBrainShape、后跑 readTodo,于是
220
+ // normalizeTodo 的 [滞留中] 映射在 load 路径上永远走不到 → 卡住的任务
221
+ // 被静默标成进行中,两条路径语义不一致。
222
+ const srcText = file === 'todo.md' ? normalizeTodo(cur) : cur;
223
+ const r0 = rebuildStructure(srcText, { ...spec, renames: LEGACY_MARKS.filter(([o]) => o.startsWith('## ') || o.startsWith('### ')) });
224
+ // 兜底:仍无状态标记的未完成任务补默认值(新格式文件本就有标记,此处不触发)。
225
+ next2 = file === 'todo.md'
226
+ ? r0.text.split('\n').map((l) => (l.startsWith('- [ ] ') ? ensureStateMark(l, '进行中') : l)).join('\n')
227
+ : r0.text;
228
+ } else {
229
+ next2 = fixMarks(cur, spec).text;
230
+ }
231
+ const r = { text: next2, changed: next2 === cur ? [] : ['结构按标准重排'] };
213
232
  if (!r.changed.length) return SKIP;
214
233
  changed = r.changed;
215
234
  return { text: r.text };
@@ -252,29 +271,6 @@ async function listBrainFiles(root) {
252
271
  }
253
272
 
254
273
  // ---------- load: 开机读状态 ----------
255
- /// 从看板文本里剔掉 `## Topics` 区段(已由「当前话题」单独渲染)。
256
- /// 只删区标题到下一个 `## ` 之间;末尾多余空行一并收掉。
257
- export function stripTopics(text) {
258
- const lines = String(text ?? '').split('\n');
259
- const at = lines.findIndex((l) => l.trim() === `## ${SEC.topics}`);
260
- if (at === -1) return text;
261
- let end = at + 1;
262
- while (end < lines.length && !/^## /.test(lines[end])) end++;
263
- lines.splice(at, end - at);
264
- return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
265
- }
266
-
267
- /** 「当前话题」一行文本 —— load 首屏用。只给最深的活话题 + 它的父链,
268
- * 让人(和 AI)一眼看到“此刻在哪条线上”,而不摄入整棵树。 */
269
- export function currentTopicText(text) {
270
- const cur = currentTopic(text);
271
- if (!cur) return '';
272
- const byId = new Map(topicTree(text).map((n) => [n.id, n]));
273
- const chain = [];
274
- for (let n = cur; n; n = n.parent ? byId.get(n.parent) : null) chain.unshift(n);
275
- return `⌖ ${chain.map((n) => `${n.id} ${n.title}`).join(' › ')}`;
276
- }
277
-
278
274
  export async function cmdLoad({ dir }) {
279
275
  const root = await requireBrain(dir || process.cwd());
280
276
  // 结构核对先跑:load 每回都要读这三个文件,顺手把它们形状摆正(缺分区)或提个醒(无头)。
@@ -310,15 +306,9 @@ export async function cmdLoad({ dir }) {
310
306
  ...(rulesSection(index) ? [rulesSection(index), ''] : []),
311
307
  collapseIndex(index) || '(index.md 为空)',
312
308
  '',
313
- // 只打「当前话题」一行:整棵树会被 load 反复读并带偏会话(同 Roadmap 之病)。
314
- // 全貌用 `abs topic` 主动查——它是查看命令,不进开机首屏。
315
- ...(currentTopic(todo) ? ['--- 当前话题 (todo.md) ---', currentTopicText(todo), ''] : []),
316
- // 开局三类(2026-09-13 用户澄清):①正在讨论=上面 Topics ②待办/已开工/已收尾=
317
- // 下面 Board(Done 区就是已结束,折叠只是显示形式——归档才管前几天)。
318
- // 曾加过「今日已结束」单列段:多余,撤掉(Done 区已表达同一信息)。
309
+ // 两区制:Todo(未完成,行首带状态标记)+ Done。
319
310
  '--- Todo Board (todo.md) ---',
320
- // 看板里剔掉 Topics 区:它已在上面用树形打印过,原样再贴一遍是纯冗余。
321
- stripTopics(collapseDone(todo).text) || '(todo.md 为空)',
311
+ collapseDone(todo).text || '(todo.md 为空)',
322
312
  '\n(Done 已按日期折叠计数;明细: abs todo --full)',
323
313
  '',
324
314
  '--- 最近动作 (log.md, 最新 5 条) ---',
@@ -393,9 +383,16 @@ export async function cmdRule({ dir, action, text }) {
393
383
  }
394
384
  const clean = String(text || '').replace(/\s+/g, ' ').trim();
395
385
  if (!clean) return '用法: abs rule add "一句话硬规则"';
396
- // 门槛:一句话说完。太长说明该写概念页,Rules 只放指针。
397
- if (clean.length > 120) {
398
- return `✗ 太长(${clean.length} 字符 > 120)—— Rules 只放一句话摘要,展开写成概念页,\n 这里改成短句 + [[页面名]] 链接。`;
386
+ // 门槛:**纪律不是记事本**。Rules 是最前面的项目铁律,每条必须一眼扫完。
387
+ // 2026-09-13 用户定:不带链接、不带解释、不写细节 —— 细节进概念页,Rules 只留结论。
388
+ // 宽严:中文一字信息量大,按 40 字算(≈ 英文 80 字符的量)。实测现有 12 条最长 56 字符。
389
+ if (clean.length > 42) {
390
+ return `✗ 太长(${clean.length} > 42)—— Rules 是纪律不是记事本:\n`
391
+ + ` 每条要一眼扫完。细节/出处/例子 → 写进 concepts/ 概念页,这里只留一句话结论。`;
392
+ }
393
+ if (/\[\[|\]\]|https?:\/\//.test(clean)) {
394
+ return '✗ 不带链接 —— Rules 区会被反复全量打印,链接占位且让纪律读起来像索引。\n'
395
+ + ' 要挂概念页,去概念页自己的「## 关联连接」里挂。';
399
396
  }
400
397
  let added = null;
401
398
  await editFile(p, (cur) => {
@@ -605,7 +602,7 @@ export async function cmdTeardownCheck({ dir, payload }) {
605
602
  '0) 本机尚未设置使用者姓名 —— 先跑 abs config set user <你的名字>,否则下面 2/3/4 都会被拦下;',
606
603
  ]),
607
604
  '[abs 收尾提醒] 本会话改过文件但 .brain/ 今天还没有记录。请立即走收尾循环:',
608
- '1) 跑 abs load 看 Today 还有哪些未完成;',
605
+ '1) 跑 abs load 看 Todo 还有哪些未完成;',
609
606
  '2) 实际做完漏登记的 abs todo done <id>,做到一半的 abs todo note <id> --note "断点";',
610
607
  '3) 值得留的经验 abs note "..."(宁少勿滥,能从代码 grep 到的不记);',
611
608
  '4) abs log "完成 X:..." 记一行工作成果,新页同步进 index。',
@@ -702,15 +699,29 @@ export async function cmdTask({ dir, action, id, section, note, as }) {
702
699
  const who = await requireUser();
703
700
  await ensurePersonPage(root, who); // 首次写操作即建人页(已存在不动)
704
701
  if (action === 'start') {
702
+ // 两区制:未完成一律进 Todo,行首带状态标记(默认 进行中)。
703
+ const st = section && TASK_STATES.includes(section) ? section : '进行中';
705
704
  const r = await upsertTask(root, {
706
- section: section || 'Today / In Progress',
707
- text: `${id} ${atTag(who)}${note ? ' — ' + note : ''} (认领 ${today()})`,
705
+ section: SEC.todo,
706
+ text: `[${st}] ${id} ${atTag(who)}${note ? ' — ' + note : ''} (认领 ${today()})`,
708
707
  });
709
- return `✓ 任务${r.updated ? '更新(幂等)' : '登记'} → ${brainPath(root, 'todo.md')}\n ${id} ${atTag(who)}${note ? ' — ' + note : ''}`;
708
+ return `✓ 任务${r.updated ? '更新(幂等)' : '登记'} → ${brainPath(root, 'todo.md')}\n [${st}] ${id} ${atTag(who)}${note ? ' — ' + note : ''}`;
710
709
  }
711
- if (action === 'blocked') {
712
- const r = await moveBlocked(root, { id, reason: note });
713
- return r.msg;
710
+ if (action === 'state') {
711
+ // 改状态标记(原地,不搬区):进行中 / 讨论中 / 滞留中
712
+ if (!note) throw new Error(`✗ 用法: abs todo state <id> --note "${TASK_STATES.join('|')}"`);
713
+ if (!TASK_STATES.includes(note)) {
714
+ throw new Error(`✗ 状态只接受: ${TASK_STATES.join(' | ')}(收到 "${note}")`);
715
+ }
716
+ const f = brainPath(root, 'todo.md');
717
+ let cc = [];
718
+ await editFile(f, (cur) => {
719
+ if (cur === null) return SKIP;
720
+ const r = setStateMark(cur, id, note);
721
+ cc = r.changed;
722
+ return r.changed.length ? { text: r.text } : SKIP;
723
+ });
724
+ return cc.length ? `✓ ${id} 状态 → [${note}]` : `(未找到含 "${id}" 的未完成任务行,或状态未变)`;
714
725
  }
715
726
  if (action === 'note') {
716
727
  if (!note) return '用法: abs todo note <id> --note "断点/进度"(实时落 ↳ 断点 行)';
@@ -741,7 +752,7 @@ async function markDone(file, id, kind = '落地') {
741
752
  if (!moved && idOfTaskLine(l) === wantId) {
742
753
  changed = true;
743
754
  const head = withDoneKind(
744
- l.replace('- [ ]', '- [x]').replace(/\(认领[^)]*\)/, '') + ` (完成 ${today()})`,
755
+ stripStateMark(l).replace('- [ ]', '- [x]').replace(/\(认领[^)]*\)/, '') + ` (完成 ${today()})`,
745
756
  kind,
746
757
  );
747
758
  const bp = [];
@@ -1059,12 +1070,12 @@ export async function cmdLint({ dir }) {
1059
1070
  const idxTxt = await readFileOrNull(join(vault, 'index.md'));
1060
1071
  const { items, found } = readRules(idxTxt);
1061
1072
  if (found && items.length > RULES_MAX) {
1062
- issues.push(`RULES-PILED-UP: Rules 区 ${items.length} 条 > ${RULES_MAX};把长条目提炼成概念页,这里只留一句 + 链接`);
1073
+ issues.push(`RULES-PILED-UP: Rules 区 ${items.length} 条 > ${RULES_MAX};把长条目提炼成概念页,这里只留一句话`);
1063
1074
  }
1064
1075
  // 该区是 load 必读的硬规则清单,条目却写得像段落 → 提醒改短句。
1065
1076
  const longOnes = items.filter((l) => l.trim().length > 160);
1066
1077
  if (longOnes.length) {
1067
- issues.push(`RULES-TOO-LONG: Rules 区 ${longOnes.length} 条超 160 字符(如 "${clip(longOnes[0].trim(), 40)}");展开写进概念页,这里只留短句 + [[链接]]`);
1078
+ issues.push(`RULES-TOO-LONG: Rules 区 ${longOnes.length} 条超 160 字符(如 "${clip(longOnes[0].trim(), 40)}");展开写进概念页,这里只留一句话(不带链接)`);
1068
1079
  }
1069
1080
  }
1070
1081
 
@@ -1172,74 +1183,3 @@ async function listPages(vault) {
1172
1183
  }
1173
1184
  return pages;
1174
1185
  }
1175
-
1176
- /** abs topic —— 分区(话题树)读写。
1177
- * 两个关键修正(2026-09-13 实测踩坑):
1178
- * ① `id` 参数可能是“"#1" 标题”合一串 —— 必须切开,否则整串被当作 id,
1179
- * upsert 找不到行 → 新插一行重复话题。
1180
- * ② 未给标题时必须**保留现有标题**(不能回退成 id):
1181
- * `abs topic conclude "#1"` 曾把标题写成 `#1` 并丢掉结论。 */
1182
- export async function cmdTopic({ dir, action, id, title, state, conclusion, full, clear }) {
1183
- const root = await requireBrain(dir || process.cwd());
1184
- const p = brainPath(root, 'todo.md');
1185
- if (!action || action === 'tree' || action === 'list') {
1186
- const text = await readTodo(root);
1187
- const t = topicTreeText(text);
1188
- return t || '(无话题。登记: abs topic new "#1 标题")';
1189
- }
1190
- // 写操作:需姓名(与 todo 同守卫);姓名同时写进话题行([[name]])
1191
- const who = await requireUser();
1192
- await ensureTodo(root);
1193
- // 拆 “#1 标题” → id / title(id 只取 # 开头的第一个 token)
1194
- let useId = id;
1195
- let useTitle = title;
1196
- if (useId && !/^#[\w.]+$/.test(useId)) {
1197
- const m = String(useId).match(/^(#[\w.]+)\s+(.*)$/);
1198
- if (m) { useId = m[1]; useTitle = (useTitle ? m[2] + ' ' + useTitle : m[2]).trim(); }
1199
- }
1200
- if (!useId) throw new Error(`✗ 缺 <id>\n 用法: abs topic ${action} "#1 标题" [--state 状态] [--note 结论]`);
1201
- if (!/^#[\w.]+$/.test(useId)) {
1202
- throw new Error(`✗ id 需以 # 开头(如 #1 / #2.1),收到 "${useId}"`);
1203
- }
1204
- const st = state || (action === 'reject' ? '已否决' : action === 'conclude' ? '已结论' : action === 'done' ? '已落地' : '进行中');
1205
- // `abs topic promote #N` —— 讨论成熟、要动手了 → 移进 Today(同 id 连续追踪)。
1206
- // 不是复制两份:话题变身任务,Topics 退位。
1207
- if (action === 'promote') {
1208
- if (!useId) throw new Error('✗ 缺 <id>\n 用法: abs topic promote "#1" [--to Today]');
1209
- const cur = await readTodo(root);
1210
- const section = state || SEC.today; // --state 可指定目标分区(默认 Today)
1211
- const r = promoteTopic(cur, useId, section);
1212
- if (!r.changed.length) {
1213
- return `• 话题 ${useId} 未登记(或目标分区 ${section} 不存在),未动`;
1214
- }
1215
- await editFile(p, () => ({ text: r.text }));
1216
- return `✓ 话题 ${useId} 已移进 ${section}(同 id 连续追踪,不再是话题)`;
1217
- }
1218
- if (!TOPIC_STATES.includes(st)) {
1219
- throw new Error(`✗ --state 只接受: ${TOPIC_STATES.join(' | ')}(收到 "${st}")`);
1220
- }
1221
- // 终止态不入 Topics 区(用户定:这里只放正在讨论的)。
1222
- // 不默默拒绝而是给明确出路:先把结论落 sources/,再从树上摘掉。
1223
- if (TOPIC_CLOSED_STATES.includes(st)) {
1224
- const cur = await readTodo(root);
1225
- const node = topicTree(cur).find((n) => n.id === useId);
1226
- if (node) {
1227
- await editFile(p, (c) => ({ text: removeTopicLine(c, useId) }));
1228
- return (
1229
- `✓ 话题 ${useId} [${st}] 已从 Topics 移出(终止态不留树上)\n` +
1230
- ` ⚠ 结论请用 \`abs note "..."\` 落 sources/,否则就丢了`
1231
- );
1232
- }
1233
- return `• 话题 ${useId} [${st}] 未登记(终止态不入 Topics 区)\n ⚠ 结论请用 \`abs note "..."\` 落 sources/`;
1234
- }
1235
- const orig = await readTodo(root);
1236
- // 未给标题 → 沿用现有行(“只改状态”场景)
1237
- const existing = topicTree(orig).find((n) => n.id === useId);
1238
- const title2 = useTitle || existing?.title;
1239
- if (!title2) throw new Error(`✗ 话题 ${useId} 不存在,需给标题\n 用法: abs topic new "${useId} 标题"`);
1240
- const concl = conclusion !== undefined ? conclusion : existing?.conclusion;
1241
- const r = await editFile(p, (cur) => ({
1242
- text: upsertTopicLine(cur, { id: useId, title: title2, state: st, conclusion: concl, author: who }).text,
1243
- }));
1244
- return `✓ 话题 ${useId} [${st}] → ${p}\n ${title2}${concl ? ' — ' + concl : ''}`;
1245
- }
package/src/todo.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/todo.js — todo.md 读写。CLI 的核心纯读写层(被 MCP 转接、可被 hook 直接调)。
2
- // 格式沿用 SKILL.md 契约:Backlog → Today / In Progress → Blocked → Done,半成品 `↳ 断点:`。
2
+ // 格式:两区 `## Todo`(未完成,行首 `[进行中|讨论中|滞留中]`)+ `## Done`;半成品附属行 `↳ 断点:`。
3
3
  import { promises as fs } from 'node:fs';
4
4
  import { brainPath } from './index.js';
5
5
  import { editFile, SKIP } from './lock.js';
@@ -48,32 +48,27 @@ export async function ensureTodo(brainRoot) {
48
48
  /** 分区名常量(单一真源)。改名时只改这里 —— 之前散在 20+ 处,一改就漏。
49
49
  * 旧名(中文)留在 LEGACY_SECTION_RENAMES 作迁移用。 */
50
50
  export const SEC = {
51
- backlog: 'Backlog',
52
- today: 'Today / In Progress',
53
- blocked: 'Blocked',
51
+ todo: 'Todo',
54
52
  done: 'Done',
55
- // 分区(话题树总看板):讨论层面的话题状态机,与「要落实的事」(Backlog/Today)分层。
56
- // 讨论出话题 → 记分区;成熟到要动手 → 才落一行任务。
57
- // 中文标题「分区」比 Topic 更直白;LEGACY 里把英文 Topic 也归一过来。
58
- topics: 'Topics',
59
53
  archived: 'Archived', // Done 区内部的归档标记区(原 '### 归档')
60
54
  undated: 'Undated', // Done 区内部无完成日期的尾组(原 '### (未标日期)')
61
55
  };
62
56
 
63
57
  /** 旧名 → 新名。供 `abs init --repair` 一次性迁移(幂等)。
64
58
  * 只改匹配整行的标题,不动正文;不做模糊替换(防误改正文里提到的旧名)。 */
65
- // 分区标题归一(英文 Topic / 话题 都归一到「分区」)
59
+ // 分区标题归一(旧名 → 标准名)
66
60
  export const LEGACY_SECTION_RENAMES = [
67
61
  // H1(文件标题)
68
62
  ['# 🗂 图谱索引', '# 🗂 Graph Index'],
69
63
  ['# 🗒 操作日志', '# 🗒 Activity Log'],
70
64
  ['# 📋 Todo 看板', '# 📋 Todo Board'],
71
65
  // ## 分区
66
+ // 四区 → 两区(2026-09-13 用户定:Backlog/Today 常年为空,进行时分区与实际工作流错配)。
67
+ // 未完成的一律归 Todo,状态改由**行首标记**表达(见 TASK_STATES)。
68
+ ['## Backlog', '## Todo'],
69
+ ['## Today / In Progress', '## Todo'],
70
+ ['## Blocked', '## Todo'],
72
71
  ['## Done(只留近期,旧的迁 log.md/快照)', '## Done'],
73
- ['## Topic', '## Topics'],
74
- ['## 话题', '## Topics'],
75
- ['## 话题树', '## Topics'],
76
- ['## 分区', '## Topics'],
77
72
  // ### 区内分组标题
78
73
  ['### 归档', '### Archived'],
79
74
  ['### (未标日期)', '### Undated'],
@@ -106,12 +101,18 @@ export function todoTemplate() {
106
101
  ).text;
107
102
  }
108
103
 
109
- /** 分区分区(话题树):**置顶** —— 回答「我们在做什么、分了几叉、哪些已死」。
110
- * 设计取舍(2026-09-13 用户定):讨论轨道与执行轨道必须分开。
111
- * 实测教训:一轮会话讨论了 14 条话题线,而 todo 只能反映 1 条(记录率 7%)——
112
- * 因为话题一旦「已结论/已证伪」就不该再占待办位,但也不该消失。
113
- * 故:分区置顶存话题状态(含已证伪),Backlog/Today 只存「要动手的事」。 */
114
- export const TODO_SECTIONS = ['Topics', 'Backlog', 'Today / In Progress', 'Blocked', 'Done'];
104
+ // 只有两区(2026-09-13 用户定:精简)。为什么砍掉 Backlog/Today/Blocked:
105
+ // 实测跨 4 个项目,Backlog 与 Today 常年为 **0 条** —— 而 log.md 有 120 条。
106
+ // 根因:AI 的工作方式是「一口气做完」,任务从开始到完成都在同一会话内走完,
107
+ // 中间那个「挂到进行时分区上」的动作既来不及也不需要发生。
108
+ // 而 log 是在**结束时**写的,那个时点真实存在,所以它记满了。
109
+ // 结论:进行时分区是符合直觉但不符合实际工作流的抽象 → 删掉,
110
+ // 未完成的一律进 Todo,状态用**行首标记**表达(不靠分区区分)。
111
+ export const TODO_SECTIONS = ['Todo', 'Done'];
112
+
113
+ /** 任务状态标记(行首,方括号)。替代原 Backlog/Today/Blocked 三区的区分作用。
114
+ * 放在 id **之前**,与 Done 结语的 `【落地】` 形态区分开(那是行尾、结语用)。 */
115
+ export const TASK_STATES = ['进行中', '讨论中', '滞留中'];
115
116
 
116
117
  /** 结构重建(B 档):以标准分区表为准重排整个文件。
117
118
  *
@@ -185,8 +186,11 @@ function trimBlank(arr) {
185
186
  export function normalizeTodo(text) {
186
187
  const lines = text.split('\n');
187
188
  const has = (name) => lines.some((l) => l.trim() === `## ${name}`);
188
- if (has('Backlog') || has('Today / In Progress')) return text; // 已是新格式
189
- if (!has('In Progress') && !has('Todo')) return text; // 不是老格式,不动
189
+ // 已是标准两区制 = 有 Todo 且**没有**任何进行时老分区。
190
+ // 坑(2026-09-13):曾只判 has('Todo') 就 return —— 而最老格式里也可能有 `## Todo`
191
+ // (与 `## In Progress` 混用),于是旧文件永不迁移。
192
+ const legacy = ['Backlog', 'Today / In Progress', 'In Progress', 'Blocked'].some(has);
193
+ if (has('Todo') && !legacy) return text;
190
194
  const out = ['# 📋 Todo Board'];
191
195
  const grab = (name) => {
192
196
  const items = [];
@@ -198,16 +202,50 @@ export function normalizeTodo(text) {
198
202
  return items;
199
203
  };
200
204
  const done = grab('Done');
201
- const blocked = grab('Blocked');
202
- const inprog = grab('In Progress');
203
- const todo = grab('Todo');
204
- out.push('## Backlog', ...todo.length ? todo : []);
205
- out.push('## Today / In Progress', ...inprog.length ? inprog : []);
206
- out.push('## Blocked', ...blocked.length ? blocked : []);
207
- out.push('## Done', ...done.length ? done : []);
205
+ // 未完成的一切(不论原来在 Backlog/In Progress/Todo/Blocked)→ 一律进 Todo。
206
+ // 状态信息由行首标记承接:原 Blocked 区的任务补 [滞留中](其余默认 [进行中],写入时补)。
207
+ const open = [];
208
+ for (const name of ['Backlog', 'Today / In Progress', 'In Progress', 'Todo']) {
209
+ open.push(...grab(name).map((l) => ensureStateMark(l, '进行中')));
210
+ }
211
+ open.push(...grab('Blocked').map((l) => ensureStateMark(l, '滞留中')));
212
+ out.push('## Todo', ...open);
213
+ out.push('## Done', ...done);
208
214
  return out.join('\n');
209
215
  }
210
216
 
217
+ /** 给任务行补行首状态标记(已有则不覆盖)。`- [ ] id …` → `- [ ] [状态] id …` */
218
+ export function ensureStateMark(line, state) {
219
+ const m = String(line).match(/^(- \[[ x]\] )(\[[^\]]+\]\s+)?(.*)$/);
220
+ if (!m) return line;
221
+ if (m[2] && TASK_STATES.includes(m[2].trim().replace(/[\[\]]/g, ''))) return line;
222
+ return `${m[1]}[${state}] ${m[3]}`;
223
+ }
224
+
225
+ /** 取任务行的状态标记;无标记返回 null。 */
226
+ export function stateOfTaskLine(line) {
227
+ const m = String(line).match(/^- \[[ x]\] \[([^\]]+)\]/);
228
+ return m && TASK_STATES.includes(m[1]) ? m[1] : null;
229
+ }
230
+
231
+ /** 改任务行的状态标记(原地)。未找到或非法状态返回原样。 */
232
+ export function setStateMark(text, id, state) {
233
+ if (!TASK_STATES.includes(state)) return { text, changed: [] };
234
+ const lines = String(text ?? '').split('\n');
235
+ const idx = findTaskLine(lines, id);
236
+ if (idx === -1) return { text, changed: [] };
237
+ const cur = lines[idx];
238
+ if (stateOfTaskLine(cur) === state) return { text, changed: [] };
239
+ const raw = stripStateMark(cur); // 先剥旧标记,再补新的
240
+ lines[idx] = raw.replace(/^(- \[[ x]\] )/, `$1[${state}] `);
241
+ return { text: lines.join('\n'), changed: [`${id} 状态 → ${state}`] };
242
+ }
243
+
244
+ /** 剥掉行首状态标记。 */
245
+ export function stripStateMark(line) {
246
+ return String(line).replace(/^(- \[[ x]\] )\[[^\]]+\] /, '$1');
247
+ }
248
+
211
249
  /** 从已完成任务行提取 `(完成 YYYY-MM-DD)` 日期;无则返回 ''。兼容中英文括号。 */
212
250
  export function doneDateOf(line) {
213
251
  const m = String(line).match(/\(完成\s*(\d{4}-\d{2}-\d{2})[^)]*\)/);
@@ -497,7 +535,7 @@ export function insertDoneGrouped(text, movedLines) {
497
535
  const di = lines.findIndex((l) => l.startsWith('## Done'));
498
536
  const newUnit = { date, lines: movedLines };
499
537
  if (di === -1) {
500
- const header = '## Done(只留近期,旧的迁 log.md/快照)';
538
+ const header = `## ${SEC.done}`;
501
539
  return [...lines, '', header, '', ...renderDoneGroups([newUnit])].join('\n').replace(/\n+$/, '\n');
502
540
  }
503
541
  const head = lines.slice(0, di + 1);
@@ -539,12 +577,6 @@ function insertTask(orig, section, text) {
539
577
  return lines.join('\n');
540
578
  }
541
579
 
542
- /** 任务行解析:`- [ ] <id> — note (认领 date)` / 附属断点行 `↳ 断点:`。 */
543
- export function parseTaskLine(line) {
544
- const m = line.match(/^- \[( |x)\] (.*?)(?: — (.*?))? \(?(认领|完成 \d{4}-\d{2}-\d{2}[^)]*)?\)?$/);
545
- return m; // 保守解析;不匹配返回 null(附属行等)
546
- }
547
-
548
580
  /** 幂等登记:同 id 已有未完成任务行则原位更新(新 note/新认领日期),否则插入。 */
549
581
  export async function upsertTask(brainRoot, { section, text }) {
550
582
  const p = await ensureTodo(brainRoot);
@@ -555,16 +587,21 @@ export async function upsertTask(brainRoot, { section, text }) {
555
587
  const lines = base.split('\n');
556
588
  // 在所有分区中找该 id 对应的未完成任务行(Done 的已完成行不重复动);全等比对
557
589
  const idx = findTaskLine(lines, id);
590
+ // 状态标记:新建用 text 里带的(默认 进行中),更新保留原有的
591
+ const wanted = String(text).match(/^\[([^\]]+)\]/)?.[1];
592
+ const state = idx !== -1
593
+ ? (stateOfTaskLine(lines[idx]) || wanted || '进行中')
594
+ : (TASK_STATES.includes(wanted) ? wanted : '进行中');
558
595
  if (idx !== -1) {
559
- // 原位更新:保留断点附属行 + 原@作者,只换任务行本体
560
- const raw = lines[idx].replace(/^- \[ \] /, '');
596
+ // 原位更新:保留断点附属行 + 原@作者 + 原状态,只换任务行本体
597
+ const raw = stripStateMark(lines[idx]).replace(/^- \[ \] /, '');
561
598
  const author = extractAuthor(raw);
562
599
  const legacy = isLegacyAuthorTag(raw); // 旧行保持旧形态,不静默改写
563
600
  const oldNote = extractNote(raw);
564
601
  const merged = note && oldNote && oldNote.startsWith(note) ? `${note}${oldNote.slice(note.length)}` : note || oldNote;
565
602
  const at = author ? ` ${legacy ? '@' + author : '[['
566
603
  + author + ']]'}` : '';
567
- const newLine = `- [ ] ${id}${at}${merged ? ' — ' + merged : ''} (认领 ${today()})`;
604
+ const newLine = `- [ ] [${state}] ${id}${at}${merged ? ' — ' + merged : ''} (认领 ${today()})`;
568
605
  lines[idx] = newLine;
569
606
  return { text: lines.join('\n'), updated: true };
570
607
  }
@@ -577,7 +614,9 @@ export async function upsertTask(brainRoot, { section, text }) {
577
614
 
578
615
  /** 从任务文本提取幂等键(行首 id,如 TASK-1 / T-2 / fix-hook)。 */
579
616
  export function extractId(text) {
580
- const m = String(text).match(/^([A-Za-z][\w-]*)\b/);
617
+ // 先剥行首状态标记(`[进行中] <id>`),否则 id 会取成 "[进行中]"(2026-09-13 实测)。
618
+ const t = String(text).replace(/^\[[^\]]+\]\s+/, '');
619
+ const m = t.match(/^([A-Za-z][\w-]*)\b/);
581
620
  return m ? m[1] : null;
582
621
  }
583
622
 
@@ -617,7 +656,9 @@ export function isLegacyAuthorTag(text) {
617
656
  */
618
657
  export function idOfTaskLine(line) {
619
658
  if (!line || !line.startsWith('- [ ]')) return null;
620
- const m = line.match(/^- \[ \] (\S+)/);
659
+ // 两区制下行首可带状态标记(`- [ ] [进行中] <id> …`)—— 必须先剥掉再取 id,
660
+ // 否则 id 会被读成 "[进行中]",findTaskLine 全等比对永远落空(2026-09-13 实测)。
661
+ const m = line.match(/^- \[ \] (?:\[[^\]]+\] )?(\S+)/);
621
662
  return m ? m[1].replace(/\u200b/g, '') : null;
622
663
  }
623
664
 
@@ -649,35 +690,6 @@ export async function setBreakpoint(brainRoot, { id, text }) {
649
690
  : { ok: true, msg: `✓ 断点已落 → ${id}\n ${bp.trim()}` };
650
691
  }
651
692
 
652
- /** 实时碰壁: 任务行原位勾成 blocked 语义(移入 Blocked 区 + 附原因)。 */
653
- export async function moveBlocked(brainRoot, { id, reason }) {
654
- const p = await ensureTodo(brainRoot);
655
- const res = await editFile(p, (orig) => {
656
- const lines = orig.split('\n');
657
- const idx = findTaskLine(lines, id);
658
- if (idx === -1) return SKIP;
659
- const taskLine = lines[idx];
660
- const kept = [];
661
- const moved = [taskLine];
662
- for (let i = 0; i < lines.length; i++) {
663
- if (i === idx) {
664
- while (i + 1 < lines.length && lines[i + 1].trimStart().startsWith('↳')) moved.push(lines[++i]);
665
- continue;
666
- }
667
- kept.push(lines[i]);
668
- }
669
- const bIdx = kept.findIndex((l) => l.startsWith('## Blocked'));
670
- if (reason) moved.push(` ↳ 卡点: ${reason}`);
671
- const out = bIdx === -1
672
- ? [...kept, '## Blocked', ...moved]
673
- : [...kept.slice(0, bIdx + 1), ...moved, ...kept.slice(bIdx + 1)];
674
- return { text: out.join('\n'), ok: true };
675
- });
676
- return res === SKIP
677
- ? { ok: false, msg: `(未找到含 "${id}" 的未完成任务行)` }
678
- : { ok: true, msg: `✓ 已标阻塞 → Blocked 区: ${id}${reason ? `\n 卡点: ${reason}` : ''}` };
679
- }
680
-
681
693
  // ---------- 看板输出 ----------
682
694
  export async function boardText(brainRoot, textOverride, { full = false } = {}) {
683
695
  const text = textOverride !== undefined ? textOverride : await readTodo(brainRoot);
@@ -688,242 +700,3 @@ export async function boardText(brainRoot, textOverride, { full = false } = {})
688
700
  const hint = full ? '' : '\n\n(Done 只给计数;看明细: abs todo --full)';
689
701
  return `${head}\n\n${body}${hint}`;
690
702
  }
691
-
692
- // ---------- 分区(话题树) ----------
693
- // 设计动机(2026-09-13 实测教训):一轮长会话讨论了 14 条话题线,而看板只能反映 1 条
694
- // (记录率 7%)——因为「已结论/已证伪」的话题不该再占待办位,但也不该消失(否则下个
695
- // 会话重走死路)。故把「讨论轨道」独立成 `## 分区`:话题带状态,与「执行轨道」
696
- // (Backlog/Today)分层。讨论出话题 → 记分区;成熟到要动手 → 才落一行任务。
697
-
698
- /** 话题状态(与执行轨道共享的语义,但用于话题层面)。 */
699
- export const TOPIC_STATES = ['进行中', '已结论', '已否决', '待验证', '已落地', '未落地'];
700
-
701
- /** 终止态:这些话题**不该留在 Topics 区**(用户定:只放正在讨论的)。
702
- * 理由:树的价值是「当前在哪」;死话题会把活话题淹掉。
703
- * 归档去向:结论写进 sources/(abs note),那里可检索、不挤占 load 首屏。
704
- *
705
- * 2026-09-13 修正:`未落地`/`待验证` **也算离开 Topics**。
706
- * 先前误把它们当“活跃”留在树上 → 结果 #8(方案已定、等客户端配合)/#3.4(等数据)
707
- * 长期挂在“正在讨论”里变成僵尸。它们的真实语义是**暂停**(等人/等数据),
708
- * 应落 Backlog/Blocked 当成任务追踪,而不是冒充“当前话题”。 */
709
- export const TOPIC_CLOSED_STATES = ['已结论', '已否决', '已落地', '待验证', '未落地'];
710
-
711
- /** 从 Topics 区移除一个话题(含其父指针行)。终止态话题用 —— 树只留活跃话题。 */
712
- export function removeTopicLine(text, id) {
713
- const lines = String(text ?? '').split('\n');
714
- const { at, lines: seg } = topicLines(lines.join('\n'));
715
- if (at === -1) return text;
716
- const rel = seg.findIndex((l) => {
717
- const t = parseTopicLine(l);
718
- return t && t.id === id;
719
- });
720
- if (rel === -1) return text;
721
- const abs = at + 1 + rel;
722
- const drop = 1 + (/^\s*└─\s*父:/.test(lines[abs + 1] || '') ? 1 : 0);
723
- lines.splice(abs, drop);
724
- return lines.join('\n');
725
- }
726
-
727
- /** 话题行格式(刻意做得极简、可手写可机器解析):
728
- * - [ ] #12 [[name]] 减少决策步往返 [进行中] — 结论
729
- * - [x] #11 [[name]] 任务拆分 [已否决] — 子任务必 miss
730
- * └─ 父: #9
731
- * 缩进 = 父子关系(纯缩进即树,不引入新语法);[[name]] = 登记人(与 todo 行同约定)。
732
- *
733
- * 解析要点(2026-09-13 踩坑):状态标记是**末尾** `[状态]`,不能用非贪婪匹配 ——
734
- * 否则标题含 `[` 时会被第一个 `[` 截断。故:先切结论,再从剩余尾部抽状态。 */
735
- const TOPIC_RE = /^(\s*)- \[( |x)\]\s+(#[\w.]+)\s+(.*)$/;
736
- const TOPIC_STATE_TAIL = /\s*\[([^\]]+)\]\s*$/;
737
- const TOPIC_AUTHOR = /^\[\[([^\]]+)\]\]\s*/;
738
-
739
- /** 解析一行话题。返回 null 表示不是话题行(普通任务行/正文行)。 */
740
- export function parseTopicLine(line) {
741
- const m = String(line).match(TOPIC_RE);
742
- if (!m) return null;
743
- const [, indent, box, id, rest] = m;
744
- const body = rest.trim();
745
- // 顺序很重要(2026-09-13 踩坑):先切结论、再抽状态。
746
- // 若颠倒,`… [已否决] — 结论` 的末尾是结论而非状态,会抽不到状态。
747
- // 结论分隔:` — ` / ` -- `(全角/半角破折号、双连字符)
748
- let head = body;
749
- let conclusion = '';
750
- const sep = body.match(/\s(?:—|--)\s/);
751
- if (sep) {
752
- head = body.slice(0, sep.index).trim();
753
- conclusion = body.slice(sep.index + sep[0].length).trim();
754
- }
755
- // 抽作者标记(紧跟 id 后的 [[name]])
756
- let author = null;
757
- const am = head.match(TOPIC_AUTHOR);
758
- if (am) {
759
- author = am[1];
760
- head = head.slice(am[0].length).trim();
761
- }
762
- // 从 head 尾部抽 [状态];只有识别为已知状态名才切除,否则归回标题
763
- let state = null;
764
- const st = head.match(TOPIC_STATE_TAIL);
765
- if (st && TOPIC_STATES.includes(st[1])) {
766
- state = st[1];
767
- head = head.slice(0, st.index).trim();
768
- }
769
- return {
770
- indent: indent.length,
771
- done: box === 'x',
772
- id,
773
- author,
774
- title: head,
775
- state: state || (box === 'x' ? '已结论' : '进行中'),
776
- conclusion,
777
- };
778
- }
779
-
780
- /** 渲染一行话题(与 parseTopicLine 互逆,便于测试)。
781
- * 结论用 ` — ` 分隔(与 parseTopicLine 的 sep 规则对称),否则回读时会把结论
782
- * 误并回标题(先前实测:`#1 … [已否决] 六条…` → 标题被截断、状态被误读)。 */
783
- export function renderTopicLine(t) {
784
- const ind = ' '.repeat(t.indent || 0);
785
- const closed = ['已结论', '已否决', '已落地'].includes(t.state);
786
- const b = closed ? 'x' : t.done ? 'x' : ' ';
787
- const who = t.author ? ` ${`[[${t.author}]]`}` : '';
788
- const head = `${ind}- [${b}] ${t.id}${who} ${t.title} [${t.state}]`;
789
- return t.conclusion ? `${head} — ${t.conclusion}` : head;
790
- }
791
-
792
- /** 取 `## 分区` 区段的行(不含标题)。 */
793
- export function topicLines(text) {
794
- const lines = String(text ?? '').split('\n');
795
- const at = lines.findIndex((l) => l.trim() === `## ${SEC.topics}`);
796
- if (at === -1) return { at: -1, lines: [] };
797
- let end = at + 1;
798
- while (end < lines.length && !/^## /.test(lines[end])) end++;
799
- return { at, lines: lines.slice(at + 1, end) };
800
- }
801
-
802
- /** 幂等登记/更新一个话题。同 id 已存在则原位更新(标题/状态/结论),否则按缩进插入。 */
803
- export function upsertTopicLine(text, { id, title, state, conclusion, indent = 0, author = null }) {
804
- const lines = String(text ?? '').split('\n');
805
- const { at, lines: seg } = topicLines(lines.join('\n'));
806
- const existing = seg.findIndex((l) => {
807
- const t = parseTopicLine(l);
808
- return t && t.id === id;
809
- });
810
- // 作者:优先新值,否则沿用已存在的行(“只改状态”不该把登记人抹掉)
811
- const prev = existing === -1 ? null : parseTopicLine(seg[existing]);
812
- const rendered = renderTopicLine({
813
- id, title, state, conclusion, indent: prev ? prev.indent : indent,
814
- author: author || prev?.author || null,
815
- });
816
- if (at === -1) {
817
- // 无分区 → 补建在**最前**(Topics 是总览,必须先被看到)。
818
- // 坑(2026-09-13 实测):曾插到 `## Done` 之前 → 反而排在 Backlog/Today 之后,
819
- // 与「置顶」意图相反。正确位置 = H1/前言之后的第一个分区位。
820
- let insertAt = 0;
821
- while (insertAt < lines.length && !/^## /.test(lines[insertAt])) insertAt++;
822
- lines.splice(insertAt, 0, `## ${SEC.topics}`, rendered, '');
823
- return { text: lines.join('\n'), changed: ['新增 Topics 区'] };
824
- }
825
- if (existing !== -1) {
826
- const abs = at + 1 + existing;
827
- if (lines[abs] === rendered) return { text, changed: [] };
828
- lines[abs] = rendered;
829
- } else {
830
- let insertAt = at + 1;
831
- while (insertAt < lines.length && !/^## /.test(lines[insertAt])) insertAt++;
832
- // 插到该分区末尾(去掉尾部空行,保持紧凑)
833
- while (insertAt - 1 > at && !lines[insertAt - 1].trim()) insertAt--;
834
- lines.splice(insertAt, 0, rendered);
835
- }
836
- return { text: lines.join('\n'), changed: [`话题 ${id} → ${state}`] };
837
- }
838
-
839
- /** 把话题从 Topics 区**移进**指定分区(默认 Today),保留同一个 id。
840
- *
841
- * 模型根基(2026-09-13 用户定):话题有生命周期,Topics 不是终点而是**入口**。
842
- * 「讨论」与「执行」是**同一件事的两态**,不是两份记录——所以是**移动**(带同 id),
843
- * 不是复制。这样 `#1.2` 从话题变成任务后,仍然能一路追到 Done。
844
- *
845
- * 落单格式用普通任务行(与 todo 同构),以便复用看板/归档/lint 全套;
846
- * 原话题的**结论**捎带过去(丢了就没上下文)。
847
- * 返回新文本;话题不存在则原样返回。 */
848
- export function promoteTopic(text, id, section = SEC.today) {
849
- const src = String(text ?? '');
850
- const nodes = topicTree(src);
851
- const node = nodes.find((n) => n.id === id);
852
- if (!node) return { text: src, changed: [] };
853
- let out = removeTopicLine(src, id);
854
- const lines = out.split('\n');
855
- const at = lines.findIndex((l) => l.trim() === `## ${section}`);
856
- if (at === -1) return { text: src, changed: [] }; // 目标分区不存在 → 不动(不猜)
857
- let end = at + 1;
858
- while (end < lines.length && !/^## /.test(lines[end])) end++;
859
- while (end - 1 > at && !lines[end - 1].trim()) end--;
860
- const who = node.author ? ` [[${node.author}]]` : '';
861
- const tail = node.conclusion ? ` — ${node.conclusion}` : '';
862
- lines.splice(end, 0, `- [ ] ${node.id}${who} ${node.title}${tail}`);
863
- return { text: lines.join('\n'), changed: [`话题 ${id} → ${section}`] };
864
- }
865
-
866
- /** 取话题树(扁平列表 + 父指针)。父由 `└─ 父: #x` 行给出;未显式给出时
867
- * 按 id 的层级默认(`#1.2` 的父是 `#1`)—— 否则 `abs topic new "#1.2 x"` 会白成一个孤根。
868
- * 显式 `└─ 父:` 优先(允许手工把子话题挂到非 id 前缀的父上)。 */
869
- export function topicTree(text) {
870
- const { lines: seg } = topicLines(text);
871
- const out = [];
872
- for (let i = 0; i < seg.length; i++) {
873
- const t = parseTopicLine(seg[i]);
874
- if (!t) continue;
875
- const pm = seg[i + 1]?.match(/^\s*└─\s*父:\s*(#[\w.]+)/);
876
- // 无显式父指针 → 从点分层级推(#a.b.c 的父 = #a.b)
877
- const dot = t.id.lastIndexOf('.');
878
- const implied = dot === -1 ? null : t.id.slice(0, dot);
879
- out.push({ ...t, parent: pm ? pm[1] : implied });
880
- }
881
- return out;
882
- }
883
-
884
- /** 此刻正在推进的那条话题(供 load 首屏「当前话题」一行)。
885
- * 判据:`进行中` 且是**叶子**(没有进行中的子话题)—— 最深的活话题才是当前在说的。
886
- * 无活话题返回 null。
887
- *
888
- * 为什么不打印整棵树(2026-09-13 用户定):整棵树会被 load 反复读取并**带偏后续会话**
889
- * (跟刚删的 Roadmap 同一个病)。当前态要常驻眼前,全貌用 `abs topic` 主动查。 */
890
- export function currentTopic(text) {
891
- const nodes = topicTree(text).filter((n) => n.state === '进行中');
892
- if (!nodes.length) return null;
893
- const hasActiveKid = new Set(
894
- nodes.map((n) => n.parent).filter((p) => p && nodes.some((n) => n.id === p)),
895
- );
896
- return nodes.filter((n) => !hasActiveKid.has(n.id)).pop() || null;
897
- }
898
-
899
- /** 话题树的文本视图:**只输出话题行**,不写任何解释/统计标题。
900
- * 理由(用户定):这一段是状态数据,不是文档 —— 正文说明会被 load 反复打印、
901
- * 挤占上下文,也让人分不清哪些是话题、哪些是说明。注释只留在源码里。
902
- * 已证伪的用 ✘ 标出并在末尾单列一块(防下个会话重走死路)。 */
903
- export function topicTreeText(text) {
904
- const nodes = topicTree(text);
905
- if (!nodes.length) return '';
906
- const byId = new Map(nodes.map((n) => [n.id, n]));
907
- const kids = new Map();
908
- const roots = [];
909
- for (const n of nodes) {
910
- if (n.parent && byId.has(n.parent)) {
911
- if (!kids.has(n.parent)) kids.set(n.parent, []);
912
- kids.get(n.parent).push(n);
913
- } else roots.push(n);
914
- }
915
- const icon = (s) => ({ 进行中: '▶', 已结论: '✔', 已否决: '✘', 待验证: '?', 已落地: '✓', 未落地: '·' }[s] || '·');
916
- const out = [];
917
- const draw = (n, depth) => {
918
- // 格式与存储行一致(单层分隔,不用双空格夹状态),便于人眼与文件对照
919
- out.push(`${' '.repeat(depth)}${icon(n.state)} ${n.id} ${n.title} [${n.state}]${n.conclusion ? ' — ' + n.conclusion : ''}`);
920
- for (const k of kids.get(n.id) || []) draw(k, depth + 1);
921
- };
922
- for (const r of roots) draw(r, 0);
923
- const dead = nodes.filter((n) => n.state === '已否决' && !roots.includes(n) && !kids.has(n.parent));
924
- if (dead.length) {
925
- out.push('');
926
- for (const d of dead) out.push(`✘ ${d.id} ${d.title}${d.conclusion ? ' — ' + d.conclusion : ''}`);
927
- }
928
- return out.join('\n');
929
- }
package/src/wrapup.js CHANGED
@@ -51,6 +51,10 @@ export function extractOpenTasks(todoText) {
51
51
  const m = l.match(/^\s*- \[ \]\s*(.*)$/);
52
52
  if (m) {
53
53
  const taskBody = m[1]
54
+ // 先剥行首状态标记(`[进行中] <id> …`)—— 它是**易变**的:任务状态一改
55
+ // (abs todo state)body 就变,而 strandedFor 用 body 精确比对 →
56
+ // 跨会话滞留提醒会静默消失(2026-09-13 实测复现)。body 必须状态无关。
57
+ .replace(/^\[[^\]]+\]\s+/, '')
54
58
  .replace(/\s*\(认领[^)]*\)\s*$/, '')
55
59
  .replace(/\s*\(完成[^)]*\)\s*$/, '')
56
60
  .trim();