@fanchao8609/agent_brain_sync 1.7.7 → 1.7.8

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,7 +59,7 @@ abs init # 建 .brain/ 图谱,只需一次
59
59
  ### 日常命令
60
60
 
61
61
  ```bash
62
- abs load # 开机读状态(路线 + 看板 + 最近流水)
62
+ abs load # 开机读状态(Rules + 图谱计数 + 看板 + 最近流水)
63
63
  abs todo # 看板 Today / In Progress / Blocked / Done
64
64
 
65
65
  abs todo add TASK-1 --note "要做什么"
@@ -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
@@ -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 } from '../src/store.js';
5
+ import { cmdInit, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdShow, cmdRepair, cmdWrapup, cmdRule, cmdTeardownCheck, cmdTodoArchive, cmdTopic } 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';
@@ -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,8 @@ 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 任务看板 Topics / Backlog / Today / Blocked / Done
171
+ abs topic 话题树(讨论轨道,含已证伪);无参=查看
169
172
  abs index 图谱索引 index.md
170
173
  abs log 流水 log.md
171
174
  abs status 当前项目 + 图谱概要
@@ -174,6 +177,10 @@ const usage = `abs — agent-brain-sync 记忆工具
174
177
  abs todo add <id> [--note ..] [--section ..] 登记任务 (start 同义)
175
178
  abs todo note <id> --note "断点/进度" 实时落 ↳ 断点 行
176
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 追踪,不复制)
177
184
  abs todo done <id> [--as 落地|否决|仅方案] 完成;结语标明到底"做成了没有"
178
185
  默认 落地。否决=评估后不做(含做了又撤);仅方案=只设计过
179
186
  不加结语或结语失真会让下一个会话把"想过"当成"做完了"。
@@ -337,6 +344,28 @@ async function main() {
337
344
  rejectExtra(opts._, 'abs status');
338
345
  console.log(await cmdStatus({ dir: opts.dir }));
339
346
  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
+ }
340
369
  // abs todo —— 无子命令=看板;带子命令=任务写操作
341
370
  case 'todo': {
342
371
  const [sub, id, ...rest2] = opts._;
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());
@@ -51,6 +51,11 @@ const server = async ({ client, directory }) => {
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" +
54
59
  "简洁执行,不要复述本条提醒。若本次确实没有可沉淀产出,直接回一句\"无可沉淀\"即可。"
55
60
 
56
61
  // bash 里只跑查询类命令不算改文件 (与 pi 侧 READONLY_CMD 同义, 但生成代码里要写进模板串)
package/hooks/abs.pi.ts CHANGED
@@ -6,6 +6,13 @@
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), 收尾时拼进注入提示 —— **不落盘**。
15
+ * 用户明确不要新文件(曾做过 .brain/topics.log, 已拆)。
9
16
  */
10
17
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
11
18
  import { appendFile, mkdir, readFile, stat } from "node:fs/promises"
@@ -15,6 +22,27 @@ import { spawn } from "node:child_process"
15
22
 
16
23
  const ABS_BIN = "@@ABS_BIN@@"
17
24
 
