@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 +2 -2
- package/bin/abs.js +5 -32
- package/bin/mcp.js +4 -4
- package/hooks/abs.opencode.ts +1 -6
- package/hooks/abs.pi.ts +3 -12
- package/package.json +1 -1
- package/skill/SKILL.md +30 -16
- package/src/store.js +63 -123
- package/src/todo.js +81 -308
- package/src/wrapup.js +4 -0
package/README.md
CHANGED
|
@@ -60,11 +60,11 @@ abs init # 建 .brain/ 图谱,只需一次
|
|
|
60
60
|
|
|
61
61
|
```bash
|
|
62
62
|
abs load # 开机读状态(Rules + 图谱计数 + 看板 + 最近流水)
|
|
63
|
-
abs todo # 看板
|
|
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
|
|
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
|
|
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 任务看板
|
|
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
|
|
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
|
-
|
|
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 /
|
|
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)。
|
|
107
|
+
'任务实时落盘(幂等键 = id)。start 登记进 Todo / note 补断点(改到哪文件哪行) / state 改状态 / done 完成归位 Done。',
|
|
108
108
|
{
|
|
109
|
-
action: z.enum(['add', 'start', 'done', 'note', '
|
|
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=断点(文件/到哪步);
|
|
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=追加一条'),
|
package/hooks/abs.opencode.ts
CHANGED
|
@@ -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 看
|
|
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
|
-
// 整天不再提醒 →
|
|
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 看
|
|
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
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
|
|
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 "一句话"`(>
|
|
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
|
-
| `##
|
|
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
|
-
|
|
185
|
-
|
|
|
186
|
-
|
|
187
|
-
|
|
|
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`,看
|
|
225
|
+
1. **读 todo** → `abs load`,看 Todo 还有哪些没完成。
|
|
212
226
|
2. **判有没有做完没登记** → 实际完成了漏登记的 `abs todo done <id>`;做到一半补
|
|
213
|
-
`abs todo note <id> --note
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
397
|
-
|
|
398
|
-
|
|
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 看
|
|
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:
|
|
707
|
-
text:
|
|
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 === '
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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();
|