@fanchao8609/agent_brain_sync 1.7.7 → 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
@@ -59,12 +59,12 @@ abs init # 建 .brain/ 图谱,只需一次
59
59
  ### 日常命令
60
60
 
61
61
  ```bash
62
- abs load # 开机读状态(路线 + 看板 + 最近流水)
63
- abs todo # 看板 Today / In Progress / Blocked / Done
62
+ abs load # 开机读状态(Rules + 图谱计数 + 看板 + 最近流水)
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/
@@ -83,7 +83,7 @@ abs update # 升级到最新版并刷新四宿主
83
83
  > `abs todo start` 与 `abs todo add` 等价(都登记任务)。
84
84
  > 旧版 `abs task ...` / `abs board` 已改名,会报错并提示新写法。
85
85
  > `abs wrapup` / `abs teardown-check` 是 hook 内部命令,无需手动调用。
86
- > **升级后分区名自动归一**:`abs load` 每次都会顺手核对 `index/log/todo` 三文件结构,旧的英文/中文分区名(如 `## 当前路线 (Roadmap)` → `## Roadmap`、`# 🗂 图谱索引` → `# 🗂 Graph Index`)会被自动改回标准;缺分区自动补建,无头文件只提醒不自动改。
86
+ > **升级后分区名自动归一**:`abs load` 每次都会顺手核对 `index/log/todo` 三文件结构,旧的分区名(如 `# 🗂 图谱索引` → `# 🗂 Graph Index`)会被自动改回标准;缺分区自动补建,无头文件只提醒不自动改。
87
87
 
88
88
  ### 工作流
89
89
 