25
+ // 素材累积锚点:会话内存里累积,收尾时拼进注入提示。**不落盘、不 spawn、不然上下文**。
26
+ // 预算:只留最近几条(收尾回忆只需线索,不需全史)。
27
+ const NOTES_MAX = 12
28
+ const NOTE_LEN = 120
29
+ const sessionNotes: string[] = []
30
+
31
+ function addNote(kind: string, text: string): void {
32
+ const clean = String(text ?? "").replace(/\s+/g, " ").trim().slice(0, NOTE_LEN)
33
+ if (!clean) return
34
+ sessionNotes.push(`${kind}: ${clean}`)
35
+ if (sessionNotes.length > NOTES_MAX) sessionNotes.shift()
36
+ }
37
+
38
+ /** 把本会话素材拼成一段(收尾注入用)。无素材则空字符串。
39
+ * 用途:让 AI 据线索**归纳**话题,而不是凭记忆回想整场对话(那正是 7% 记录率的原因)。 */
40
+ function notesBlock(notes: string[]): string {
41
+ if (!notes.length) return ""
42
+ return " 本会话素材(hook 机械记录,供归纳)\n" +
43
+ notes.map((n) => " · " + n).join("\n") + "\n"
44
+ }
45
+
18
46
  async function logHook(evt: string): Promise<void> {
19
47
  const dir = process.env.ABS_LOG_DIR || join(homedir(), ".abs", "log")
20
48
  const d = new Date()
@@ -73,17 +101,51 @@ async function loggedToday(brain: string): Promise<boolean> {
73
101
  const d = new Date()
74
102
  const pad = (n: number) => String(n).padStart(2, "0")
75
103
  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)
104
+ // 只认**工作成果**条目(kind=dev),不能只看“今天有没有行”。
105
+ // 坑(2026-09-13 实测):`abs note` 也写 log.md(kind=note),
106
+ // 旧判据 (^## [今天 HH:MM]) 把“沉淀了一条经验”当成“今天已收尾”→
107
+ // 整天不再提醒 → 新冒的话题全部漏登(用户实报“话题还是没进 Topics”)。
108
+ // 行格式: `## [YYYY-MM-DD HH:MM] [[name]] dev | 内容`。
109
+ return new RegExp("^## \\[" + today + " \\d{2}:\\d{2}\\] (?:\\[\\[[^\\]]+\\]\\] )?dev \\|", "m").test(txt)
78
110
  } catch { return false }
79
111
  }
80
112
 
