@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 +4 -4
- package/bin/abs.js +6 -4
- package/bin/mcp.js +5 -5
- package/hooks/abs.opencode.ts +1 -1
- package/hooks/abs.pi.ts +75 -7
- package/package.json +1 -1
- package/skill/SKILL.md +34 -21
- package/src/store.js +81 -37
- package/src/todo.js +86 -60
- package/src/wrapup.js +4 -0
package/README.md
CHANGED
|
@@ -59,12 +59,12 @@ abs init # 建 .brain/ 图谱,只需一次
|
|
|
59
59
|
### 日常命令
|
|
60
60
|
|
|
61
61
|
```bash
|
|
62
|
-
abs load #
|
|
63
|
-
abs todo # 看板
|
|
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
|
|
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`
|
|
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 任务看板
|
|
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
|
|
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
|
-
|
|
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 /
|
|
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
|
|
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)。
|
|
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,7 +47,7 @@ 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" +
|
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
|
-
//
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 看
|
|
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
package/skill/SKILL.md
CHANGED
|
@@ -15,7 +15,7 @@ Obsidian 可直接打开的 Markdown 图谱(`.brain/`)做统一落点。
|
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
17
|
# 读
|
|
18
|
-
abs load # 开机读状态(
|
|
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
|
|
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
|
-
| `##
|
|
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 "一句话"`(>
|
|
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
|
-
| `##
|
|
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
|
|
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
|
-
>
|
|
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
|
-
|
|
186
|
-
|
|
|
187
|
-
|
|
188
|
-
|
|
|
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`,看
|
|
225
|
+
1. **读 todo** → `abs load`,看 Todo 还有哪些没完成。
|
|
213
226
|
2. **判有没有做完没登记** → 实际完成了漏登记的 `abs todo done <id>`;做到一半补
|
|
214
|
-
`abs todo note <id> --note
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
'##
|
|
115
|
+
'## Rules', '## Concepts', '## Entities', '## Sources', '## Syntheses', '## Sessions'].join('\n'),
|
|
116
116
|
{
|
|
117
117
|
h1: '# 🗂 Graph Index',
|
|
118
|
-
|
|
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: ['##
|
|
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: ['##
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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 (
|
|
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
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
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`
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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 (
|
|
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 看
|
|
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:
|
|
674
|
-
text:
|
|
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 === '
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
94
|
-
{ h1: '# 📋 Todo Board', order:
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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();
|