package/bin/abs.js CHANGED
@@ -73,6 +73,7 @@ const FLAG_SPEC = {
73
73
  'as': { type: 'string' },
74
74
  'payload': { type: 'string' },
75
75
  'tags': { type: 'string' },
76
+ 'state': { type: 'string' },
76
77
  'keep-days': { type: 'string' },
77
78
  'help': { type: 'boolean' },
78
79
  'dry-run': { type: 'boolean' },
@@ -142,6 +143,7 @@ function parseArgv(args) {
142
143
  section: values.section,
143
144
  note: values.note,
144
145
  as: values.as,
146
+ state: values.state,
145
147
  payload: values.payload,
146
148
  tags: values.tags,
147
149
  help: !!values.help,
@@ -165,7 +167,7 @@ const usage = `abs — agent-brain-sync 记忆工具
165
167
 
166
168
  读:
167
169
  abs load 开机读状态 (index/todo/log)
168
- abs todo 任务看板 Today / In Progress / Blocked / Done
170
+ abs todo 任务看板 Todo / Done(未完成的都在这,行首带状态)
169
171
  abs index 图谱索引 index.md
170
172
  abs log 流水 log.md
171
173
  abs status 当前项目 + 图谱概要
@@ -173,7 +175,7 @@ const usage = `abs — agent-brain-sync 记忆工具
173
175
  写:
174
176
  abs todo add <id> [--note ..] [--section ..] 登记任务 (start 同义)
175
177
  abs todo note <id> --note "断点/进度" 实时落 ↳ 断点 行
176
- abs todo blocked <id> --note "卡点原因" 移入 Blocked 区
178
+ abs todo state <id> --note "进行中|讨论中|滞留中" 改状态标记(原地,不搬区)
177
179
  abs todo done <id> [--as 落地|否决|仅方案] 完成;结语标明到底"做成了没有"
178
180
  默认 落地。否决=评估后不做(含做了又撤);仅方案=只设计过
179
181
  不加结语或结语失真会让下一个会话把"想过"当成"做完了"。
@@ -267,7 +269,7 @@ const TODO_ACTIONS = {
267
269
  add: 'start',
268
270
  start: 'start', // add 的别名(老习惯保留)
269
271
  note: 'note',
270
- blocked: 'blocked',
272
+ state: 'state', // 改行首状态标记:进行中|讨论中|滞留中(原地,不搬区)
271
273
  done: 'done',
272
274
  };
273
275
 
@@ -356,7 +358,7 @@ async function main() {
356
358
  if (!action) {
357
359
  throw new Error(
358
360
  `✗ 未知子命令 "${sub}"\n` +
359
- ` 可用: add / start / note / blocked / done / archive\n` +
361
+ ` 可用: add / start / note / state / done / archive\n` +
360
362
  ` 看板: abs todo(不带参数)\n` +
361
363
  ` 登记任务: abs todo add <id> --note "做什么"`,
362
364
  );
package/bin/mcp.js CHANGED
@@ -93,7 +93,7 @@ tool(
93
93
 
94
94
  tool(
95
95
  'abs_load',
96
- '开机读状态:index 路线 + todo 看板 + 最近 log。跨会话续接的入口。',
96
+ '开机读状态:index 的 Rules + 图谱计数 + todo 看板 + 最近 log。跨会话续接的入口。',
97
97
  { cwd: z.string().describe('项目根目录(.brain/ 所在处)') },
98
98
  async ({ cwd }) => {
99
99
  const root = await findBrainRoot(cwd || process.cwd());
@@ -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,7 +47,7 @@ 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" +
package/hooks/abs.pi.ts CHANGED
@@ -6,6 +6,12 @@
6
6
  * session_shutdown 额外触发 abs wrapup: 把当前项目未完成任务快照到 wrapup.log (跨会话收尾保险)。
7
7
  * agent_end 收尾注入: 本会话真改过文件 + .brain 今日无记录 → 注入一条收尾指令, 逼 agent 走收尾循环
8
8
  * (被动记日志不够 —— 没人提醒就不会有人收尾)。每会话最多一次, 且已收尾后不再打扰。
9
+ *
10
+ * 素材累积 (B+C, 2026-09-13 用户定): 话题在会话中途连续产生, 而收尾提醒只在会话末尾——
11
+ * 用一次性提醒追持续事件 = 记录率 7% (14 条话题只落 1 条)。故在能机械观察的锚点上累积素材:
12
+ * - before_agent_start (用户发话 = 天然话题边界) → 记用户原话
13
+ * - turn_end (每轮结束) → 记改过哪些文件
14
+ * 累积在**内存**(下方 sessionNotes), 收尾时拼进注入提示 —— **不落盘**。
9
15
  */
10
16
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
11
17
  import { appendFile, mkdir, readFile, stat } from "node:fs/promises"
@@ -15,6 +21,27 @@ import { spawn } from "node:child_process"
15
21
 
16
22
  const ABS_BIN = "@@ABS_BIN@@"
17
23
 
24
+ // 素材累积锚点:会话内存里累积,收尾时拼进注入提示。**不落盘、不 spawn、不然上下文**。
25
+ // 预算:只留最近几条(收尾回忆只需线索,不需全史)。
26
+ const NOTES_MAX = 12
27
+ const NOTE_LEN = 120
28
+ const sessionNotes: string[] = []
29
+
30
+ function addNote(kind: string, text: string): void {
31
+ const clean = String(text ?? "").replace(/\s+/g, " ").trim().slice(0, NOTE_LEN)
32
+ if (!clean) return
33
+ sessionNotes.push(`${kind}: ${clean}`)
34
+ if (sessionNotes.length > NOTES_MAX) sessionNotes.shift()
35
+ }
36
+
37
+ /** 把本会话素材拼成一段(收尾注入用)。无素材则空字符串。
38
+ * 用途:让 AI 据线索**归纳**话题,而不是凭记忆回想整场对话(那正是 7% 记录率的原因)。 */
39
+ function notesBlock(notes: string[]): string {
40
+ if (!notes.length) return ""
41
+ return " 本会话素材(hook 机械记录,供归纳)\n" +
42
+ notes.map((n) => " · " + n).join("\n") + "\n"
43
+ }
44
+
18
45
  async function logHook(evt: string): Promise<void> {
19
46
  const dir = process.env.ABS_LOG_DIR || join(homedir(), ".abs", "log")
20
47
  const d = new Date()
@@ -73,17 +100,51 @@ async function loggedToday(brain: string): Promise<boolean> {
73
100
  const d = new Date()
74
101
  const pad = (n: number) => String(n).padStart(2, "0")
75
102
  const today = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate())
76
- // 注意: 这里是真实 TS, 不是生成代码的模板字符串。用拼接形式避免反斜杠逃逸歧义。
77
- return new RegExp("^## \\[" + today + " \\d{2}:\\d{2}\\]", "m").test(txt)
103
+ // 只认**工作成果**条目(kind=dev),不能只看“今天有没有行”。
104
+ // 坑(2026-09-13 实测):`abs note` 也写 log.md(kind=note),
105
+ // 旧判据 (^## [今天 HH:MM]) 把“沉淀了一条经验”当成“今天已收尾”→
106
+ // 整天不再提醒 → 会话后半场全部漏登。
107
+ // 行格式: `## [YYYY-MM-DD HH:MM] [[name]] dev | 内容`。
108
+ return new RegExp("^## \\[" + today + " \\d{2}:\\d{2}\\] (?:\\[\\[[^\\]]+\\]\\] )?dev \\|", "m").test(txt)
78
109
  } catch { return false }
79
110
  }
80
111
 
81
112
  export default function absPiHook(pi: ExtensionAPI): void {
82
- pi.on("session_start", () => logHook("session_start").catch(() => {}))
83
-
84
- // 收尾注入: 每个会话最多一次, 避免反复打扰。
113
+ // 收尾注入的节流状态。声明在**外层** + 在 session_start 里重置:
114
+ // 否则同一进程的第二个会话会继承上一个会话的 true,永久不再提醒
115
+ // (2026-09-13 实测:同一天多个会话时后半场全部静默)。
85
116
  let teardownNudged = false
86
117
  let agentEndSeen = false
118
+
119
+ pi.on("session_start", () => {
120
+ teardownNudged = false
121
+ agentEndSeen = false
122
+ return logHook("session_start").catch(() => {})
123
+ })
124
+
125
+ // ---- 素材锚点(不烧上下文、不注入消息、不 spawn、不落盘)----
126
+ // 用户发话 = 天然的话题边界。这是 C 路线:你说的每句话都是一个锚点。
127
+ pi.on("before_agent_start", async (event: any, _ctx: any) => {
128
+ addNote("user", String(event?.prompt || ""))
129
+ })
130
+
131
+ // 每轮结束 = 这轮干了什么(改了哪些文件)。机械事实,供收尾时回忆。
132
+ pi.on("turn_end", async (event: any, _ctx: any) => {
133
+ const files = new Set<string>()
134
+ for (const r of event?.toolResults || []) {
135
+ const name = String(r?.toolName || "")
136
+ if (!WRITE_TOOLS.has(name)) continue
137
+ if (r?.isError) continue
138
+ const f = r?.input?.file_path ?? r?.args?.file_path ?? r?.input?.path ?? r?.args?.path
139
+ if (f) files.add(String(f))
140
+ else if (name === "bash") {
141
+ const cmd = String(r?.input?.command ?? r?.args?.command ?? "")
142
+ if (!READONLY_CMD.test(cmd)) files.add("bash: " + cmd.slice(0, 60))
143
+ }
144
+ }
145
+ if (files.size) addNote("tool", [...files].join(", "))
146
+ })
147
+ // 收尾注入: 每个会话最多一次, 避免反复打扰。
87
148
  pi.on("agent_end", async (event: any, ctx: any) => {
88
149
  if (teardownNudged) return
89
150
  const cwd = (ctx && ctx.cwd) || process.cwd()
@@ -97,16 +158,23 @@ export default function absPiHook(pi: ExtensionAPI): void {
97
158
  }
98
159
  if (!brain) return // 无图谱=不在这项目沉淀, 不打扰
99
160
  if (!hasWriteWork(event?.messages ? collectToolResults(event.messages) : [])) return
100
- if (await loggedToday(brain)) return // 今天已收尾过
161
+ // 今日已收尾 → 只在**本会话还没真正干事**时才静默。
162
+ // 旧行为:今天 log 有一行就整天闭口 —— 于是收尾之后的产出全部没人提醒。
163
+ // 注意不能用 sessionNotes.length 当判据:用户每说一句话就会 push 一条,
164
+ // 那会让 nudge 每轮都触发(噪音)。判据保持「今天已收尾」但配合下面的笔记消费。
165
+ if (await loggedToday(brain)) return
101
166
  teardownNudged = true
102
167
  await logHook("agent_end:teardown-nudge").catch(() => {})
168
+ // 素材交给 AI 后清空:同一会话再触发时不该重复喂旧料。
169
+ const notes = sessionNotes.splice(0, sessionNotes.length)
103
170
  try {
104
171
  pi.sendUserMessage(
105
172
  "[abs 收尾提醒] 本会话改过文件但 .brain/ 今天还没有记录。请立即走收尾循环:\n" +
106
- "1) 跑 abs load 看 Today 还有哪些未完成;\n" +
173
+ "1) 跑 abs load 看 Todo 还有哪些未完成;\n" +
107
174
  "2) 实际做完漏登记的 abs todo done <id>,做到一半的 abs todo note <id> --note \"断点\";\n" +
108
175
  "3) 值得留的经验 abs note \"...\"(宁少勿滥,能从代码 grep 到的不记);\n" +
109
176
  "4) abs log \"完成 X:...\" 记一行工作成果,新页同步进 index。\n" +
177
+ notesBlock(notes) +
110
178
  "简洁执行,不要复述本条提醒。若本次确实没有可沉淀产出,直接回一句\"无可沉淀\"即可。",
111
179
  { deliverAs: "followUp" },
112
180
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.7.7",
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
@@ -15,7 +15,7 @@ Obsidian 可直接打开的 Markdown 图谱(`.brain/`)做统一落点。
15
15
 
16
16
  ```bash
17
17
  # 读
18
- abs load # 开机读状态(Roadmap + Rules + todo + 最新 log)
18
+ abs load # 开机读状态(Rules + 图谱计数 + todo + 最新 log)
19
19
  abs todo # 看板(Done 折成计数;明细 abs todo --full)
20
20
  abs index / abs log # 完整 index.md / log.md
21
21
  abs status # 当前项目 + 图谱概要
@@ -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/
@@ -101,14 +101,13 @@ abs config [set user <名字>] # 使用者姓名(写操作
101
101
 
102
102
  | 分区 | 放什么 | 怎么用 |
103
103
  |---|---|---|
104
- | `## Roadmap` | 方向:已落地 / 下一阶段候选 | **写方向不写版本号**(复述第三方状态必然漂移)。有界的,load 原样展示 |
105
- | `## Rules` | 本项目铁律 | 见下 |
104
+ | `## Rules` | 本项目铁律 | 见下(load 里**原样全量**输出,不折) |
106
105
  | `## Concepts` `## Entities` `## Sources` `## Syntheses` `## Sessions` | 各类页的清单 | 每页一行 `- [[slug]] — 一句话`(`abs note`/建归档页会自动登记),load 里折成计数 |
107
106
 
108
107
  **`## Rules` 区**:铁律清单,`abs load` 每次都全量读(代码里明确不折它)。
109
- - 一句一条;有概念页就用 `[[链接]]` 指过去,**不在此展开**。
108
+ - 一句一条,**不带链接**(链接去概念页自己的「## 关联连接」挂),不展开。
110
109
  - 只有「违反会丢数据 / 静默失效 / 白干活」级才进 —— 普通经验进 `concepts/`。
111
- - 读写:`abs rule` / `abs rule add "一句话"`(>120 字符被拒);`abs lint` 超 30 条会报。
110
+ - 读写:`abs rule` / `abs rule add "一句话"`(>42 字符被拒;也不接受 `[[链接]]`/URL);`abs lint` 超 30 条会报。
112
111
 
113
112
  ### `log.md` —— 工作成果流水
114
113
 
@@ -117,13 +116,15 @@ load 只展示最新 5 条、每条按语义边界收口。
117
116
 
118
117
  ### `todo.md` —— 活看板(进度唯一真源)
119
118
 
119
+ **只有两区**(2026-09-13 精简):未完成的一切都进 `## Todo`,状态用**行首标记**表达。
120
+
120
121
  | 分区 | 放什么 |
121
122
  |---|---|
122
- | `## Backlog` | 想做但没开工 |
123
- | `## Today / In Progress` | 正在做 |
124
- | `## Blocked` | 卡住(附原因,`abs todo blocked`) |
123
+ | `## Todo` | 未完成的一切。行首 `[进行中]` / `[讨论中]` / `[滞留中]`(`abs todo state`) |
125
124
  | `## Done` | 已完成,**必须带结语 `【落地/否决/仅方案】` + `(完成 YYYY-MM-DD)`** |
126
125
 
126
+ 行形态:`- [ ] [进行中] <id> [[name]] — 说明 (认领 YYYY-MM-DD)`
127
+
127
128
  `abs todo done <id>` 会勾选并归位到 Done 的日期组顶部,断点(`↳` 行)随迁;
128
129
  Done 区由 `abs wrapup` 在会话结束时自动把「超 3 天且整天都已完成」的组迁到 `sessions/<日期>-todo归档.md`
129
130
  (任一天有未完成则整天不迁)。**什么时候动它见「进行中」一节。**
@@ -153,7 +154,7 @@ Done 区由 `abs wrapup` 在会话结束时自动把「超 3 天且整天都已
153
154
 
154
155
  图谱已存在;收到第一个核心开发指令**之前**走这条链载入上下文:
155
156
 
156
- 1. **读状态**:`abs load`(或 MCP `abs_load`)读 index 路线 + Rules + todo 看板 + 最近 log。
157
+ 1. **读状态**:`abs load`(或 MCP `abs_load`)读 index 的 Rules + 图谱计数 + todo 看板 + 最近 log。
157
158
  > **开工前先看 `## Rules`** —— 那是本项目踩过坑后定下的硬规则,每条都是曾经付过代价的。
158
159
  > 违反的代价一般是丢数据/静默失效/白干活,而它就在 load 输出里,没有理由不看。
159
160
  >
@@ -162,7 +163,7 @@ Done 区由 `abs wrapup` 在会话结束时自动把「超 3 天且整天都已
162
163
  > - **index 页面清单** → 各分区只给页数(concept 清单占 load 输出 64%,隨图谱线性增长)
163
164
  > - 「最近动作」每条按语义边界收口到 220 字符
164
165
  >
165
- > **路线(Roadmap) 与 Rules 两区原样保留** —— 那是 load 要传达的状态本身(代码里明确不折)。
166
+ > **`## Rules` 区原样保留** —— 那是 load 要传达的状态本身(代码里明确不折)。
166
167
  > 要全量明细:`abs todo --full` / `abs index`,或直接读 `.brain/` 文件、
167
168
  > `.brain/sessions/<日期>-todo归档.md`。
168
169
  2. **对账滞留(强制,别跳过)**:若 `abs load` 顶部出现 `⏳ 上会话滞留`,说明上会话有任务做完/做到一半就断了。**先收尾再开工**:
@@ -180,12 +181,24 @@ Done 区由 `abs wrapup` 在会话结束时自动把「超 3 天且整天都已
180
181
 
181
182
  **todo 不是收尾仪式,是随改随写的活看板。** 每个任务边界立即更新,与 git commit 同反射。
182
183
 
183
- | 时机 | 动作 |
184
- |---|---|
185
- | 认领新任务 | `abs todo add <id> --note "做什么"` |
186
- | 子任务做完 | `abs todo done <id>` |
187
- | 碰壁/阻塞 | `abs todo blocked <id> --note "卡点原因"` |
188
- | 被打断/干到一半 | `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
+ **核心:事件发生的那一刻就落,别攒到收尾。** 动作一变,扫一眼属于哪行,调对应工具。
189
202
 
190
203
  **经验刚冒出来就落**:`abs note "一句话经验" --tags 坑,docker` → 暂存 `sources/`(幂等去重)。宁少勿滥。
191
204
 
@@ -209,15 +222,15 @@ Done 区由 `abs wrapup` 在会话结束时自动把「超 3 天且整天都已
209
222
 
210
223
  收到 Stop / "结束/先这样/切别的事" / 长任务告一段落,立即执行:
211
224
 
212
- 1. **读 todo** → `abs load`,看 Today 还有哪些没完成。
225
+ 1. **读 todo** → `abs load`,看 Todo 还有哪些没完成。
213
226
  2. **判有没有做完没登记** → 实际完成了漏登记的 `abs todo done <id>`;做到一半补
214
- `abs todo note <id> --note 断点`;碰壁 `abs todo blocked`。别让干完的事还停 Today。
227
+ `abs todo note <id> --note 断点`;卡住的 `abs todo state <id> --note 滞留中`。别让干完的事还留在 Todo。
215
228
  3. **沉淀经验(该沉淀才沉淀)** → 踩了值得记的坑/有可复用技巧/跨会话判断 → `abs note`
216
229
  暂存;值得深提炼的(规律/坑/决策)按 Teardown 走完整流程。
217
230
  4. **更新 index/log/todo** → 新页同步进 index;`log.md` 倒序记一行**工作成果**摘要
218
231
  (`abs log "完成 X:..."`,不是工具动作);todo 对账。
219
232
  **新规律是「违反会丢数据/静默失效/白干活」级别 → 往 index 的 `## Rules` 加一行**
220
- (短句 + `[[概念页]]`,不展开)。普通经验不进 Rules —— 否则会长成第二份概念库。
233
+ (短句,不带链接;链接去概念页挂)。普通经验不进 Rules —— 否则会长成第二份概念库。
221
234
  跑 `abs lint` 确认自洽。
222
235
 
223
236
  **完成标准**:看板反映真实状态(Done 无滞留半成品)、该沉淀已落、index/log/todo 与事实一致。
@@ -229,7 +242,7 @@ Done 区由 `abs wrapup` 在会话结束时自动把「超 3 天且整天都已
229
242
  1. **暂存线索**:`abs note`(或建 `sources/YYYY-MM-DD-slug.md`)记做了什么、改哪些文件、验证命令。
230
243
  2. **抽规律**:值得留的 → `concepts/<kebab-slug>.md`:触发场景/❌表现/🛠根因+解法+验证。挂双链。
231
244
  3. **沉淀实体**:碰了重要未记录的事物 → `entities/<TitleCase>.md`。
232
- 4. **对账 todo**:滞留 Today 归位(Done 标日期 / Backlog 补断点);遗留 bug 写 Backlog/Blocked。
245
+ 4. **对账 todo**:做完的归位 Done(标结语+日期),做一半的补断点,卡住的改 `[滞留中]`。
233
246
  5. **综合(可选)**:推进了选型/取舍 → `syntheses/`。
234
247
  6. **收拢 sources**:提炼成规律的删 source,**同步清指向它的引用**(防死链)。
235
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 } 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
 
@@ -112,10 +112,12 @@ export function indexTemplate() {
112
112
  return rebuildStructure(
113
113
  ['# 🗂 Graph Index', '',
114
114
  '本文件唯一入口。每新建/大改一页,同步在此分类下加一行 [[页面名]] — 一句话。', '',
115
- '## Roadmap', '## Rules', '## Concepts', '## Entities', '## Sources', '## Syntheses', '## Sessions'].join('\n'),
115
+ '## Rules', '## Concepts', '## Entities', '## Sources', '## Syntheses', '## Sessions'].join('\n'),
116
116
  {
117
117
  h1: '# 🗂 Graph Index',
118
- order: ['## Roadmap', '## Rules', '## Concepts', '## Entities', '## Sources', '## Syntheses', '## Sessions'],
118
+ // 无 `## Roadmap`:它是 AI 自己写的方向总结,会被 load 反复读到并带偏后续会话
119
+ // (“看似是总结指引,其实是 AI 的总结指引” —— 2026-09-13 用户决策删除)。
120
+ order: ['## Rules', '## Concepts', '## Entities', '## Sources', '## Syntheses', '## Sessions'],
119
121
  },
120
122
  ).text;
121
123
  }
@@ -131,7 +133,7 @@ export function logTemplate() {
131
133
  export const BRAIN_SHAPE = {
132
134
  'todo.md': {
133
135
  h1: '# 📋 Todo Board',
134
- order: ['## Backlog', '## Today / In Progress', '## Blocked', '## Done'],
136
+ order: ['## Todo', '## Done'],
135
137
  },
136
138
  'log.md': {
137
139
  h1: '# 🗒 Activity Log',
@@ -139,7 +141,7 @@ export const BRAIN_SHAPE = {
139
141
  },
140
142
  'index.md': {
141
143
  h1: '# 🗂 Graph Index',
142
- order: ['## Roadmap', '## Rules', '## Concepts', '## Entities', '## Sources', '## Syntheses', '## Sessions'],
144
+ order: ['## Rules', '## Concepts', '## Entities', '## Sources', '## Syntheses', '## Sessions'],
143
145
  },
144
146
  };
145
147
 
@@ -151,7 +153,13 @@ export const LEGACY_MARKS = [
151
153
  ['# 操作日志', '# 🗒 Activity Log'],
152
154
  ['# 图谱索引', '# 🗂 Graph Index'],
153
155
  ['# Todo 看板', '# 📋 Todo Board'],
154
- ['## 当前路线 (Roadmap)', '## Roadmap'],
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'],
155
163
  ['## Done(只留近期,旧的迁 log.md/快照)', '## Done'],
156
164
  ['### 归档', '### Archived'],
157
165
  ['### (未标日期)', '### Undated'],
@@ -203,9 +211,24 @@ export async function checkBrainShape(root) {
203
211
  let changed = [];
204
212
  await editFile(p, (cur) => {
205
213
  if (cur === null) return SKIP;
206
- const r = spec.order.length
207
- ? rebuildStructure(cur, { ...spec, renames: LEGACY_MARKS.filter(([o]) => o.startsWith('## ') || o.startsWith('### ')) })
208
- : 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 ? [] : ['结构按标准重排'] };
209
232
  if (!r.changed.length) return SKIP;
210
233
  changed = r.changed;
211
234
  return { text: r.text };
@@ -281,9 +304,9 @@ export async function cmdLoad({ dir }) {
281
304
  // Rules 单独成段且放在最前(仅次于项目行/滞留):它是硬规则,不是普通清单。
282
305
  // 坑: 曾在 index 段内与页面清单平铺 —— AI 会当普通清单划过,而它每条都是付过代价的。
283
306
  ...(rulesSection(index) ? [rulesSection(index), ''] : []),
284
- '--- Roadmap (index.md) ---',
285
307
  collapseIndex(index) || '(index.md 为空)',
286
308
  '',
309
+ // 两区制:Todo(未完成,行首带状态标记)+ Done。
287
310
  '--- Todo Board (todo.md) ---',
288
311
  collapseDone(todo).text || '(todo.md 为空)',
289
312
  '\n(Done 已按日期折叠计数;明细: abs todo --full)',
@@ -331,7 +354,7 @@ function rulesSection(indexText) {
331
354
  // 有引言句(> 开头)时一并带上,它解释了这个区是干什么的。
332
355
  const intro = body.split('\n').filter((l) => l.trim().startsWith('>')).join('\n');
333
356
  return [
334
- `--- Rules (硬规则,先读) — ${items.length} 条 ---`,
357
+ `--- Rules (硬规则,先读, index.md) — ${items.length} 条 ---`,
335
358
  intro,
336
359
  ...items,
337
360
  ].filter((x) => x !== '').join('\n');
@@ -360,9 +383,16 @@ export async function cmdRule({ dir, action, text }) {
360
383
  }
361
384
  const clean = String(text || '').replace(/\s+/g, ' ').trim();
362
385
  if (!clean) return '用法: abs rule add "一句话硬规则"';
363
- // 门槛:一句话说完。太长说明该写概念页,Rules 只放指针。
364
- if (clean.length > 120) {
365
- 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
+ + ' 要挂概念页,去概念页自己的「## 关联连接」里挂。';
366
396
  }
367
397
  let added = null;
368
398
  await editFile(p, (cur) => {
@@ -393,8 +423,7 @@ async function readFileOrNull(p) {
393
423
  try { return await fs.readFile(p, 'utf8'); } catch { return ''; }
394
424
  }
395
425
 
396
- /** index.md 在 `abs load` 里的折叠形态:保留「当前路线」(那是 load 要传达的状态,
397
- * 且有界),把页面清单各分区折成计数。
426
+ /** index.md 在 `abs load` 里的折叠形态:把页面清单各分区折成计数。
398
427
  *
399
428
  * 坑: index 的 concept 清单带每一页的一句话描述,**隨图谱线性增长** —— 本仓库 17 条
400
429
  * 占 load 输出 2286/3571 tok(64%),另一台 40 条的项目约 2.3 倍。它刚成了 Done 之后
@@ -402,37 +431,38 @@ async function readFileOrNull(p) {
402
431
  *
403
432
  * 续接真正需要的只是「有哪些分区、各多少页」(据此知道去哪找),不需要每页写了什么 ——
404
433
  * 要那个用 `abs index`(或直接读 index.md / 按词 `abs query`)。
405
- * `## 当前路线` 不折(它是路线内容本身,非清单)。 */
434
+ *
435
+ * `## Rules` 也已不在本函数输出——它由 rulesSection 在**上方**单独成段(需要正文);
436
+ * 这里再原样吐一遍 = 同一段硬规则在首屏出现两次(2026-09-13 实测发现)。
437
+ * 曾另有 `## Roadmap`:AI 自己写的方向总结,会被反复读到并带偏会话,已删。 */
406
438
  export function collapseIndex(text) {
407
439
  const s = String(text || '').trim();
408
440
  if (!s) return '';
409
441
  const out = [];
410
- let mode = null; // null=逐行透传(路线区/文件头);字符串=当前在计数的分区名
442
+ let mode = null; // null=逐行透传(文件头);字符串=当前在计数的分区名
411
443
  let n = 0; // mode 非 null 时的清单行计数
412
444
  const flush = () => {
413
445
  if (mode !== null) out.push(n ? `## ${mode}(${n} 页)` : `## ${mode}`);
414
446
  mode = null;
415
447
  n = 0;
416
448
  };
449
+ let skipping = false; // 跳过 Rules 正文(已在上方单独成段)
417
450
  for (const l of s.split('\n')) {
418
451
  const m = l.match(/^##\s+(.+?)\s*$/);
419
452
  if (m) {
420
453
  flush();
421
454
  const name = m[1].trim();
422
- // 「Roadmap」与「Rules」都是**内容**不是清单:原样保留。
423
- // Rules 区尤其不能折 —— 它的全部价值就是被读到;折成"(N 页)"等于把它静默删掉。
424
- if (/路线|Roadmap|Rules?|规则/i.test(name)) {
425
- out.push(l);
426
- mode = null;
427
- } else {
428
- mode = name; // 页面清单分区:只计数
429
- }
455
+ skipping = /Rules?|规则/i.test(name);
456
+ if (skipping) continue;
457
+ mode = name; // 页面清单分区:只计数
430
458
  continue;
431
459
  }
432
- if (mode === null) out.push(l); // 透传区(含文件头 H1、路线区正文)
460
+ if (skipping) continue;
461
+ if (mode === null) out.push(l); // 透传区(含文件头 H1)
433
462
  else if (l.trim().startsWith('-')) n++;
434
463
  }
435
464
  flush();
465
+ // 文件头与首个分区之间可能因跳过 Rules 而留下多余空行
436
466
  return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
437
467
  }
438
468
 
@@ -572,7 +602,7 @@ export async function cmdTeardownCheck({ dir, payload }) {
572
602
  '0) 本机尚未设置使用者姓名 —— 先跑 abs config set user <你的名字>,否则下面 2/3/4 都会被拦下;',
573
603
  ]),
574
604
  '[abs 收尾提醒] 本会话改过文件但 .brain/ 今天还没有记录。请立即走收尾循环:',
575
- '1) 跑 abs load 看 Today 还有哪些未完成;',
605
+ '1) 跑 abs load 看 Todo 还有哪些未完成;',
576
606
  '2) 实际做完漏登记的 abs todo done <id>,做到一半的 abs todo note <id> --note "断点";',
577
607
  '3) 值得留的经验 abs note "..."(宁少勿滥,能从代码 grep 到的不记);',
578
608
  '4) abs log "完成 X:..." 记一行工作成果,新页同步进 index。',
@@ -669,15 +699,29 @@ export async function cmdTask({ dir, action, id, section, note, as }) {
669
699
  const who = await requireUser();
670
700
  await ensurePersonPage(root, who); // 首次写操作即建人页(已存在不动)
671
701
  if (action === 'start') {
702
+ // 两区制:未完成一律进 Todo,行首带状态标记(默认 进行中)。
703
+ const st = section && TASK_STATES.includes(section) ? section : '进行中';
672
704
  const r = await upsertTask(root, {
673
- section: section || 'Today / In Progress',
674
- text: `${id} ${atTag(who)}${note ? ' — ' + note : ''} (认领 ${today()})`,
705
+ section: SEC.todo,
706
+ text: `[${st}] ${id} ${atTag(who)}${note ? ' — ' + note : ''} (认领 ${today()})`,
675
707
  });
676
- 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 : ''}`;
677
709
  }
678
- if (action === 'blocked') {
679
- const r = await moveBlocked(root, { id, reason: note });
680
- 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}" 的未完成任务行,或状态未变)`;
681
725
  }
682
726
  if (action === 'note') {
683
727
  if (!note) return '用法: abs todo note <id> --note "断点/进度"(实时落 ↳ 断点 行)';
@@ -708,7 +752,7 @@ async function markDone(file, id, kind = '落地') {
708
752
  if (!moved && idOfTaskLine(l) === wantId) {
709
753
  changed = true;
710
754
  const head = withDoneKind(
711
- l.replace('- [ ]', '- [x]').replace(/\(认领[^)]*\)/, '') + ` (完成 ${today()})`,
755
+ stripStateMark(l).replace('- [ ]', '- [x]').replace(/\(认领[^)]*\)/, '') + ` (完成 ${today()})`,
712
756
  kind,
713
757
  );
714
758
  const bp = [];
@@ -1026,12 +1070,12 @@ export async function cmdLint({ dir }) {
1026
1070
  const idxTxt = await readFileOrNull(join(vault, 'index.md'));
1027
1071
  const { items, found } = readRules(idxTxt);
1028
1072
  if (found && items.length > RULES_MAX) {
1029
- issues.push(`RULES-PILED-UP: Rules 区 ${items.length} 条 > ${RULES_MAX};把长条目提炼成概念页,这里只留一句 + 链接`);
1073
+ issues.push(`RULES-PILED-UP: Rules 区 ${items.length} 条 > ${RULES_MAX};把长条目提炼成概念页,这里只留一句话`);
1030
1074
  }
1031
1075
  // 该区是 load 必读的硬规则清单,条目却写得像段落 → 提醒改短句。
1032
1076
  const longOnes = items.filter((l) => l.trim().length > 160);
1033
1077
  if (longOnes.length) {
1034
- 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)}");展开写进概念页,这里只留一句话(不带链接)`);
1035
1079
  }
1036
1080
  }
1037
1081
 
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,9 +48,7 @@ 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
53
  archived: 'Archived', // Done 区内部的归档标记区(原 '### 归档')
56
54
  undated: 'Undated', // Done 区内部无完成日期的尾组(原 '### (未标日期)')
@@ -58,13 +56,18 @@ export const SEC = {
58
56
 
59
57
  /** 旧名 → 新名。供 `abs init --repair` 一次性迁移(幂等)。
60
58
  * 只改匹配整行的标题,不动正文;不做模糊替换(防误改正文里提到的旧名)。 */
59
+ // 分区标题归一(旧名 → 标准名)
61
60
  export const LEGACY_SECTION_RENAMES = [
62
61
  // H1(文件标题)
63
62
  ['# 🗂 图谱索引', '# 🗂 Graph Index'],
64
63
  ['# 🗒 操作日志', '# 🗒 Activity Log'],
65
64
  ['# 📋 Todo 看板', '# 📋 Todo Board'],
66
65
  // ## 分区
67
- ['## 当前路线 (Roadmap)', '## Roadmap'],
66
+ // 四区 → 两区(2026-09-13 用户定:Backlog/Today 常年为空,进行时分区与实际工作流错配)。
67
+ // 未完成的一律归 Todo,状态改由**行首标记**表达(见 TASK_STATES)。
68
+ ['## Backlog', '## Todo'],
69
+ ['## Today / In Progress', '## Todo'],
70
+ ['## Blocked', '## Todo'],
68
71
  ['## Done(只留近期,旧的迁 log.md/快照)', '## Done'],
69
72
  // ### 区内分组标题
70
73
  ['### 归档', '### Archived'],
@@ -89,15 +92,27 @@ export function renameLegacySections(text) {
89
92
  export function todoTemplate() {
90
93
  // 由 rebuildStructure 生成,保证“模板”与“重排结果”逐字节一致
91
94
  // (否则 load 会把新建的模板又重排一次 = 无意义的写盘)。
95
+ // 注意(2026-09-13 踩坑):rebuildStructure 的 order 要**带 `## ` 前缀**,
96
+ // 而 TODO_SECTIONS 是裸名(两者用途不同,不能复用 —— 曾误传裸名导致
97
+ // 生成出没有 `##` 的裸标题行,模板直接损坏、Backlog 分区消失)。
92
98
  return rebuildStructure(
93
- ['# 📋 Todo Board', '## Backlog', '- [ ] 待办任务', '## Today / In Progress', '## Blocked', '## Done'].join('\n'),
94
- { h1: '# 📋 Todo Board', order: ['## Backlog', '## Today / In Progress', '## Blocked', '## Done'] },
99
+ ['# 📋 Todo Board', ...TODO_SECTIONS.map((s) => `## ${s}`)].join('\n'),
100
+ { h1: '# 📋 Todo Board', order: TODO_SECTIONS.map((s) => `## ${s}`) },
95
101
  ).text;
96
102
  }
97
103
 
98
- /** 归一化 todo.md 分区:老格式(In Progress/Todo)迁移为 B4 定稿格式(Backlog→Today / In Progress→Blocked→Done)。
99
- * 幂等:已是新格式则原样返回。迁移原则——老 "In Progress" 内容进 "Today / In Progress",老 "Todo" 内容进 "Backlog"。 */
100
- export const TODO_SECTIONS = ['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 = ['进行中', '讨论中', '滞留中'];
101
116
 
102
117
  /** 结构重建(B 档):以标准分区表为准重排整个文件。
103
118
  *
@@ -171,8 +186,11 @@ function trimBlank(arr) {
171
186
  export function normalizeTodo(text) {
172
187
  const lines = text.split('\n');
173
188
  const has = (name) => lines.some((l) => l.trim() === `## ${name}`);
174
- if (has('Backlog') || has('Today / In Progress')) return text; // 已是新格式
175
- 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;
176
194
  const out = ['# 📋 Todo Board'];
177
195
  const grab = (name) => {
178
196
  const items = [];
@@ -184,16 +202,50 @@ export function normalizeTodo(text) {
184
202
  return items;
185
203
  };
186
204
  const done = grab('Done');
187
- const blocked = grab('Blocked');
188
- const inprog = grab('In Progress');
189
- const todo = grab('Todo');
190
- out.push('## Backlog', ...todo.length ? todo : []);
191
- out.push('## Today / In Progress', ...inprog.length ? inprog : []);
192
- out.push('## Blocked', ...blocked.length ? blocked : []);
193
- 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);
194
214
  return out.join('\n');
195
215
  }
196
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
+
197
249
  /** 从已完成任务行提取 `(完成 YYYY-MM-DD)` 日期;无则返回 ''。兼容中英文括号。 */
198
250
  export function doneDateOf(line) {
199
251
  const m = String(line).match(/\(完成\s*(\d{4}-\d{2}-\d{2})[^)]*\)/);
@@ -483,7 +535,7 @@ export function insertDoneGrouped(text, movedLines) {
483
535
  const di = lines.findIndex((l) => l.startsWith('## Done'));
484
536
  const newUnit = { date, lines: movedLines };
485
537
  if (di === -1) {
486
- const header = '## Done(只留近期,旧的迁 log.md/快照)';
538
+ const header = `## ${SEC.done}`;
487
539
  return [...lines, '', header, '', ...renderDoneGroups([newUnit])].join('\n').replace(/\n+$/, '\n');
488
540
  }
489
541
  const head = lines.slice(0, di + 1);
@@ -525,12 +577,6 @@ function insertTask(orig, section, text) {
525
577
  return lines.join('\n');
526
578
  }
527
579
 
528
- /** 任务行解析:`- [ ] <id> — note (认领 date)` / 附属断点行 `↳ 断点:`。 */
529
- export function parseTaskLine(line) {
530
- const m = line.match(/^- \[( |x)\] (.*?)(?: — (.*?))? \(?(认领|完成 \d{4}-\d{2}-\d{2}[^)]*)?\)?$/);
531
- return m; // 保守解析;不匹配返回 null(附属行等)
532
- }
533
-
534
580
  /** 幂等登记:同 id 已有未完成任务行则原位更新(新 note/新认领日期),否则插入。 */
535
581
  export async function upsertTask(brainRoot, { section, text }) {
536
582
  const p = await ensureTodo(brainRoot);
@@ -541,16 +587,21 @@ export async function upsertTask(brainRoot, { section, text }) {
541
587
  const lines = base.split('\n');
542
588
  // 在所有分区中找该 id 对应的未完成任务行(Done 的已完成行不重复动);全等比对
543
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 : '进行中');
544
595
  if (idx !== -1) {
545
- // 原位更新:保留断点附属行 + 原@作者,只换任务行本体
546
- const raw = lines[idx].replace(/^- \[ \] /, '');
596
+ // 原位更新:保留断点附属行 + 原@作者 + 原状态,只换任务行本体
597
+ const raw = stripStateMark(lines[idx]).replace(/^- \[ \] /, '');
547
598
  const author = extractAuthor(raw);
548
599
  const legacy = isLegacyAuthorTag(raw); // 旧行保持旧形态,不静默改写
549
600
  const oldNote = extractNote(raw);
550
601
  const merged = note && oldNote && oldNote.startsWith(note) ? `${note}${oldNote.slice(note.length)}` : note || oldNote;
551
602
  const at = author ? ` ${legacy ? '@' + author : '[['
552
603
  + author + ']]'}` : '';
553
- const newLine = `- [ ] ${id}${at}${merged ? ' — ' + merged : ''} (认领 ${today()})`;
604
+ const newLine = `- [ ] [${state}] ${id}${at}${merged ? ' — ' + merged : ''} (认领 ${today()})`;
554
605
  lines[idx] = newLine;
555
606
  return { text: lines.join('\n'), updated: true };
556
607
  }
@@ -563,7 +614,9 @@ export async function upsertTask(brainRoot, { section, text }) {
563
614
 
564
615
  /** 从任务文本提取幂等键(行首 id,如 TASK-1 / T-2 / fix-hook)。 */
565
616
  export function extractId(text) {
566
- 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/);
567
620
  return m ? m[1] : null;
568
621
  }
569
622
 
@@ -603,7 +656,9 @@ export function isLegacyAuthorTag(text) {
603
656
  */
604
657
  export function idOfTaskLine(line) {
605
658
  if (!line || !line.startsWith('- [ ]')) return null;
606
- const m = line.match(/^- \[ \] (\S+)/);
659
+ // 两区制下行首可带状态标记(`- [ ] [进行中] <id> …`)—— 必须先剥掉再取 id,
660
+ // 否则 id 会被读成 "[进行中]",findTaskLine 全等比对永远落空(2026-09-13 实测)。
661
+ const m = line.match(/^- \[ \] (?:\[[^\]]+\] )?(\S+)/);
607
662
  return m ? m[1].replace(/\u200b/g, '') : null;
608
663
  }
609
664
 
@@ -635,35 +690,6 @@ export async function setBreakpoint(brainRoot, { id, text }) {
635
690
  : { ok: true, msg: `✓ 断点已落 → ${id}\n ${bp.trim()}` };
636
691
  }
637
692
 
638
- /** 实时碰壁: 任务行原位勾成 blocked 语义(移入 Blocked 区 + 附原因)。 */
639
- export async function moveBlocked(brainRoot, { id, reason }) {
640
- const p = await ensureTodo(brainRoot);
641
- const res = await editFile(p, (orig) => {
642
- const lines = orig.split('\n');
643
- const idx = findTaskLine(lines, id);
644
- if (idx === -1) return SKIP;
645
- const taskLine = lines[idx];
646
- const kept = [];
647
- const moved = [taskLine];
648
- for (let i = 0; i < lines.length; i++) {
649
- if (i === idx) {
650
- while (i + 1 < lines.length && lines[i + 1].trimStart().startsWith('↳')) moved.push(lines[++i]);
651
- continue;
652
- }
653
- kept.push(lines[i]);
654
- }
655
- const bIdx = kept.findIndex((l) => l.startsWith('## Blocked'));
656
- if (reason) moved.push(` ↳ 卡点: ${reason}`);
657
- const out = bIdx === -1
658
- ? [...kept, '## Blocked', ...moved]
659
- : [...kept.slice(0, bIdx + 1), ...moved, ...kept.slice(bIdx + 1)];
660
- return { text: out.join('\n'), ok: true };
661
- });
662
- return res === SKIP
663
- ? { ok: false, msg: `(未找到含 "${id}" 的未完成任务行)` }
664
- : { ok: true, msg: `✓ 已标阻塞 → Blocked 区: ${id}${reason ? `\n 卡点: ${reason}` : ''}` };
665
- }
666
-
667
693
  // ---------- 看板输出 ----------
668
694
  export async function boardText(brainRoot, textOverride, { full = false } = {}) {
669
695
  const text = textOverride !== undefined ? textOverride : await readTodo(brainRoot);
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();