81
113
  export default function absPiHook(pi: ExtensionAPI): void {
82
- pi.on("session_start", () => logHook("session_start").catch(() => {}))
83
-
84
- // 收尾注入: 每个会话最多一次, 避免反复打扰。
114
+ // 收尾注入的节流状态。声明在**外层** + 在 session_start 里重置:
115
+ // 否则同一进程的第二个会话会继承上一个会话的 true,永久不再提醒
116
+ // (2026-09-13 实测:同一天多个会话时后半场全部静默)。
85
117
  let teardownNudged = false
86
118
  let agentEndSeen = false
119
+
120
+ pi.on("session_start", () => {
121
+ teardownNudged = false
122
+ agentEndSeen = false
123
+ return logHook("session_start").catch(() => {})
124
+ })
125
+
126
+ // ---- 素材锚点(不烧上下文、不注入消息、不 spawn、不落盘)----
127
+ // 用户发话 = 天然的话题边界。这是 C 路线:你说的每句话都是一个锚点。
128
+ pi.on("before_agent_start", async (event: any, _ctx: any) => {
129
+ addNote("user", String(event?.prompt || ""))
130
+ })
131
+
132
+ // 每轮结束 = 这轮干了什么(改了哪些文件)。机械事实,供收尾时回忆。
133
+ pi.on("turn_end", async (event: any, _ctx: any) => {
134
+ const files = new Set<string>()
135
+ for (const r of event?.toolResults || []) {
136
+ const name = String(r?.toolName || "")
137
+ if (!WRITE_TOOLS.has(name)) continue
138
+ if (r?.isError) continue
139
+ const f = r?.input?.file_path ?? r?.args?.file_path ?? r?.input?.path ?? r?.args?.path
140
+ if (f) files.add(String(f))
141
+ else if (name === "bash") {
142
+ const cmd = String(r?.input?.command ?? r?.args?.command ?? "")
143
+ if (!READONLY_CMD.test(cmd)) files.add("bash: " + cmd.slice(0, 60))
144
+ }
145
+ }
146
+ if (files.size) addNote("tool", [...files].join(", "))
147
+ })
148
+ // 收尾注入: 每个会话最多一次, 避免反复打扰。
87
149
  pi.on("agent_end", async (event: any, ctx: any) => {
88
150
  if (teardownNudged) return
89
151
  const cwd = (ctx && ctx.cwd) || process.cwd()
@@ -97,9 +159,16 @@ export default function absPiHook(pi: ExtensionAPI): void {
97
159
  }
98
160
  if (!brain) return // 无图谱=不在这项目沉淀, 不打扰
99
161
  if (!hasWriteWork(event?.messages ? collectToolResults(event.messages) : [])) return
100
- if (await loggedToday(brain)) return // 今天已收尾过
162
+ // 今日已收尾 → 只在**本会话还没真正干事**时才静默。
163
+ // 旧行为:今天 log 有一行就整天闭口 —— 于是「收尾过之后新冒的话题」全部没人提醒
164
+ // (2026-09-13 实报:web 版聊了一整轮,Topics 里一条没有)。
165
+ // 注意不能用 sessionNotes.length 当判据:用户每说一句话就会 push 一条,
166
+ // 那会让 nudge 每轮都触发(噪音)。判据保持「今天已收尾」但配合下面的笔记消费。
167
+ if (await loggedToday(brain)) return
101
168
  teardownNudged = true
102
169
  await logHook("agent_end:teardown-nudge").catch(() => {})
170
+ // 素材交给 AI 后清空:同一会话再触发时不该重复喂旧料。
171
+ const notes = sessionNotes.splice(0, sessionNotes.length)
103
172
  try {
104
173
  pi.sendUserMessage(
105
174
  "[abs 收尾提醒] 本会话改过文件但 .brain/ 今天还没有记录。请立即走收尾循环:\n" +
@@ -107,6 +176,14 @@ export default function absPiHook(pi: ExtensionAPI): void {
107
176
  "2) 实际做完漏登记的 abs todo done <id>,做到一半的 abs todo note <id> --note \"断点\";\n" +
108
177
  "3) 值得留的经验 abs note \"...\"(宁少勿滥,能从代码 grep 到的不记);\n" +
109
178
  "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
+ notesBlock(notes) +
110
187
  "简洁执行,不要复述本条提醒。若本次确实没有可沉淀产出,直接回一句\"无可沉淀\"即可。",
111
188
  { deliverAs: "followUp" },
112
189
  )
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.8",
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 # 当前项目 + 图谱概要
@@ -101,8 +101,7 @@ 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` 每次都全量读(代码里明确不折它)。
@@ -153,7 +152,7 @@ Done 区由 `abs wrapup` 在会话结束时自动把「超 3 天且整天都已
153
152
 
154
153
  图谱已存在;收到第一个核心开发指令**之前**走这条链载入上下文:
155
154
 
156
- 1. **读状态**:`abs load`(或 MCP `abs_load`)读 index 路线 + Rules + todo 看板 + 最近 log。
155
+ 1. **读状态**:`abs load`(或 MCP `abs_load`)读 index 的 Rules + 图谱计数 + todo 看板 + 最近 log。
157
156
  > **开工前先看 `## Rules`** —— 那是本项目踩过坑后定下的硬规则,每条都是曾经付过代价的。
158
157
  > 违反的代价一般是丢数据/静默失效/白干活,而它就在 load 输出里,没有理由不看。
159
158
  >
@@ -162,7 +161,7 @@ Done 区由 `abs wrapup` 在会话结束时自动把「超 3 天且整天都已
162
161
  > - **index 页面清单** → 各分区只给页数(concept 清单占 load 输出 64%,隨图谱线性增长)
163
162
  > - 「最近动作」每条按语义边界收口到 220 字符
164
163
  >
165
- > **路线(Roadmap) 与 Rules 两区原样保留** —— 那是 load 要传达的状态本身(代码里明确不折)。
164
+ > **`## Rules` 区原样保留** —— 那是 load 要传达的状态本身(代码里明确不折)。
166
165
  > 要全量明细:`abs todo --full` / `abs index`,或直接读 `.brain/` 文件、
167
166
  > `.brain/sessions/<日期>-todo归档.md`。
168
167
  2. **对账滞留(强制,别跳过)**:若 `abs load` 顶部出现 `⏳ 上会话滞留`,说明上会话有任务做完/做到一半就断了。**先收尾再开工**:
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 { 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';
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,10 @@ 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
+ // 分区(话题树)置顶:讨论轨道先于执行轨道(见 TODO_SECTIONS 注释)
137
+ order: ['## Topics', '## Backlog', '## Today / In Progress', '## Blocked', '## Done'],
138
+ // 旧文件标题归一(已在 todo.js 的 LEGACY_SECTION_RENAMES 里给出,这里引用以免双源漂移)
139
+ renames: [['## Topic', '## Topics'], ['## 话题', '## Topics'], ['## 话题树', '## Topics'], ['## 分区', '## Topics']],
135
140
  },
136
141
  'log.md': {
137
142
  h1: '# 🗒 Activity Log',
@@ -139,7 +144,7 @@ export const BRAIN_SHAPE = {
139
144
  },
140
145
  'index.md': {
141
146
  h1: '# 🗂 Graph Index',
142
- order: ['## Roadmap', '## Rules', '## Concepts', '## Entities', '## Sources', '## Syntheses', '## Sessions'],
147
+ order: ['## Rules', '## Concepts', '## Entities', '## Sources', '## Syntheses', '## Sessions'],
143
148
  },
144
149
  };
145
150
 
@@ -151,7 +156,6 @@ export const LEGACY_MARKS = [
151
156
  ['# 操作日志', '# 🗒 Activity Log'],
152
157
  ['# 图谱索引', '# 🗂 Graph Index'],
153
158
  ['# Todo 看板', '# 📋 Todo Board'],
154
- ['## 当前路线 (Roadmap)', '## Roadmap'],
155
159
  ['## Done(只留近期,旧的迁 log.md/快照)', '## Done'],
156
160
  ['### 归档', '### Archived'],
157
161
  ['### (未标日期)', '### Undated'],
@@ -248,6 +252,29 @@ async function listBrainFiles(root) {
248
252
  }
249
253
 
250
254
  // ---------- 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
+
251
278
  export async function cmdLoad({ dir }) {
252
279
  const root = await requireBrain(dir || process.cwd());
253
280
  // 结构核对先跑:load 每回都要读这三个文件,顺手把它们形状摆正(缺分区)或提个醒(无头)。
@@ -281,11 +308,17 @@ export async function cmdLoad({ dir }) {
281
308
  // Rules 单独成段且放在最前(仅次于项目行/滞留):它是硬规则,不是普通清单。
282
309
  // 坑: 曾在 index 段内与页面清单平铺 —— AI 会当普通清单划过,而它每条都是付过代价的。
283
310
  ...(rulesSection(index) ? [rulesSection(index), ''] : []),
284
- '--- Roadmap (index.md) ---',
285
311
  collapseIndex(index) || '(index.md 为空)',
286
312
  '',
313
+ // 只打「当前话题」一行:整棵树会被 load 反复读并带偏会话(同 Roadmap 之病)。
314
+ // 全貌用 `abs topic` 主动查——它是查看命令,不进开机首屏。
315
+ ...(currentTopic(todo) ? ['--- 当前话题 (todo.md) ---', currentTopicText(todo), ''] : []),
316
+ // 开局三类(2026-09-13 用户澄清):①正在讨论=上面 Topics ②待办/已开工/已收尾=
317
+ // 下面 Board(Done 区就是已结束,折叠只是显示形式——归档才管前几天)。
318
+ // 曾加过「今日已结束」单列段:多余,撤掉(Done 区已表达同一信息)。
287
319
  '--- Todo Board (todo.md) ---',
288
- collapseDone(todo).text || '(todo.md 为空)',
320
+ // 看板里剔掉 Topics 区:它已在上面用树形打印过,原样再贴一遍是纯冗余。
321
+ stripTopics(collapseDone(todo).text) || '(todo.md 为空)',
289
322
  '\n(Done 已按日期折叠计数;明细: abs todo --full)',
290
323
  '',
291
324
  '--- 最近动作 (log.md, 最新 5 条) ---',
@@ -331,7 +364,7 @@ function rulesSection(indexText) {
331
364
  // 有引言句(> 开头)时一并带上,它解释了这个区是干什么的。
332
365
  const intro = body.split('\n').filter((l) => l.trim().startsWith('>')).join('\n');
333
366
  return [
334
- `--- Rules (硬规则,先读) — ${items.length} 条 ---`,
367
+ `--- Rules (硬规则,先读, index.md) — ${items.length} 条 ---`,
335
368
  intro,
336
369
  ...items,
337
370
  ].filter((x) => x !== '').join('\n');
@@ -393,8 +426,7 @@ async function readFileOrNull(p) {
393
426
  try { return await fs.readFile(p, 'utf8'); } catch { return ''; }
394
427
  }
395
428
 
396
- /** index.md 在 `abs load` 里的折叠形态:保留「当前路线」(那是 load 要传达的状态,
397
- * 且有界),把页面清单各分区折成计数。
429
+ /** index.md 在 `abs load` 里的折叠形态:把页面清单各分区折成计数。
398
430
  *
399
431
  * 坑: index 的 concept 清单带每一页的一句话描述,**隨图谱线性增长** —— 本仓库 17 条
400
432
  * 占 load 输出 2286/3571 tok(64%),另一台 40 条的项目约 2.3 倍。它刚成了 Done 之后
@@ -402,37 +434,38 @@ async function readFileOrNull(p) {
402
434
  *
403
435
  * 续接真正需要的只是「有哪些分区、各多少页」(据此知道去哪找),不需要每页写了什么 ——
404
436
  * 要那个用 `abs index`(或直接读 index.md / 按词 `abs query`)。
405
- * `## 当前路线` 不折(它是路线内容本身,非清单)。 */
437
+ *
438
+ * `## Rules` 也已不在本函数输出——它由 rulesSection 在**上方**单独成段(需要正文);
439
+ * 这里再原样吐一遍 = 同一段硬规则在首屏出现两次(2026-09-13 实测发现)。
440
+ * 曾另有 `## Roadmap`:AI 自己写的方向总结,会被反复读到并带偏会话,已删。 */
406
441
  export function collapseIndex(text) {
407
442
  const s = String(text || '').trim();
408
443
  if (!s) return '';
409
444
  const out = [];
410
- let mode = null; // null=逐行透传(路线区/文件头);字符串=当前在计数的分区名
445
+ let mode = null; // null=逐行透传(文件头);字符串=当前在计数的分区名
411
446
  let n = 0; // mode 非 null 时的清单行计数
412
447
  const flush = () => {
413
448
  if (mode !== null) out.push(n ? `## ${mode}(${n} 页)` : `## ${mode}`);
414
449
  mode = null;
415
450
  n = 0;
416
451
  };
452
+ let skipping = false; // 跳过 Rules 正文(已在上方单独成段)
417
453
  for (const l of s.split('\n')) {
418
454
  const m = l.match(/^##\s+(.+?)\s*$/);
419
455
  if (m) {
420
456
  flush();
421
457
  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
- }
458
+ skipping = /Rules?|规则/i.test(name);
459
+ if (skipping) continue;
460
+ mode = name; // 页面清单分区:只计数
430
461
  continue;
431
462
  }
432
- if (mode === null) out.push(l); // 透传区(含文件头 H1、路线区正文)
463
+ if (skipping) continue;
464
+ if (mode === null) out.push(l); // 透传区(含文件头 H1)
433
465
  else if (l.trim().startsWith('-')) n++;
434
466
  }
435
467
  flush();
468
+ // 文件头与首个分区之间可能因跳过 Rules 而留下多余空行
436
469
  return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
437
470
  }
438
471
 
@@ -1139,3 +1172,74 @@ async function listPages(vault) {
1139
1172
  }
1140
1173
  return pages;
1141
1174
  }
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
@@ -52,20 +52,28 @@ export const SEC = {
52
52
  today: 'Today / In Progress',
53
53
  blocked: 'Blocked',
54
54
  done: 'Done',
55
+ // 分区(话题树总看板):讨论层面的话题状态机,与「要落实的事」(Backlog/Today)分层。
56
+ // 讨论出话题 → 记分区;成熟到要动手 → 才落一行任务。
57
+ // 中文标题「分区」比 Topic 更直白;LEGACY 里把英文 Topic 也归一过来。
58
+ topics: 'Topics',
55
59
  archived: 'Archived', // Done 区内部的归档标记区(原 '### 归档')
56
60
  undated: 'Undated', // Done 区内部无完成日期的尾组(原 '### (未标日期)')
57
61
  };
58
62
 
59
63
  /** 旧名 → 新名。供 `abs init --repair` 一次性迁移(幂等)。
60
64
  * 只改匹配整行的标题,不动正文;不做模糊替换(防误改正文里提到的旧名)。 */
65
+ // 分区标题归一(英文 Topic / 话题 都归一到「分区」)
61
66
  export const LEGACY_SECTION_RENAMES = [
62
67
  // H1(文件标题)
63
68
  ['# 🗂 图谱索引', '# 🗂 Graph Index'],
64
69
  ['# 🗒 操作日志', '# 🗒 Activity Log'],
65
70
  ['# 📋 Todo 看板', '# 📋 Todo Board'],
66
71
  // ## 分区
67
- ['## 当前路线 (Roadmap)', '## Roadmap'],
68
72
  ['## Done(只留近期,旧的迁 log.md/快照)', '## Done'],
73
+ ['## Topic', '## Topics'],
74
+ ['## 话题', '## Topics'],
75
+ ['## 话题树', '## Topics'],
76
+ ['## 分区', '## Topics'],
69
77
  // ### 区内分组标题
70
78
  ['### 归档', '### Archived'],
71
79
  ['### (未标日期)', '### Undated'],
@@ -89,15 +97,21 @@ export function renameLegacySections(text) {
89
97
  export function todoTemplate() {
90
98
  // 由 rebuildStructure 生成,保证“模板”与“重排结果”逐字节一致
91
99
  // (否则 load 会把新建的模板又重排一次 = 无意义的写盘)。
100
+ // 注意(2026-09-13 踩坑):rebuildStructure 的 order 要**带 `## ` 前缀**,
101
+ // 而 TODO_SECTIONS 是裸名(两者用途不同,不能复用 —— 曾误传裸名导致
102
+ // 生成出没有 `##` 的裸标题行,模板直接损坏、Backlog 分区消失)。
92
103
  return rebuildStructure(
93
- ['# 📋 Todo Board', '## Backlog', '- [ ] 待办任务', '## Today / In Progress', '## Blocked', '## Done'].join('\n'),
94
- { h1: '# 📋 Todo Board', order: ['## Backlog', '## Today / In Progress', '## Blocked', '## Done'] },
104
+ ['# 📋 Todo Board', ...TODO_SECTIONS.map((s) => `## ${s}`)].join('\n'),
105
+ { h1: '# 📋 Todo Board', order: TODO_SECTIONS.map((s) => `## ${s}`) },
95
106
  ).text;
96
107
  }
97
108
 
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'];
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'];
101
115
 
102
116
  /** 结构重建(B 档):以标准分区表为准重排整个文件。
103
117
  *
@@ -674,3 +688,242 @@ export async function boardText(brainRoot, textOverride, { full = false } = {})
674
688
  const hint = full ? '' : '\n\n(Done 只给计数;看明细: abs todo --full)';
675
689
  return `${head}\n\n${body}${hint}`;
676
690
  }
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
+ }