@fanchao8609/agent_brain_sync 1.19.6 → 1.20.0
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 +8 -7
- package/bin/abs.js +7 -7
- package/bin/mcp.js +7 -7
- package/hooks/abs.omp.ts +13 -17
- package/hooks/abs.pi.ts +25 -29
- package/hooks/event.sh +1 -1
- package/package.json +1 -1
- package/skill/abs-agent-brain-sync/SKILL.md +8 -8
- package/src/lint.js +22 -5
- package/src/store.js +20 -13
- package/src/todo/archive.js +5 -2
- package/src/todo/common.js +40 -31
- package/src/todo/spec.js +3 -3
- package/src/todo/structure.js +44 -16
- package/src/todo/tasks.js +8 -12
- package/src/todo/validate.js +7 -8
- package/src/todo.js +1 -0
- package/src/wrapup.js +17 -11
package/README.md
CHANGED
|
@@ -62,16 +62,15 @@ pi 与 omp 宿主额外多一层 UI(其它宿主没有):**编辑器上方
|
|
|
62
62
|
─────────────────────────────────────────────────
|
|
63
63
|
📋 todo (3) — fanchao · ☕ 靠咖啡续命
|
|
64
64
|
├─ [进行中] some-task ●●○ — 干活中
|
|
65
|
-
│
|
|
65
|
+
│ - [ ] hooks/abs.pi.ts:120
|
|
66
66
|
├─ [滞留中] waiting — 等外部输入
|
|
67
67
|
└─ [进行中] another ●●● — …
|
|
68
68
|
```
|
|
69
69
|
|
|
70
70
|
- **只显示未完成**,Done 归计数不占位(数量在标题里)
|
|
71
71
|
- **按当前使用者过滤**:显示 `[[我]]` 的 + 没标作者的(老任务/手写),别人的不显示
|
|
72
|
-
-
|
|
73
|
-
-
|
|
74
|
-
单独的 `↳ 断点: 一句话` 仍是纯附属行、不算任务
|
|
72
|
+
- **子项(缩进的 `- [ ]`)挂在父任务下**;`├─` / `└─` 表结构
|
|
73
|
+
- **子项可各自勾选**:` - [ ] 一句话` → ` - [x] 一句话`;全部勾上才能 `done` 父任务
|
|
75
74
|
- **「进行中」带三点动画**`○○○ → ●●○ → ●●●`(250ms/帧)—— 一眼看出哪条在跑
|
|
76
75
|
- **实时刷新**:我调 `abs_task` / `abs_board` 后立即重画,不用等我讲完话
|
|
77
76
|
- **上描边与输入框同色满宽**(主题色 `thinkingOff`,跟 pi 输入框的边框一致)
|
|
@@ -109,8 +108,8 @@ abs todo # 看板 Todo / Done(行首带状态标记)
|
|
|
109
108
|
|
|
110
109
|
abs todo add TASK-1 --note "要做什么"
|
|
111
110
|
abs todo note TASK-1 --note "改到 X 文件 L40" # 追加一条子项(标准 md 嵌套列表,可多条)
|
|
112
|
-
abs todo
|
|
113
|
-
abs todo
|
|
111
|
+
abs todo child-done TASK-1 [--n 2] # 勾子项(n=第几条,缺省最后一条)
|
|
112
|
+
abs todo child-undone TASK-1 [--n 2] # 取消勾(勾错了退回)
|
|
114
113
|
abs todo memo TASK-1 "随手记一段(可多句,不上看板)" # 任务笔记 → sessions/;无参=查看
|
|
115
114
|
abs todo state TASK-1 --note "进行中|讨论中|滞留中" # 改行首状态标记
|
|
116
115
|
abs todo done TASK-1
|
|
@@ -132,7 +131,9 @@ abs update # 升级到最新版并刷新四宿主
|
|
|
132
131
|
|
|
133
132
|
> `abs todo start` 与 `abs todo add` 等价(都登记任务)。
|
|
134
133
|
> **子项就是标准 Markdown 嵌套列表**:任务下缩进 2 空格的 `- [ ] …`,可多条、可各自勾选;
|
|
135
|
-
>
|
|
134
|
+
> 面板按缩进画树线,归档时原样保留缩进。
|
|
135
|
+
> 旧版的 `↳ 断点:` 写法已在 1.20.0 移除;存量会在 `abs load` 或任意写操作时
|
|
136
|
+
> 自动迁移成标准子项(内容一条不丢),不需要手工改。
|
|
136
137
|
> 任务 id 不能含空格(用连字符),含空格会被明确拒掉。
|
|
137
138
|
> 旧版 `abs task ...` / `abs board` 已废,打它们会报「未知命令」并退出码 1。
|
|
138
139
|
> `abs wrapup` / `abs teardown-check` 是 hook 内部命令,无需手动调用。
|
package/bin/abs.js
CHANGED
|
@@ -311,9 +311,9 @@ const subUsage = {
|
|
|
311
311
|
' abs todo 看板',
|
|
312
312
|
' abs todo add <id> [--note "做什么"] [--section 讨论中|滞留中|搁置]',
|
|
313
313
|
' abs todo rename <旧id> --note "<新id>" 改任务 id(起错名/不可读时)',
|
|
314
|
-
' abs todo note <id> --note "
|
|
315
|
-
' abs todo
|
|
316
|
-
' abs todo
|
|
314
|
+
' abs todo note <id> --note "进度/子项" 追加一条子项(标准 md 缩进列表)',
|
|
315
|
+
' abs todo child-done <id> [--n N] 勾子项(缺省最后一条)',
|
|
316
|
+
' abs todo child-undone <id> [--n N] 取消勾',
|
|
317
317
|
' abs todo memo <id> "随手记(可多句)" 任务笔记 → sessions/,不上看板;无文字=查看',
|
|
318
318
|
' abs todo state <id> --note 进行中|讨论中|滞留中|搁置',
|
|
319
319
|
' abs todo done <id> [--as 落地|否决|仅方案] [结语文字]',
|
|
@@ -378,8 +378,8 @@ const TODO_ACTIONS = {
|
|
|
378
378
|
add: 'start',
|
|
379
379
|
start: 'start', // add 的别名(老习惯保留)
|
|
380
380
|
note: 'note',
|
|
381
|
-
'
|
|
382
|
-
'
|
|
381
|
+
'child-done': 'child-done', // 勾子项(标准 md 缩进列表项)
|
|
382
|
+
'child-undone': 'child-undone', // 取消勾子项
|
|
383
383
|
memo: 'memo', // 任务随手记:多句/追加/不占看板(无文字时=查看)
|
|
384
384
|
state: 'state', // 改行首状态标记:进行中|讨论中|滞留中|搁置(原地,不搬区)
|
|
385
385
|
rename: 'rename', // 改任务 id:人工起错名、或旧 id 不可读时用
|
|
@@ -475,7 +475,7 @@ async function main() {
|
|
|
475
475
|
if (!action) {
|
|
476
476
|
throw new Error(
|
|
477
477
|
`✗ 未知子命令 "${sub}"\n` +
|
|
478
|
-
` 可用: add / start / note /
|
|
478
|
+
` 可用: add / start / note / child-done / child-undone / memo / state / done / archive\n` +
|
|
479
479
|
` 看板: abs todo(不带参数)\n` +
|
|
480
480
|
` 登记任务: abs todo add <id> --note "做什么"`,
|
|
481
481
|
);
|
|
@@ -499,7 +499,7 @@ async function main() {
|
|
|
499
499
|
break;
|
|
500
500
|
}
|
|
501
501
|
// 勾/取消勾子项:只收 <id>(子项位置 = 「某任务的第 N 条」,没有自己的 id)
|
|
502
|
-
if (action === '
|
|
502
|
+
if (action === 'child-done' || action === 'child-undone') {
|
|
503
503
|
rejectExtra(rest2, `abs todo ${sub} <id> [--n N]`);
|
|
504
504
|
if (!id) throw new Error(`✗ 缺 <id>\n 用法: abs todo ${sub} <id> [--n N]`);
|
|
505
505
|
const n = opts.n ? Number(opts.n) : undefined;
|
package/bin/mcp.js
CHANGED
|
@@ -90,7 +90,7 @@ tool(
|
|
|
90
90
|
|
|
91
91
|
tool(
|
|
92
92
|
'abs_board',
|
|
93
|
-
'读取当前项目 todo
|
|
93
|
+
'读取当前项目 todo 看板(含任务与子项)。先 resolve 得到项目,再传其根目录。',
|
|
94
94
|
{ cwd: z.string().describe('项目根目录(.brain/ 所在处)') },
|
|
95
95
|
async ({ cwd }) => {
|
|
96
96
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
@@ -112,12 +112,12 @@ tool(
|
|
|
112
112
|
|
|
113
113
|
tool(
|
|
114
114
|
'abs_task',
|
|
115
|
-
'任务实时落盘(幂等键 = id)。start 登记进 Todo / note 补子项(**追加**,标准 md 嵌套列表,`abs todo note`)/
|
|
115
|
+
'任务实时落盘(幂等键 = id)。start 登记进 Todo / note 补子项(**追加**,标准 md 嵌套列表,`abs todo note`)/ child_done、child_undone 勾/取消勾子项(n=第几条,缺省最后一条)/ memo 任务随手记(多句/追加/不上看板)/ state 改状态 / done 完成归位 Done(done 时 note=结语文字,as=结语类型:落地|否决|仅方案)。必填: cwd, action, id。多会话并行时,start/add 带上 session(值见 system prompt 的 [会话] 段)以标记「这条任务谁在做」。',
|
|
116
116
|
{
|
|
117
|
-
action: z.enum(['add', 'start', 'done', 'note', '
|
|
117
|
+
action: z.enum(['add', 'start', 'done', 'note', 'child_done', 'child_undone', 'memo', 'state']),
|
|
118
118
|
id: z.string().describe('任务幂等键,如 TASK-xxx 或子任务名'),
|
|
119
|
-
n: z.number().int().positive().optional().describe('
|
|
120
|
-
cwd: z.string().describe('项目根目录(.brain/ 所在处)'), note: z.string().optional().describe('add/start=做什么; note
|
|
119
|
+
n: z.number().int().positive().optional().describe('child_done/child_undone 用:第几条子项(1-based,缺省=最后一条)'),
|
|
120
|
+
cwd: z.string().describe('项目根目录(.brain/ 所在处)'), note: z.string().optional().describe('add/start=做什么; note=追加一条子项(改到哪/下一步,一句话); memo=随手记(可多句、不定长,追加式;不传=查看已有笔记); state=进行中|讨论中|滞留中|搁置(搁置=用户改方向/不做了;滞留中=还要做只是卡住); done=结语文字'),
|
|
121
121
|
as: z.enum(['落地', '否决', '仅方案']).optional().describe('仅 done:结语类型。默认 落地。做了又撤/评估后不做用 否决,只设计过用 仅方案 —— 别让假【落地】污染看板'),
|
|
122
122
|
// 会话 id:多会话并行时标记「这条任务是谁在做」。
|
|
123
123
|
// 值取自 system prompt 里的 `[会话] 本会话 id = xxx`(hook 注入),由模型原样回传。
|
|
@@ -130,8 +130,8 @@ tool(
|
|
|
130
130
|
if (!root) return errNoBrain(cwd || process.cwd());
|
|
131
131
|
// add 是 start 的别名(与 CLI `abs todo add` 对齐)
|
|
132
132
|
const act = action === 'add' ? 'start' : action;
|
|
133
|
-
//
|
|
134
|
-
const act2 = act === '
|
|
133
|
+
// child_done → child-done(MCP 参数名不用连字符,内部 action 用)
|
|
134
|
+
const act2 = act === 'child_done' ? 'child-done' : act === 'child_undone' ? 'child-undone' : act;
|
|
135
135
|
return { content: [{ type: 'text', text: await cmdTask({ dir: root, action: act2, id, note, as, session, n }) }] };
|
|
136
136
|
}
|
|
137
137
|
);
|
package/hooks/abs.omp.ts
CHANGED
|
@@ -104,7 +104,7 @@ function inputField(input: unknown, key: string): string {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
/** 会改文件的工具名。用于判定「真的开始干活了」—— 排查类会话先跑一堆读命令,
|
|
107
|
-
* 若用「第一次 tool_call
|
|
107
|
+
* 若用「第一次 tool_call」当判据,子项会被写在与任务无关的文件上。
|
|
108
108
|
* 白名单必须按宿主实际用法穷举,漏一个就把功能静默关掉(含 bash:大量修改是经 bash 完成的)。 */
|
|
109
109
|
const WRITE_TOOL = /^(write|edit|apply_patch|patch|multiedit|notebookedit|bash|shell)$/i
|
|
110
110
|
|
|
@@ -354,8 +354,8 @@ export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
|
|
|
354
354
|
rows: { state: string; id: string; desc: string; kids: { done: boolean; text: string }[]; sid: string }[]
|
|
355
355
|
hidden: number
|
|
356
356
|
} {
|
|
357
|
-
//
|
|
358
|
-
//
|
|
357
|
+
// 两阶段:先无过滤地按序解析(子项归属需要「上一条」是原文里真正的前一条),
|
|
358
|
+
// 再按作者过滤。若边解析边过滤,被滤掉的条目下方的子项会挂到它上面那一条。
|
|
359
359
|
type Row = { state: string; id: string; desc: string; kids: { done: boolean; text: string }[]; authors: string[]; sid: string }
|
|
360
360
|
const parsed: Row[] = []
|
|
361
361
|
let inTodo = false
|
|
@@ -364,7 +364,7 @@ export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
|
|
|
364
364
|
if (/^##\s+Todo\s*$/.test(line)) { inTodo = true; continue }
|
|
365
365
|
if (/^##\s+/.test(line)) { inTodo = false; continue }
|
|
366
366
|
if (!inTodo) continue
|
|
367
|
-
// 子项行(标准 md:缩进的 `- [ ]
|
|
367
|
+
// 子项行(标准 md:缩进的 `- [ ]`)→ 挂到上一条任务
|
|
368
368
|
const kid = parseChildLine(line)
|
|
369
369
|
if (kid && kid.indent > 0) {
|
|
370
370
|
const last = parsed[parsed.length - 1]
|
|
@@ -372,9 +372,9 @@ export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
|
|
|
372
372
|
continue
|
|
373
373
|
}
|
|
374
374
|
// `- [ ] [状态] <id> …`;状态标记可能缺省。
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
const m = line.match(/^\s
|
|
375
|
+
// 只认 `- [ ]`(2026-10-20 旧 `↳ [ ]` 写法已废弃)。
|
|
376
|
+
// 子项任务必须显示 —— 它就是一等待任务(写得进去、看得见、能 done)。
|
|
377
|
+
const m = line.match(/^\s*-\s*\[ \]\s*(?:\[([^\]]+)\]\s*)?(\S+)\s*(.*)$/)
|
|
378
378
|
if (!m) continue
|
|
379
379
|
const state = m[1] || '进行中'
|
|
380
380
|
const id = m[2]
|
|
@@ -417,13 +417,9 @@ async function currentUser(): Promise<string> {
|
|
|
417
417
|
* 宽度规则:CJK/全角记 2 列,其余记 1 列。 */
|
|
418
418
|
/** 解析子项行(与 src/todo/common.js 的 parseChild 同规则,双份实现 —— 见 pi 版说明)。 */
|
|
419
419
|
function parseChildLine(l: string): { done: boolean; text: string; indent: number } | null {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
if (!old[2].trim()) return null
|
|
424
|
-
return { done: /^\s*↳\s*\[x\]/.test(s), text: old[2].trim(), indent: old[1].length }
|
|
425
|
-
}
|
|
426
|
-
const m = s.match(/^(\s+)-\s*\[([ x])\]\s*(.*)$/)
|
|
420
|
+
// 只认标准 md 缩进子项(2026-10-20 旧箭头写法已废弃)。
|
|
421
|
+
// 旧行由 CLI 侧 migrateLegacyChild 在读写时迁移掉,面板拿到的已是新格式。
|
|
422
|
+
const m = String(l || '').match(/^(\s+)-\s*\[([ x])\]\s*(.*)$/)
|
|
427
423
|
if (!m) return null
|
|
428
424
|
return { done: m[2] === 'x', text: m[3].trim(), indent: m[1].length }
|
|
429
425
|
}
|
|
@@ -532,7 +528,7 @@ export function renderPanelLines(
|
|
|
532
528
|
return lines
|
|
533
529
|
}
|
|
534
530
|
data.rows.forEach((t, i) => {
|
|
535
|
-
// `└─` 只给最后一条 ——
|
|
531
|
+
// `└─` 只给最后一条 —— 不管它有没有子项(否则末条带子项时全篇没有收尾)。
|
|
536
532
|
const isLast = i === lastIdx
|
|
537
533
|
const branch = isLast ? '└─' : '├─'
|
|
538
534
|
const anim = t.state === '进行中' && animPhase >= 0 ? ' ' + fg('accent', todoAnimFrame(animPhase)) : ''
|
|
@@ -707,7 +703,7 @@ export default function absOmpExtension(pi: ExtensionAPI): void {
|
|
|
707
703
|
resetThrottle()
|
|
708
704
|
|
|
709
705
|
pi.on("session_start", (_event: SessionStartEvent, ctx: ExtensionContext) => {
|
|
710
|
-
// 新会话 =
|
|
706
|
+
// 新会话 = 重新登记:不重置则同进程的第二个会话永远不再落子项
|
|
711
707
|
autoTaskSeen = false
|
|
712
708
|
filesSeen.clear() // 同理:新会话的文件痕迹不能带到下个会话
|
|
713
709
|
loadInjected.clear() // 同:新会话的每个目录都要重新摆一次开局状态
|
|
@@ -743,7 +739,7 @@ export default function absOmpExtension(pi: ExtensionAPI): void {
|
|
|
743
739
|
pendingTodoTool = true
|
|
744
740
|
logHook(`tool_call todo_tool name=${name} target=${target || '-'}`).catch(() => {})
|
|
745
741
|
}
|
|
746
|
-
//
|
|
742
|
+
// 自动登记子项:真动文件时把「改到哪个」记进本会话(可观测埋点,不写看板)。
|
|
747
743
|
if (!autoTaskSeen && isWriteCall(name, input)) {
|
|
748
744
|
autoTaskSeen = true
|
|
749
745
|
const sid = sessionIdFor(ctx)
|
package/hooks/abs.pi.ts
CHANGED
|
@@ -95,15 +95,15 @@ function sessionIdFor(ctx: any): string {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
/** 会改文件的工具名。用于判定「真的开始干活了」—— 排查类会话先跑一堆读命令,
|
|
98
|
-
* 若用「第一次 tool_call
|
|
98
|
+
* 若用「第一次 tool_call」当判据,子项会被记在与任务无关的文件上。
|
|
99
99
|
*
|
|
100
100
|
* 为何含 bash:大量修改是经 bash 完成的(heredoc / sed -i / > 重定向),
|
|
101
|
-
* 漏掉它 = 纯 bash
|
|
101
|
+
* 漏掉它 = 纯 bash 会话永远不记子项(见 concept opencode-inject-channel-verdict
|
|
102
102
|
* 的同源教训:白名单必须按宿主实际用法穷举,漏一个就把功能静默关掉)。
|
|
103
103
|
*/
|
|
104
104
|
const WRITE_TOOL = /^(write|edit|apply_patch|patch|multiedit|notebookedit|bash|shell)$/i
|
|
105
105
|
|
|
106
|
-
/** bash 命令里没有写入迹象则不算「干活」—— 否则 `ls`/`git status`
|
|
106
|
+
/** bash 命令里没有写入迹象则不算「干活」—— 否则 `ls`/`git status` 也会记子项。 */
|
|
107
107
|
const BASH_WRITES = /(^|[^\w])(>|>>|sed\s+-i|tee\b|mkdir\b|rm\b|mv\b|cp\b|touch\b|cat\s*>)/
|
|
108
108
|
|
|
109
109
|
/** 这次 tool_call 是否算「真的动了文件」。 */
|
|
@@ -145,8 +145,8 @@ let agentEndSeen = false
|
|
|
145
145
|
* 同样在模块级,理由同上(闭包 flag 在重复注册时不共享)。 */
|
|
146
146
|
let pendingTodoTool = false
|
|
147
147
|
|
|
148
|
-
/**
|
|
149
|
-
*
|
|
148
|
+
/** 本会话是否已记过自动任务子项。模块级(重复注册时闭包 flag 不共享 —— 同 agentEndSeen 的坑)。
|
|
149
|
+
* 只记第一次写文件,不是每次都记:子项是「记到哪」不是流水账。 */
|
|
150
150
|
let autoTaskSeen = false
|
|
151
151
|
|
|
152
152
|
/** 本会话动过的文件(去重,最多记 FILES_SEEN_MAX 个)。
|
|
@@ -219,7 +219,7 @@ const TODO_GUIDELINES = [
|
|
|
219
219
|
'Use the abs task tool as soon as you receive instructions that involve changing this project — including investigation work where you read code or ask questions before any edit. Call it with action "start" and a short id before the work itself, not after. Skip it for pure questions about general knowledge, one-line answers, and conversation. (Tool name varies by host: `mcp__abs` with tool="abs_task", or `mcp__abs__abs_task`, or `abs_task` — use whichever form this session exposes.)',
|
|
220
220
|
// ② 完成即结:不许攒
|
|
221
221
|
'Mark a task "done" immediately when it finishes — never batch completions at the end of a session.',
|
|
222
|
-
// ③
|
|
222
|
+
// ③ 子项:跨会话接力靠它
|
|
223
223
|
'Before starting a task, record the checkpoint with action "note" (which file, which step) so a later session can resume.',
|
|
224
224
|
// ④ 反例(照 rpiv-todo):明确什么情况【不许】标完成 —— 只给正向要求时,
|
|
225
225
|
// 模型倾向于把"我以为做完了"当成完成,结语失真会污染下个会话的判断。
|
|
@@ -717,15 +717,15 @@ export function pickNickname(rnd: () => number = Math.random): string {
|
|
|
717
717
|
* who 不为空时只收“我的任务”:行内有 [[who]] 的,或**完全没标作者**的
|
|
718
718
|
* (老任务/手写的没标,漏掉比多显示更糟)。标了别人的则不收。
|
|
719
719
|
*
|
|
720
|
-
*
|
|
720
|
+
* 子项行(缩进的 `- [ ]`)归属到它上一条任务,不单独成条。 */
|
|
721
721
|
export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
|
|
722
722
|
total: number
|
|
723
723
|
rows: { state: string; id: string; desc: string; kids: { done: boolean; text: string }[]; sid: string }[]
|
|
724
724
|
hidden: number
|
|
725
725
|
} {
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
// ——
|
|
726
|
+
// 两阶段:先无过滤地按序解析(子项归属需要“上一条”是原文里真正的前一条),
|
|
727
|
+
// 再按作者过滤。若边解析边过滤,被滤掉的条目下方的子项会挂到它上面那一条
|
|
728
|
+
// —— 实测:别人任务的子项会显示在我的任务下面(2026-10-03 审查发现)。
|
|
729
729
|
type Row = { state: string; id: string; desc: string; kids: { done: boolean; text: string }[]; authors: string[]; sid: string }
|
|
730
730
|
const parsed: Row[] = []
|
|
731
731
|
let inTodo = false
|
|
@@ -734,7 +734,7 @@ export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
|
|
|
734
734
|
if (/^##\s+Todo\s*$/.test(line)) { inTodo = true; continue }
|
|
735
735
|
if (/^##\s+/.test(line)) { inTodo = false; continue }
|
|
736
736
|
if (!inTodo) continue
|
|
737
|
-
// 子项行(标准 md:缩进的 `- [ ]
|
|
737
|
+
// 子项行(标准 md:缩进的 `- [ ]`)→ 挂到上一条任务。
|
|
738
738
|
// 子项可多条、可各自勾选(2026-10-16 用户定)。
|
|
739
739
|
const kid = parseChildLine(line)
|
|
740
740
|
if (kid && kid.indent > 0) {
|
|
@@ -743,10 +743,10 @@ export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
|
|
|
743
743
|
continue
|
|
744
744
|
}
|
|
745
745
|
// `- [ ] [状态] <id> …`;状态标记可能缺省。
|
|
746
|
-
//
|
|
747
|
-
//
|
|
746
|
+
// 只认 `- [ ]`(2026-10-20 旧 `↳ [ ]` 写法已废弃)。
|
|
747
|
+
// 子项任务必须显示 —— 它就是一等等任务(写得进去、看得见、能 done),
|
|
748
748
|
// 否则落回「静默死区」(用户在 .brain 里报的原症状)。
|
|
749
|
-
const m = line.match(/^\s
|
|
749
|
+
const m = line.match(/^\s*-\s*\[ \]\s*(?:\[([^\]]+)\]\s*)?(\S+)\s*(.*)$/)
|
|
750
750
|
if (!m) continue
|
|
751
751
|
const state = m[1] || '进行中'
|
|
752
752
|
const id = m[2]
|
|
@@ -800,15 +800,11 @@ async function currentUser(): Promise<string> {
|
|
|
800
800
|
*
|
|
801
801
|
* 为何不 import src:面板是宿主扩展,单独装到 ~/.pi/extensions/,与包内 src/ 不同目录
|
|
802
802
|
* (装完就断了)。故两端各一份 —— 规则简单(两步正则),且两边测试都盖。
|
|
803
|
-
* 标准 md:缩进的 `- [ ]
|
|
803
|
+
* 标准 md:缩进的 `- [ ] 内容`。 */
|
|
804
804
|
function parseChildLine(l: string): { done: boolean; text: string; indent: number } | null {
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
if (!old[2].trim()) return null
|
|
809
|
-
return { done: /^\s*↳\s*\[x\]/.test(s), text: old[2].trim(), indent: old[1].length }
|
|
810
|
-
}
|
|
811
|
-
const m = s.match(/^(\s+)-\s*\[([ x])\]\s*(.*)$/)
|
|
805
|
+
// 只认标准 md 缩进子项(2026-10-20 旧箭头写法已废弃)。
|
|
806
|
+
// 旧行由 CLI 侧 migrateLegacyChild 在读写时迁移掉,面板拿到的已是新格式。
|
|
807
|
+
const m = String(l || '').match(/^(\s+)-\s*\[([ x])\]\s*(.*)$/)
|
|
812
808
|
if (!m) return null
|
|
813
809
|
return { done: m[2] === 'x', text: m[3].trim(), indent: m[1].length }
|
|
814
810
|
}
|
|
@@ -945,9 +941,9 @@ export function renderPanelLines(
|
|
|
945
941
|
return lines
|
|
946
942
|
}
|
|
947
943
|
data.rows.forEach((t, i) => {
|
|
948
|
-
// `└─` 只给最后一条 ——
|
|
949
|
-
// (2026-10-03 审查修正:原条件 `i === lastIdx && !t.note`
|
|
950
|
-
// 全篇没有 └─
|
|
944
|
+
// `└─` 只给最后一条 —— 不管它有没有子项。
|
|
945
|
+
// (2026-10-03 审查修正:原条件 `i === lastIdx && !t.note` 会让“末条带子项”时
|
|
946
|
+
// 全篇没有 └─ 收尾,子项行的 │ 又延伸到视觉底部,看起来像列表被截断。)
|
|
951
947
|
const isLast = i === lastIdx
|
|
952
948
|
const branch = isLast ? '└─' : '├─'
|
|
953
949
|
// 进行中的行带一个小动画(其余行不加,避免满屏都在跳)
|
|
@@ -1044,7 +1040,7 @@ export default function absPiHook(pi: ExtensionAPI): void {
|
|
|
1044
1040
|
resetThrottle()
|
|
1045
1041
|
|
|
1046
1042
|
pi.on("session_start", (event: any, ctx: any) => {
|
|
1047
|
-
// 新会话 =
|
|
1043
|
+
// 新会话 = 重新登记:不重置则同进程的第二个会话永远不再落子项
|
|
1048
1044
|
autoTaskSeen = false
|
|
1049
1045
|
filesSeen.clear() // 同理:新会话的文件痕迹不能带到下个会话
|
|
1050
1046
|
loadInjected.clear() // 同:新会话的每个目录都要重新摆一次开局状态
|
|
@@ -1085,8 +1081,8 @@ export default function absPiHook(pi: ExtensionAPI): void {
|
|
|
1085
1081
|
pendingTodoTool = true
|
|
1086
1082
|
logHook(`tool_call todo_tool name=${name} target=${target || '-'}`).catch(() => {})
|
|
1087
1083
|
}
|
|
1088
|
-
//
|
|
1089
|
-
// 为何不用「第一次 tool_call
|
|
1084
|
+
// 自动登记子项(2026-10-05):真的动文件时,把「改到哪个」记进本会话的自动任务。
|
|
1085
|
+
// 为何不用「第一次 tool_call」:排查类会话先跑一堆读命令,子项会落在无关文件上。
|
|
1090
1086
|
// 写类工具只留一行痕(可观测),不 spawn、不写看板 —— 见上面的删除说明。
|
|
1091
1087
|
if (!autoTaskSeen && isWriteCall(name, input)) {
|
|
1092
1088
|
autoTaskSeen = true
|
|
@@ -1205,7 +1201,7 @@ export default function absPiHook(pi: ExtensionAPI): void {
|
|
|
1205
1201
|
}
|
|
1206
1202
|
// 每轮 spawn 已删除(2026-10-05 用户实报"卡顿"):agentEndSeen 一旦置位,
|
|
1207
1203
|
// 下面那个条件就恒真 → 每轮结束都起一个 node 进程,纯属白烧。
|
|
1208
|
-
//
|
|
1204
|
+
// 子项改由 agent 在用 abs_task 时自己写(那时它本来就在跑,不额外起进程)。
|
|
1209
1205
|
})
|
|
1210
1206
|
|
|
1211
1207
|
pi.on("session_shutdown", (_e: any, ctx: any) => {
|
package/hooks/event.sh
CHANGED
|
@@ -103,7 +103,7 @@ fi
|
|
|
103
103
|
printf '[%s] wrapup-remind 会话已结束(%s): 若 Today 有滞留任务或经验未沉淀, 下会话开头走收尾循环\n' \
|
|
104
104
|
"$(date '+%Y-%m-%d %H:%M:%S')" "$SESSION_ID"
|
|
105
105
|
} >> "$LOG_DIR/wrapup.log" 2>/dev/null || true
|
|
106
|
-
# 机械快照滞留清单: abs wrapup
|
|
106
|
+
# 机械快照滞留清单: abs wrapup 把当前项目未完成任务+子项追加到 wrapup.log (跨会话收尾保险)。
|
|
107
107
|
# abs wrapup 已自行写 wrapup.log (honor ABS_LOG_DIR); 这里 stdout 只进 hooks.log 作痕迹, 避免污染 wrapup.log。
|
|
108
108
|
"$NODE_BIN" "$ABS_BIN" wrapup >>"$LOG_DIR/hooks.log" 2>/dev/null || true
|
|
109
109
|
fi
|
package/package.json
CHANGED
|
@@ -48,7 +48,7 @@ abs rule [add "一句话"] # 读写 ## Rules;abs lint 体检;abs i
|
|
|
48
48
|
用户报 bug 或要求加功能时先别动代码,三步:
|
|
49
49
|
|
|
50
50
|
1. **总结方案**(一屏内,给用户过目):准确复述需求(不确定就写明假设);bug 给根因、功能给做法;要改哪些文件、怎么验证。
|
|
51
|
-
2. **登记 todo**:`abs todo add <id> --note "<需求+关键约束>"`;根因/取舍用 `abs todo note <id>`
|
|
51
|
+
2. **登记 todo**:`abs todo add <id> --note "<需求+关键约束>"`;根因/取舍用 `abs todo note <id>` 追加到任务下当子项。
|
|
52
52
|
3. **问是否开工**:明确问一句,**等确认再改代码**。
|
|
53
53
|
|
|
54
54
|
**例外(可直接开工,回复里须说明援引哪一条)**:用户已说"开工/直接做/修它";一行级修字、纯查询、纯收尾沉淀;同一需求已登记且用户确认过。
|
|
@@ -66,7 +66,7 @@ abs rule [add "一句话"] # 读写 ## Rules;abs lint 体检;abs i
|
|
|
66
66
|
| `sources/` `syntheses/` | 经验暂存(`abs note` 自动落 `YYYY-MM-DD-slug.md`)/ 横向选型取舍(`synthesis-slug.md`) | sources 是暂存不是归档,提炼后删 |
|
|
67
67
|
| `sessions/` | 会话快照 + 当日归档段 | **一天只允许一个文件,只能叫 `log-<日期>.md`**(多主题就多写 `##` 段) |
|
|
68
68
|
|
|
69
|
-
`todo.md` 行形态:`- [ ] [进行中] <id> [[认领人]] — 说明 (认领 YYYY-MM-DD)`(标记还有 `[讨论中]`/`[滞留中]`;`[[认领人]]` = 使用者名,非任务名)。`abs todo done <id>` 勾选并归位到 Done
|
|
69
|
+
`todo.md` 行形态:`- [ ] [进行中] <id> [[认领人]] — 说明 (认领 YYYY-MM-DD)`(标记还有 `[讨论中]`/`[滞留中]`;`[[认领人]]` = 使用者名,非任务名)。`abs todo done <id>` 勾选并归位到 Done 日期组顶部,子项(缩进 `- [ ]` 行)随迁;超 3 天**且整天已完成**的组由 `abs wrapup` 迁进当天快照归档段(任一天还有未完成则整天不归档,不拆半天)。
|
|
70
70
|
|
|
71
71
|
`sessions/` 快照含正文 + `## 关联连接` + `## 🪝 Next Session Hook`(强制)+ `## 📦 任务归档`(机器写勿手改)。归档唯一动作 `abs todo archive`:已存在快照只替换归档段、段外逐字保留;那天没写过快照则建一个只有归档段的页;同日重复归档在段内追加。
|
|
72
72
|
|
|
@@ -79,7 +79,7 @@ abs rule [add "一句话"] # 读写 ## Rules;abs lint 体检;abs i
|
|
|
79
79
|
收到第一个核心开发指令**之前**走这条链载入上下文:
|
|
80
80
|
|
|
81
81
|
1. **读状态**:`abs load`(MCP `abs_load`)读 Rules + 图谱计数 + todo + 最近 log,**先看 `## Rules`**。输出折叠(Done 按日期计数、清单只给页数);全量明细用 `abs todo --full` / `abs index`。
|
|
82
|
-
2. **对账滞留(强制)**:顶部出现 `⏳ 上会话滞留` 说明上会话断了,先收尾再开工 —— 做完的 `abs todo done <id
|
|
82
|
+
2. **对账滞留(强制)**:顶部出现 `⏳ 上会话滞留` 说明上会话断了,先收尾再开工 —— 做完的 `abs todo done <id>`;没做完的补一条子项 `abs todo note <id> --note "接到哪"`。
|
|
83
83
|
3. **读命中页**:`abs query <词>` → `abs resolve <页名或id>` 拿路径后**只读命中那几页**,**绝不通读 `.brain/`**;多文件汇总交给 `ctx_execute` 在沙箱处理,**只打印结论**。
|
|
84
84
|
4. **续 todo**:把顶部未完成项当当前任务开做;有明确新任务而 todo 没有 → `abs todo add <id> --note 做什么` 再动工。
|
|
85
85
|
|
|
@@ -91,18 +91,18 @@ abs rule [add "一句话"] # 读写 ## Rules;abs lint 体检;abs i
|
|
|
91
91
|
|---|---|---|
|
|
92
92
|
| 认领新任务 | `start` + note 做什么 | Todo `[进行中]` |
|
|
93
93
|
| 只讨论没动手 / 卡住等人等数据 | `state` + note 讨论中\|滞留中 | 原地改标记 |
|
|
94
|
-
| 被打断/干到一半 | `note` + 改到哪个文件哪一步 |
|
|
94
|
+
| 被打断/干到一半 | `note` + 改到哪个文件哪一步 | 任务下追加一条子项(**一句话**) |
|
|
95
95
|
| 干活中想随手记一段(多句/发现/取舍) | **`memo`** + 原文 | `sessions/log-<日期>-<id>.md`,**不上看板** |
|
|
96
96
|
| 子任务做完 | `done` + note 结语 + as 落地\|否决\|仅方案 | Done(别让假【落地】污染看板) |
|
|
97
97
|
| 总结出经验/坑/规律 | 改用 `abs_note` text + tags | sources/ |
|
|
98
98
|
|
|
99
99
|
**选错落点会被闸门拒或被 lint 报。四个落点的分别:**
|
|
100
|
-
- `note` =
|
|
100
|
+
- `note` = **子项**,一句话**追加式**(每次调用多一条,不覆盖),上看板。
|
|
101
101
|
- `memo` = **过程随手记**,可多句**追加式**(旧值不能丢),不上看板。
|
|
102
102
|
- `abs_note` = 提炼过的**经验/坑**,一句话结论 → `sources/` 待提炼,不上看板。
|
|
103
103
|
- `abs log` = 一轮干完的**成果流水**行(带 kind),不上看板。
|
|
104
104
|
|
|
105
|
-
过程随手记一律走 `memo`,别塞 `todo.md`(Todo
|
|
105
|
+
过程随手记一律走 `memo`,别塞 `todo.md`(Todo 区有**白名单闸门**:只收任务行、缩进子项、空行、标题,其余拒写报错)。
|
|
106
106
|
|
|
107
107
|
```bash
|
|
108
108
|
abs todo memo <id> "第一句:根因是判据极性。第二句:改成白名单就不漏了。" # 记(可多句)
|
|
@@ -126,7 +126,7 @@ abs todo memo <id> # 查看
|
|
|
126
126
|
用户**明确说要收尾/结束/切别的事**时按下面走(不是每个词都触发,见开头总则):
|
|
127
127
|
|
|
128
128
|
1. **读 todo** → `abs load`,看 Todo 还有哪些没完成。
|
|
129
|
-
2. **对账** → 按上节「进行中」的四个落点补记:漏登记的 `done`、做一半的补 `note`
|
|
129
|
+
2. **对账** → 按上节「进行中」的四个落点补记:漏登记的 `done`、做一半的补 `note` 子项、卡住的转 `滞留中`。别让干完的事留在 Todo。
|
|
130
130
|
3. **沉淀经验(该沉淀才沉淀)** → 值得记的坑/可复用技巧 → `abs note` 暂存;值得深提炼的按 Teardown 走。
|
|
131
131
|
4. **判教训够不够格进 Rules** → 是否「违反会丢数据/静默失效/白干活」级?够格就提议(Rules 是唯一每次 load 全量送达的通道,差这一步下次就不送达):
|
|
132
132
|
- **够格 → 提议,不直写**:输出一行 `[Rules 提议] <一句话>` 问用户要不要加,确认后才 `abs rule add "<一句话>"`(短句,不带链接/解释)。
|
|
@@ -135,7 +135,7 @@ abs todo memo <id> # 查看
|
|
|
135
135
|
|
|
136
136
|
**完成标准**:看板反映真实状态(Done 无滞留半成品)、该沉淀已落、够格教训已提议进 Rules、index/log/todo 与事实一致。
|
|
137
137
|
|
|
138
|
-
Stop 时 hook 把「未完成 +
|
|
138
|
+
Stop 时 hook 把「未完成 + 子项」快照进 `~/.abs/log/wrapup.log`,下会话 `abs load` 自动把滞留顶到顶部。**不往对话里插收尾提醒** —— 插 turn 就是打断。
|
|
139
139
|
|
|
140
140
|
## 收尾:Teardown Sync(深提炼,工具不替判断)
|
|
141
141
|
|
package/src/lint.js
CHANGED
|
@@ -5,7 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { requireBrain, brainPath, BRAIN_DIR } from './index.js';
|
|
6
6
|
import { readRules, idOfPage, statusOfPage, supersededByOf, clip, RULE_BODY_MAX } from './store.js';
|
|
7
7
|
import { doneKindOf, doneDateOf, checkFileShape, TODO_MAX_LINES, LOG_MAX_LINES, INDEX_MAX_LINES } from './todo.js';
|
|
8
|
-
import { isAllowedTodoLine, todoSectionOf, parseChild } from './todo/common.js';
|
|
8
|
+
import { isAllowedTodoLine, todoSectionOf, parseChild, migrateLegacyChild } from './todo/common.js';
|
|
9
9
|
|
|
10
10
|
// ---------- 图谱遍历 ----------
|
|
11
11
|
|
|
@@ -136,10 +136,27 @@ async function checkRootFiles(vault) {
|
|
|
136
136
|
if (file !== 'todo.md') continue;
|
|
137
137
|
for (let i = 0; i < lines.length; i++) {
|
|
138
138
|
const l = lines[i];
|
|
139
|
+
// 旧 `↳` 子项写法(2026-10-20 废弃):lint 仍要能看见它,
|
|
140
|
+
// 报专门的迁移码而不是「裸文本」—— 两者出路不同。
|
|
141
|
+
const legacy = migrateLegacyChild(l);
|
|
142
|
+
if (legacy !== null) {
|
|
143
|
+
issues.push(
|
|
144
|
+
`TODO-LEGACY-CHILD: ${BRAIN_DIR}/${file}:${i + 1} (旧 ↳ 子项写法;` +
|
|
145
|
+
`跑一次 abs load 即可自动迁移为标准 md 缩进子项)`,
|
|
146
|
+
);
|
|
147
|
+
const legacyText = (parseChild(legacy) || {}).text || '';
|
|
148
|
+
if (legacyText.length > LINT_BREAKPOINT_MAX) {
|
|
149
|
+
issues.push(
|
|
150
|
+
`TODO-BREAKPOINT-BLOAT: ${BRAIN_DIR}/${file}:${i + 1} (子项 ${legacyText.length} 字 > ${LINT_BREAKPOINT_MAX};` +
|
|
151
|
+
`子项只写「改到哪个文件哪一步」,报告迁 sessions/ 或 sources/)`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
139
156
|
// 裸文本行(与闸门 isAllowedTodoLine 同源,2026-10-11):Todo 区只能有任务行/断点/空行/标题。
|
|
140
157
|
if (todoSectionOf(lines, i) === 'Todo' && !isAllowedTodoLine(l)) {
|
|
141
158
|
issues.push(
|
|
142
|
-
`TODO-STRAY-PROSE: ${BRAIN_DIR}/${file}:${i + 1} (Todo
|
|
159
|
+
`TODO-STRAY-PROSE: ${BRAIN_DIR}/${file}:${i + 1} (Todo 区里的裸文本,不是任务行也不是子项;` +
|
|
143
160
|
`总结/报告/清单请分流:流水 → abs log,经验 → abs note,长设计 → sources/)`,
|
|
144
161
|
);
|
|
145
162
|
}
|
|
@@ -149,12 +166,12 @@ async function checkRootFiles(vault) {
|
|
|
149
166
|
const bp = bpLine.text;
|
|
150
167
|
if (bp.length > LINT_BREAKPOINT_MAX) {
|
|
151
168
|
issues.push(
|
|
152
|
-
`TODO-BREAKPOINT-BLOAT: ${BRAIN_DIR}/${file}:${i + 1} (
|
|
153
|
-
|
|
169
|
+
`TODO-BREAKPOINT-BLOAT: ${BRAIN_DIR}/${file}:${i + 1} (子项 ${bp.length} 字 > ${LINT_BREAKPOINT_MAX};` +
|
|
170
|
+
`子项只写「改到哪个文件哪一步」,报告迁 sessions/ 或 sources/)`,
|
|
154
171
|
);
|
|
155
172
|
} else if (bp.split(/[。!?!?]+|\s*[\u2460-\u2473]\s*/).map((x) => x.trim()).filter(Boolean).length > 2) {
|
|
156
173
|
issues.push(
|
|
157
|
-
`TODO-BREAKPOINT-PROSE: ${BRAIN_DIR}/${file}:${i + 1} (
|
|
174
|
+
`TODO-BREAKPOINT-PROSE: ${BRAIN_DIR}/${file}:${i + 1} (子项写了多句 = 报告体;` +
|
|
158
175
|
`todo 只放待办,长文迁 sessions/ 或 sources/)`,
|
|
159
176
|
);
|
|
160
177
|
}
|
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 } from './index.js';
|
|
6
6
|
import { requireUser, atTag, getUser, placeholderWarn } from './userconfig.js';
|
|
7
|
-
import { stripStateMark, checkTaskBox, ensureStateMark, stateOfTaskLine, TODO_MAX_LINES, upsertTask, boardText, readTodo, todoTemplate, today, localStamp, setBreakpoint, setBreakpointDone, setMemo, readMemo, setStateMark, TASK_STATES, insertDoneGrouped, idOfTaskLine, archiveDoneInText, upsertArchiveSection, DONE_KINDS, withDoneKind, collapseDone, SEC, rebuildStructure, enforceBrainFormat, sessionOf, parseChild } from './todo.js';
|
|
7
|
+
import { stripStateMark, checkTaskBox, ensureStateMark, stateOfTaskLine, TODO_MAX_LINES, upsertTask, boardText, readTodo, todoTemplate, today, localStamp, setBreakpoint, setBreakpointDone, setMemo, readMemo, setStateMark, TASK_STATES, insertDoneGrouped, idOfTaskLine, archiveDoneInText, upsertArchiveSection, DONE_KINDS, withDoneKind, collapseDone, SEC, rebuildStructure, enforceBrainFormat, sessionOf, parseChild, migrateLegacyChild } from './todo.js';
|
|
8
8
|
import { setFormatGate, editFile, SKIP } from './lock.js';
|
|
9
9
|
import { appendWrapup, strandedFor } from './wrapup.js';
|
|
10
10
|
import { keywords, recentFiles, topicStrength } from './relevant.js';
|
|
@@ -367,7 +367,7 @@ export async function cmdLoad({ dir }) {
|
|
|
367
367
|
);
|
|
368
368
|
if (stranded.length) {
|
|
369
369
|
const rows = stranded.map((t) => {
|
|
370
|
-
const bp = t.bp.length ? `\n ${t.bp.map((b) => (b.startsWith('[') ?
|
|
370
|
+
const bp = t.bp.length ? `\n ${t.bp.map((b) => (b.startsWith('[') ? `- ${b}` : `- [ ] ${b}`)).join('\n ')}` : '';
|
|
371
371
|
return ` - ${t.body}${bp}`;
|
|
372
372
|
});
|
|
373
373
|
// 插在项目行之后、其它内容之前
|
|
@@ -375,7 +375,7 @@ export async function cmdLoad({ dir }) {
|
|
|
375
375
|
1, 0,
|
|
376
376
|
'⏳ 上会话滞留(未 done,先对账)',
|
|
377
377
|
rows.join('\n'),
|
|
378
|
-
'→ 完成: abs todo done <id>;未完: abs todo note <id> --note
|
|
378
|
+
'→ 完成: abs todo done <id>;未完: abs todo note <id> --note 子项',
|
|
379
379
|
''
|
|
380
380
|
);
|
|
381
381
|
}
|
|
@@ -430,8 +430,9 @@ export function extractBlocks(todoText) {
|
|
|
430
430
|
// 完成行:不再跟踪
|
|
431
431
|
if (/^\s*-\s*\[x\]/.test(line)) { curId = null; continue; }
|
|
432
432
|
if (!curId) continue;
|
|
433
|
-
//
|
|
434
|
-
|
|
433
|
+
// 附属行(缩进子项)里找卡点前缀。
|
|
434
|
+
// 旧写法 `↳ 断点: 阻塞: …`(存量)也认 —— 保留一条前缀兼容,以免漏报历史卡点。
|
|
435
|
+
const m = line.match(/^\s*(?:↳\s*(?:断点|卡点)?[::]\s*)?(?:阻塞|等待|等)[::]\s*(.+)$/);
|
|
435
436
|
if (m && m[1].trim()) out.push({ id: curId, why: m[1].trim() });
|
|
436
437
|
}
|
|
437
438
|
return out;
|
|
@@ -957,10 +958,12 @@ export async function cmdTask({ dir, action, id, section, note, as, session, n }
|
|
|
957
958
|
const r = await setBreakpoint(root, { id, text: note });
|
|
958
959
|
return r.msg;
|
|
959
960
|
}
|
|
960
|
-
//
|
|
961
|
+
// 勾/取消勾子项:子项也是待办,做完要勾。
|
|
961
962
|
// 单独两个子命令而不是 `--done true/false` —— 勾与取消是两件事,各自一个动词更顺手。
|
|
962
|
-
|
|
963
|
-
|
|
963
|
+
// 2026-10-20:命令名从 bp-done/bp-undone 改为 child-done/child-undone
|
|
964
|
+
//(「断点」这个叫法已废弃,子项就是标准 md 嵌套列表项)。
|
|
965
|
+
if (action === 'child-done' || action === 'child-undone') {
|
|
966
|
+
const r = await setBreakpointDone(root, { id, n, done: action === 'child-done' });
|
|
964
967
|
return r.msg;
|
|
965
968
|
}
|
|
966
969
|
if (action === 'memo') {
|
|
@@ -1039,10 +1042,14 @@ async function markDone(file, id, kind = '落地', conclusion, bySid = '') {
|
|
|
1039
1042
|
else leftover.push(kid.text);
|
|
1040
1043
|
continue;
|
|
1041
1044
|
}
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1045
|
+
// 防御:旧 `↳` 行本应已在 normalizeTodo 里迁掉。万一有路径绕过迁移,
|
|
1046
|
+
// 这里必须**消费掉该行**(i++),否则外层 while 原地打转(2026-10-18 OOM 实测)。
|
|
1047
|
+
const legacyChild = migrateLegacyChild(nx);
|
|
1048
|
+
if (legacyChild !== null) {
|
|
1049
|
+
i++; // 消费掉这一行,防止原地打转
|
|
1050
|
+
const parsed2 = parseChild(legacyChild);
|
|
1051
|
+
if (parsed2 && !parsed2.done) leftover.push(parsed2.text);
|
|
1052
|
+
else bp.push(legacyChild);
|
|
1046
1053
|
continue;
|
|
1047
1054
|
}
|
|
1048
1055
|
break;
|
|
@@ -1065,7 +1072,7 @@ async function markDone(file, id, kind = '落地', conclusion, bySid = '') {
|
|
|
1065
1072
|
`✗ 任务 "${wantId}" 还有 ${leftover.length} 条子任务未完成,不能 done:\n` +
|
|
1066
1073
|
leftover.map((p) => ` - [ ] ${p}`).join('\n') +
|
|
1067
1074
|
`\n子任务也是任务,主子全部完成才能 done。先二选一:\n` +
|
|
1068
|
-
` • 做完了 → abs todo
|
|
1075
|
+
` • 做完了 → abs todo child-done ${wantId} (勾最后一条;指定第 N 条:child-done ${wantId} --n N)\n` +
|
|
1069
1076
|
` • 不做了 → 删掉那几行子项,再 abs todo done ${wantId}`,
|
|
1070
1077
|
);
|
|
1071
1078
|
}
|
package/src/todo/archive.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/todo/archive.js — Done 结语契约与任务归档(分组、折叠、迁归档区)。
|
|
2
2
|
|
|
3
|
-
import { today, SEC, isTaskLine, isChildLine, daysAgo } from './common.js';
|
|
3
|
+
import { today, SEC, isTaskLine, isChildLine, daysAgo, migrateLegacyChild } from './common.js';
|
|
4
4
|
import { ARCHIVE_HEADING, ARCHIVE_SECTION } from './spec.js';
|
|
5
5
|
|
|
6
6
|
export function doneDateOf(line) {
|
|
@@ -39,7 +39,10 @@ export function withDoneKind(line, kind) {
|
|
|
39
39
|
function parseDoneUnits(bodyLines) {
|
|
40
40
|
const units = [];
|
|
41
41
|
let cur = null;
|
|
42
|
-
for (const
|
|
42
|
+
for (const raw of bodyLines) {
|
|
43
|
+
// 旧 `↳` 子项先迁移(2026-10-20)——否则它不再被认成子项,会从所属任务里“掉出去”,
|
|
44
|
+
// 归档时要么丢失、要么归错日期组。
|
|
45
|
+
const l = migrateLegacyChild(raw) ?? raw;
|
|
43
46
|
if (/^### /.test(l.trim()) || l.trim() === '') { cur = null; continue; } // 分组标题/空行断开会话
|
|
44
47
|
// 先判子项再判任务:缩进的 `- [ ]` 两个判据都为真,而它应挂到父任务。
|
|
45
48
|
// (标准 md 嵌套下子项本身就是任务行 —— 靠缩进区分层级,不靠形态。)
|
package/src/todo/common.js
CHANGED
|
@@ -104,47 +104,56 @@ export function sentenceCount(s) {
|
|
|
104
104
|
* 子任务:缩进的 `- [ ]` 行(标准 Markdown 嵌套列表,2 空格一层)。
|
|
105
105
|
* 两者**都是任务行**(子任务可各自勾选);层级的区分看缩进,不在本函数。
|
|
106
106
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
107
|
+
* 2026-10-20:旧 `↳ [ ]` 写法已彻底废弃,本函数不再认它。
|
|
108
|
+
* 存量由 migrateLegacyChild 在 load 时就地转成标准 md(见下)。 */
|
|
109
109
|
export function isTaskLine(l) {
|
|
110
|
-
|
|
111
|
-
if (/^\s*↳\s*\[[ x]\]\s*断点[::]/.test(l)) return false;
|
|
112
|
-
return /^\s*(?:-\s*\[[ x]\]|↳\s*\[[ x]\])/.test(l);
|
|
110
|
+
return /^\s*-\s*\[[ x]\]/.test(l);
|
|
113
111
|
}
|
|
114
112
|
|
|
115
|
-
/** 一行是否任务附属行(子项):缩进的 `- [ ]
|
|
116
|
-
*
|
|
117
|
-
* 标准 md 下子项就是缩进的列表项 —— 它本身也是任务行(isTaskLine 为真),
|
|
118
|
-
* 但**不单独成条**:它挂在父任务下,不占顶层 id、不参与 `abs todo done`。
|
|
119
|
-
* 故 isChild 的判据是「有缩进」,不是「形态不同」。 */
|
|
113
|
+
/** 一行是否任务附属行(子项):缩进的 `- [ ]`。 */
|
|
120
114
|
export function isChildLine(l) {
|
|
121
|
-
|
|
122
|
-
return /^\s+-\s*\[[ x]\]/.test(l); // 缩进的 `- [ ]`(新形态)
|
|
115
|
+
return /^\s+-\s*\[[ x]\]/.test(l);
|
|
123
116
|
}
|
|
124
117
|
|
|
125
118
|
// ---------- 子项行(标准 md 嵌套列表:缩进 2 空格 + `- [ ]`) ----------
|
|
126
|
-
/** 子项 =
|
|
119
|
+
/** 子项 = 挂在任务下的可勾条目。
|
|
127
120
|
*
|
|
128
|
-
* 格式(2026-10-16
|
|
121
|
+
* 格式(2026-10-16 用户定,2026-10-20 彻底收口):标准 Markdown 嵌套列表。
|
|
129
122
|
* - [ ] [进行中] 主任务
|
|
130
123
|
* - [x] 已完成的子项
|
|
131
124
|
* - [ ] 还没做的子项
|
|
132
|
-
*
|
|
125
|
+
*
|
|
126
|
+
* 旧的 `↳ 断点: 内容` / `↳ [ ] 断点: 内容` 写法**已移除**,不再被解析。
|
|
127
|
+
* 存量靠 migrateLegacyChild 一次性迁移(那是全仓唯一还认得 `↳` 的地方)。 */
|
|
133
128
|
export const CHILD_INDENT = ' ';
|
|
134
129
|
|
|
130
|
+
/** 唯一还认得旧 `↳` 写法的地方 —— 存量迁移。
|
|
131
|
+
*
|
|
132
|
+
* 为何保留这一个函数(2026-10-20 用户定:彻底去掉断点写法,但存量要自动迁移):
|
|
133
|
+
* 其它项目的 todo.md 里已有旧行(实测 corp_agent 1 行、obsidian_bridge 1 行、
|
|
134
|
+
* zj_shop 6 行,形态还包括 `↳ 内容:` 这种变体)。直接删判据会让这些行变成
|
|
135
|
+
* 「裸文本」而被闸门拒写 → 那几个项目的 todo 全部锁死。
|
|
136
|
+
* 所以保留这一个「只读旧形态、产出新形态」的转换器,写回后旧行就不存在了。
|
|
137
|
+
*
|
|
138
|
+
* 返回 null = 不是旧形态(不用迁移);否则返回 ` - [ ] 内容` 形态的行。
|
|
139
|
+
* 裸 `↳ 内容`(无复选框)按未完成子项处理 —— 旧格式里它就是「待办的一句话」。
|
|
140
|
+
* `↳ [x] 内容` 保留已勾状态。
|
|
141
|
+
* `↳ [ ]` 后面没内容 = 旧一等任务的空壳,迁移成 `- [ ] (空)` 没意义 → 返回 null 由闸门拒。 */
|
|
142
|
+
export function migrateLegacyChild(l) {
|
|
143
|
+
const s = String(l || '');
|
|
144
|
+
const m = s.match(/^(\s*)↳\s*(?:\[([ x])\]\s*)?(?:断点|卡点|内容)[::]?\s*(.*)$/)
|
|
145
|
+
|| s.match(/^(\s*)↳\s*(?:\[([ x])\]\s*)?(.*)$/);
|
|
146
|
+
if (!m) return null;
|
|
147
|
+
const text = (m[3] || '').trim();
|
|
148
|
+
if (!text) return null; // 空壳子:不猜,交给闸门拒
|
|
149
|
+
const done = m[2] === 'x';
|
|
150
|
+
return `${m[1]}- ${done ? '[x]' : '[ ]'} ${text}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
135
153
|
/** 解析子项行 → { done, text, indent };不是子项返回 null。
|
|
136
|
-
*
|
|
154
|
+
* 只认标准 md(缩进的 `- [ ]`)。旧 `↳` 形态请先过 migrateLegacyChild。 */
|
|
137
155
|
export function parseChild(l) {
|
|
138
|
-
const
|
|
139
|
-
// 旧形态(↳ 开头):剥掉 ↳ 与可选复选框及可选的 `断点:` 标签
|
|
140
|
-
const old = s.match(/^(\s*)↳\s*(\[[ x]\])?\s*(?:断点[::]\s*)?(.*)$/);
|
|
141
|
-
if (old) {
|
|
142
|
-
// 勾选看 ↳ 后的那个复选框;旧格式无勾 ⇒ false
|
|
143
|
-
if (!old[3].trim()) return null; // `↳ [ ]` 后面没内容 = 不是子项(是旧一等任务的空壳)
|
|
144
|
-
return { done: old[2] === '[x]', text: old[3].trim(), indent: old[1].length };
|
|
145
|
-
}
|
|
146
|
-
// 新形态(缩进的 `- [ ]`)—— 必须缩进,否则那是顶层任务
|
|
147
|
-
const m = s.match(/^(\s+)-\s*\[([ x])\]\s*(.*)$/);
|
|
156
|
+
const m = String(l || '').match(/^(\s+)-\s*\[([ x])\]\s*(.*)$/);
|
|
148
157
|
if (!m) return null;
|
|
149
158
|
return { done: m[2] === 'x', text: m[3].trim(), indent: m[1].length };
|
|
150
159
|
}
|
|
@@ -161,7 +170,7 @@ export function renderChild(text, { done = false, indent = CHILD_INDENT } = {})
|
|
|
161
170
|
* 于是把总结写成「紧跟任务行的裸文本行」就三层全部放行:闸门不看、形状规则不管、
|
|
162
171
|
* lint 只查断点行 —— 实测 .agents/.brain/todo.md 被塞进 10 行实施报告而 lint 报 0 问题。
|
|
163
172
|
* 前面的修法都挂在「行的形态」上(先修超长断点、再修多句断点),换个形态就绕过。
|
|
164
|
-
* 改成白名单:Todo
|
|
173
|
+
* 改成白名单:Todo 区里除了任务行/子项/空行/标题,别的都是非法。
|
|
165
174
|
* 只在 `## Todo` 区生效 —— Done/Archived 区有日期分组与归档链接,判据不同。
|
|
166
175
|
* 返回 false 才叫非法。 */
|
|
167
176
|
export function isAllowedTodoLine(l) {
|
|
@@ -169,12 +178,12 @@ export function isAllowedTodoLine(l) {
|
|
|
169
178
|
if (!t) return true; // 空行
|
|
170
179
|
if (/^#{1,3}\s/.test(t)) return true; // `## Todo` / `### …` 标题
|
|
171
180
|
if (isTaskLine(l)) return true; // 任务行
|
|
172
|
-
if (isChildLine(l)) return true; //
|
|
181
|
+
if (isChildLine(l)) return true; // 缩进子项
|
|
173
182
|
return false;
|
|
174
183
|
}
|
|
175
184
|
|
|
176
185
|
/** 把 Done 区文本(bodyLines,不含 `## Done` 标题)解析成任务单元 [{ date, lines:[main,...children] }]。
|
|
177
|
-
* 剥 `### 日期`
|
|
186
|
+
* 剥 `### 日期` 分组标题与空行;任务行下紧跟的缩进子项并入该单元。 */
|
|
178
187
|
|
|
179
188
|
export function daysAgo(n, from) {
|
|
180
189
|
const [y, m, d] = String(from).split('-').map(Number);
|
|
@@ -196,9 +205,9 @@ export function isPlaceholder(line) {
|
|
|
196
205
|
|
|
197
206
|
export function extractId(text) {
|
|
198
207
|
// 先剥行首状态标记(`[进行中] <id>`),否则 id 会取成 "[进行中]"(2026-09-13 实测)。
|
|
199
|
-
// 再剥具体的 `- [ ] `
|
|
208
|
+
// 再剥具体的 `- [ ] ` 前缀(CLI 传完整行时)。
|
|
200
209
|
const t = String(text)
|
|
201
|
-
.replace(/^\s
|
|
210
|
+
.replace(/^\s*-\s*\[[ x]\]\s*/, '')
|
|
202
211
|
.replace(/^\[[^\]]+\]\s+/, '');
|
|
203
212
|
// id = 到第一个空白 / `[` 为止的连续字符(id 不允许含空格或方括号)。
|
|
204
213
|
// ★ 必须支持**中文 id**(2026-10-16 实测):原正则 `^[A-Za-z][\w-]*` 要求英文开头,
|
package/src/todo/spec.js
CHANGED
|
@@ -156,7 +156,7 @@ export const ENTRY_SHAPES = {
|
|
|
156
156
|
// 不是事后逐行猜(猜不出哪行是新的、哪行是旧的)。
|
|
157
157
|
test: (l) => {
|
|
158
158
|
if (/^#{1,3}\s/.test(l)) return null; // 标题行(## Done / ### 日期)不是条目
|
|
159
|
-
if (!/^-\s/.test(l)) return null; //
|
|
159
|
+
if (!/^-\s/.test(l)) return null; // 非条目行(如缩进子项)由下面单独判
|
|
160
160
|
// Done 区的归档行:`- [[log-日期]] 完成任务 N 条`。
|
|
161
161
|
// ★ 历史命名不统一:还有 `[[2026-09-12-todo归档]]` 这种旧写法(实测),
|
|
162
162
|
// 只认 `log-` 前缀会把它误报为「缺状态标记」。状归档行与任务行的
|
|
@@ -172,8 +172,8 @@ export const ENTRY_SHAPES = {
|
|
|
172
172
|
if (m[1] === ' ' && !/^\[[^\]]+\]\s*\S/.test(rest)) {
|
|
173
173
|
return '未完成任务应为 `- [ ] [状态] …`(状态:进行中/讨论中/滞留中)';
|
|
174
174
|
}
|
|
175
|
-
//
|
|
176
|
-
if (
|
|
175
|
+
// 子项不能挤在同一行(应另起 ` - [ ] `)
|
|
176
|
+
if (/↳/.test(rest) || /\[\s*\]\s*断点/.test(rest)) return '子项要另起一行写 ` - [ ] …`';
|
|
177
177
|
return null;
|
|
178
178
|
},
|
|
179
179
|
},
|
package/src/todo/structure.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { TODO_SECTIONS, H1_TO_FILE, TASK_STATES, RETIRED_SECTIONS } from './spec.js';
|
|
4
4
|
import { checkFileShape, violationSignatures, assertNoStrayBlank, assertTodoContent } from './validate.js';
|
|
5
|
-
import { trimBlank, sigOfLine, isPlaceholder } from './common.js';
|
|
5
|
+
import { trimBlank, sigOfLine, isPlaceholder, migrateLegacyChild } from './common.js';
|
|
6
6
|
|
|
7
7
|
export function todoTemplate() {
|
|
8
8
|
// 由 rebuildStructure 生成,保证“模板”与“重排结果”逐字节一致
|
|
@@ -156,8 +156,24 @@ function stripStrayBlanks(body, fixed) {
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
export function enforceBrainFormat(text, spec, prev) {
|
|
159
|
-
|
|
159
|
+
let src = String(text ?? '');
|
|
160
160
|
if (!src.trim()) return { text: src, fixed: [] };
|
|
161
|
+
const fixed = [];
|
|
162
|
+
// ★ 旧 `↳` 子项写法最先迁移(2026-10-20),必须跑在**一切校验之前**:
|
|
163
|
+
// 否则旧行会被 assertTodoContent 判成「裸文本」而拒写 —— 整个库变只读。
|
|
164
|
+
// (实测:真库形态的 ` ↳ 断点: …` 在 load 时就撞上了这条。)
|
|
165
|
+
if (spec?.h1 === '# 📋 Todo Board') {
|
|
166
|
+
const ls0 = src.split('\n');
|
|
167
|
+
let n0 = 0;
|
|
168
|
+
for (let i = 0; i < ls0.length; i++) {
|
|
169
|
+
const conv = migrateLegacyChild(ls0[i]);
|
|
170
|
+
if (conv !== null && conv !== ls0[i]) { ls0[i] = conv; n0++; }
|
|
171
|
+
}
|
|
172
|
+
if (n0) {
|
|
173
|
+
src = ls0.join('\n');
|
|
174
|
+
fixed.push(`旧 ↳ 子项写法 → 标准 md 缩进子项(${n0} 行)`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
161
177
|
// 内容闸门:形状归一之前先拒掉乱塞(超长断点/报告体),否则下面的 rebuild
|
|
162
178
|
// 会把它当「人自加的正文」好好保留下来 —— 越规整越难发现。
|
|
163
179
|
// ★ 存量宽容(2026-10-05 本机实测踩到):只拒「本次写入新引入」的违规;
|
|
@@ -203,7 +219,6 @@ export function enforceBrainFormat(text, spec, prev) {
|
|
|
203
219
|
);
|
|
204
220
|
}
|
|
205
221
|
};
|
|
206
|
-
const fixed = [];
|
|
207
222
|
// 旧标记 → 标准标记(H1 与分区/分组标题)。与 store.js 的 LEGACY_MARKS 同源,
|
|
208
223
|
// 由调用方通过 spec.renames 注入(todo.js 不反向依赖 store.js)。
|
|
209
224
|
|
|
@@ -233,13 +248,23 @@ export function enforceBrainFormat(text, spec, prev) {
|
|
|
233
248
|
// 底层 addTask/upsertTask 传的就是裸文本,它们是内部 API,不是 AI 入口)。
|
|
234
249
|
if (fname === 'todo.md') {
|
|
235
250
|
const ls = body.split('\n');
|
|
251
|
+
let migrated = 0;
|
|
236
252
|
for (let i = 0; i < ls.length; i++) {
|
|
253
|
+
// ★ 旧 `↳ 断点:` / `↳ [ ]` 写法 → 标准 md 缩进子项(2026-10-20)。
|
|
254
|
+
// 为何在闸门里做而不只靠 load:所有写入都过这里,迁移一次就永久生效。
|
|
255
|
+
// 保留这个转换器是「存量不能锁死」的代价 —— 它是全仓唯一还认得 ↳ 的地方。
|
|
256
|
+
const conv = migrateLegacyChild(ls[i]);
|
|
257
|
+
if (conv !== null) {
|
|
258
|
+
if (conv !== ls[i]) { ls[i] = conv; migrated++; }
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
237
261
|
// 裸任务行(`- [ ] id …` 无状态标记)→ 补默认「进行中」
|
|
238
262
|
if (/^- \[ \] /.test(ls[i]) && !/^- \[ \] \[[^\]]+\]/.test(ls[i])) {
|
|
239
263
|
const fixed2 = ensureStateMark(ls[i], '进行中');
|
|
240
264
|
if (fixed2 !== ls[i]) { ls[i] = fixed2; fixed.push('补回缺失的状态标记'); }
|
|
241
265
|
}
|
|
242
266
|
}
|
|
267
|
+
if (migrated) fixed.push(`旧 ↳ 子项写法 → 标准 md 缩进子项(${migrated} 行)`);
|
|
243
268
|
body = ls.join('\n');
|
|
244
269
|
}
|
|
245
270
|
|
|
@@ -277,13 +302,18 @@ function normalizeTodoIf(body, spec) {
|
|
|
277
302
|
}
|
|
278
303
|
|
|
279
304
|
export function normalizeTodo(text) {
|
|
280
|
-
|
|
305
|
+
// ★ 旧 `↳` 子项写法先就地迁移(2026-10-20)。
|
|
306
|
+
// 为何放在 normalizeTodo 而不只放闸门:读路径(readTodo)不过写入闸门,
|
|
307
|
+
// 不在这里迁,旧行就会以「看不见」的状态留在盘上(面板/看板都不认它)。
|
|
308
|
+
// 迁移后走到下面的「已是标准两区」早退分支也没关系 —— 内容已经是新的了。
|
|
309
|
+
const migrated = String(text).split('\n').map((l) => migrateLegacyChild(l) ?? l).join('\n');
|
|
310
|
+
const lines = migrated.split('\n');
|
|
281
311
|
const has = (name) => lines.some((l) => l.trim() === `## ${name}`);
|
|
282
312
|
// 已是标准两区制 = 有 Todo 且**没有**任何进行时老分区。
|
|
283
313
|
// 坑(2026-09-13):曾只判 has('Todo') 就 return —— 而最老格式里也可能有 `## Todo`
|
|
284
314
|
// (与 `## In Progress` 混用),于是旧文件永不迁移。
|
|
285
315
|
const legacy = ['Backlog', 'Today / In Progress', 'In Progress', 'Blocked'].some(has);
|
|
286
|
-
if (has('Todo') && !legacy) return
|
|
316
|
+
if (has('Todo') && !legacy) return migrated;
|
|
287
317
|
const out = ['# 📋 Todo Board'];
|
|
288
318
|
const grab = (name) => {
|
|
289
319
|
const items = [];
|
|
@@ -308,33 +338,31 @@ export function normalizeTodo(text) {
|
|
|
308
338
|
}
|
|
309
339
|
|
|
310
340
|
/** 给任务行补行首状态标记(已有则不覆盖)。`- [ ] id …` → `- [ ] [状态] id …`
|
|
311
|
-
*
|
|
341
|
+
* 缩进与 `- [ ]` 前缀都认(子项也要能补标记)。 */
|
|
312
342
|
export function ensureStateMark(line, state) {
|
|
313
|
-
const m = String(line).match(/^(\s
|
|
343
|
+
const m = String(line).match(/^(\s*-\s*\[[ x]\]\s*)(\[[^\]]+\]\s+)?(.*)$/);
|
|
314
344
|
if (!m) return line;
|
|
315
345
|
if (m[2] && TASK_STATES.includes(m[2].trim().replace(/[\x5B\x5D]/g, ''))) return line;
|
|
316
346
|
return `${m[1]}[${state}] ${m[3]}`;
|
|
317
347
|
}
|
|
318
348
|
|
|
319
|
-
/**
|
|
349
|
+
/** 读任务行的状态标记;缩进与 `- [ ]` 前缀都认。 */
|
|
320
350
|
export function stateOfTaskLine(line) {
|
|
321
|
-
const m = String(line).match(/^\s
|
|
351
|
+
const m = String(line).match(/^\s*-\s*\[[ x]\]\s*\[([^\]]+)\]/);
|
|
322
352
|
return m && TASK_STATES.includes(m[1]) ? m[1] : null;
|
|
323
353
|
}
|
|
324
354
|
|
|
325
355
|
/** 剥掉任务行的状态标记(`- [ ] [进行中] id …` → `- [ ] id …`),保留前缀与缩进。 */
|
|
326
356
|
|
|
327
357
|
export function stripStateMark(line) {
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
return String(line).replace(/^(\s*(?:-\s*\[[ x]\]|↳\s*\[[ x]\])\s*)\[[^\]]+\]\s*/, '$1');
|
|
358
|
+
// 前缀只剥 `- [ ] `(缩进与状态标记的位置保留)。
|
|
359
|
+
// 旧正则里带 `↳ [ ]` 分支已在 2026-10-20 随旧写法一并移除。
|
|
360
|
+
return String(line).replace(/^(\s*-\s*\[[ x]\]\s*)\[[^\]]+\]\s*/, '$1');
|
|
332
361
|
}
|
|
333
362
|
|
|
334
|
-
/** 把未完成任务行的复选框从 `[ ]` 翻到 `[x]`(保留原前缀与缩进)。
|
|
335
|
-
* 单独抽出:断点任务的 `↳ ` 前缀 + 缩进必须原样保留,用固定的 `- [ ]` 字面替换会漏。 */
|
|
363
|
+
/** 把未完成任务行的复选框从 `[ ]` 翻到 `[x]`(保留原前缀与缩进)。 */
|
|
336
364
|
export function checkTaskBox(line) {
|
|
337
|
-
return String(line).replace(/^(\s
|
|
365
|
+
return String(line).replace(/^(\s*-\s*)\[ \]/, '$1[x]');
|
|
338
366
|
}
|
|
339
367
|
|
|
340
368
|
/** 从已完成任务行提取 `(完成 YYYY-MM-DD)` 日期;无则返回 ''。兼容中英文括号。 */
|
package/src/todo/tasks.js
CHANGED
|
@@ -45,9 +45,8 @@ export function setStateMark(text, id, state) {
|
|
|
45
45
|
const cur = lines[idx];
|
|
46
46
|
if (stateOfTaskLine(cur) === state) return { text, changed: [] };
|
|
47
47
|
const raw = stripStateMark(cur); // 先剥旧标记,再补新的
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
lines[idx] = raw.replace(/^(\s*(?:-\s*\[[ x]\]|↳\s*\[[ x]\])\s*)/, `$1[${state}] `);
|
|
48
|
+
// 前缀统一为 `- [ ]`(缩进保留);旧 `↳` 写法已由 migrateLegacyChild 迁移掉。
|
|
49
|
+
lines[idx] = raw.replace(/^(\s*-\s*\[[ x]\]\s*)/, `$1[${state}] `);
|
|
51
50
|
return { text: lines.join('\n'), changed: [`${id} 状态 → ${state}`] };
|
|
52
51
|
}
|
|
53
52
|
|
|
@@ -121,19 +120,18 @@ export async function upsertTask(brainRoot, { section, text, session = '' }) {
|
|
|
121
120
|
: { file: p, text, updated: false };
|
|
122
121
|
}
|
|
123
122
|
|
|
124
|
-
/** 从任务文本提取幂等键(行首 id,如 TASK-1 / T-2 / fix-hook)。
|
|
125
|
-
* 两种前缀都认:`- [ ] `(普通任务)与 `↳ [ ] `(断点任务)。缩进不影响取 id。 */
|
|
123
|
+
/** 从任务文本提取幂等键(行首 id,如 TASK-1 / T-2 / fix-hook)。 */
|
|
126
124
|
|
|
127
125
|
export function idOfTaskLine(line) {
|
|
128
126
|
if (!line) return null;
|
|
129
127
|
// 两区制下行首可带状态标记(`- [ ] [进行中] <id> …`)—— 必须先剥掉再取 id,
|
|
130
128
|
// 否则 id 会被读成 "[进行中]",findTaskLine 全等比对永远落空(2026-09-13 实测)。
|
|
131
|
-
// 前缀允许缩进(` - [ ]
|
|
132
|
-
const m = line.match(/^\s
|
|
129
|
+
// 前缀允许缩进(` - [ ]`)—— 子任务天然带缩进。
|
|
130
|
+
const m = line.match(/^\s*-\s*\[ \]\s*(?:\[[^\]]+\]\s*)?(\S+)/);
|
|
133
131
|
return m ? m[1].replace(/\u200b/g, '') : null;
|
|
134
132
|
}
|
|
135
133
|
|
|
136
|
-
/** 定位 id
|
|
134
|
+
/** 定位 id 对应的未完成任务行下标;子项行不算独立任务。
|
|
137
135
|
* 全等比对,不用 includes(否则 T1 会误命中 T11)。 */
|
|
138
136
|
|
|
139
137
|
export function findTaskLine(lines, id) {
|
|
@@ -160,8 +158,7 @@ function childRange(lines, idx) {
|
|
|
160
158
|
*
|
|
161
159
|
* 语义(2026-10-16 用户定):子项就是 todo 的嵌套子任务 —— 可多条、可各自勾选。
|
|
162
160
|
* `abs todo note <id> "…"` **追加**一条(不再覆盖)—— 用户原话「不限,写多少算多少」。
|
|
163
|
-
* 仍保留幂等:与最后一条完全同文就不重复追加(防手滑重复调)。
|
|
164
|
-
* 旧的 `↳ 断点:` 存量行不动(parseChild 认得,继续显示);新写一律用标准 md。 */
|
|
161
|
+
* 仍保留幂等:与最后一条完全同文就不重复追加(防手滑重复调)。 */
|
|
165
162
|
export async function setBreakpoint(brainRoot, { id, text }) {
|
|
166
163
|
const p = await ensureTodo(brainRoot);
|
|
167
164
|
let added = false;
|
|
@@ -187,8 +184,7 @@ export async function setBreakpoint(brainRoot, { id, text }) {
|
|
|
187
184
|
/** 勾/取消勾子项:`- [ ] …` ↔ `- [x] …`。
|
|
188
185
|
*
|
|
189
186
|
* 定位:子项没有自己的 id(身份 = 「某任务的第 N 条子项」),故按 n 定位(1-based)。
|
|
190
|
-
* n 缺省 = 勾最后一条(最常用的「刚写的这条做完了」)。
|
|
191
|
-
* 旧格式 `↳ 断点:` 也能勾 —— 勾一下就地升级成标准 md 形态。 */
|
|
187
|
+
* n 缺省 = 勾最后一条(最常用的「刚写的这条做完了」)。 */
|
|
192
188
|
export async function setBreakpointDone(brainRoot, { id, n, done = true }) {
|
|
193
189
|
const p = await ensureTodo(brainRoot);
|
|
194
190
|
let picked = null;
|
package/src/todo/validate.js
CHANGED
|
@@ -91,12 +91,11 @@ export function checkFileShape(file, text, { lintMode = false } = {}) {
|
|
|
91
91
|
let p = i - 1;
|
|
92
92
|
while (p >= 0 && !lines[p].trim()) p--;
|
|
93
93
|
const prev = p >= 0 ? lines[p] : null;
|
|
94
|
-
// 父行可以是:顶层任务 `- [x]
|
|
95
|
-
//
|
|
94
|
+
// 父行可以是:顶层任务 `- [x]` 或子任务 ` - [x]`。
|
|
95
|
+
// (旧 `↳ [ ]` 形态已由 migrateLegacyChild 迁移,不再存在)
|
|
96
96
|
const ok = prev !== null && (
|
|
97
97
|
/^- \[[ x]\]/.test(prev) ||
|
|
98
|
-
/^\s+- \[[ x]\]/.test(prev)
|
|
99
|
-
/^\s*↳\s*\[[ x]\]/.test(prev)
|
|
98
|
+
/^\s+- \[[ x]\]/.test(prev)
|
|
100
99
|
);
|
|
101
100
|
if (!ok) {
|
|
102
101
|
issues.push({
|
|
@@ -233,28 +232,28 @@ export function assertTodoContent(text, prevSig = null) {
|
|
|
233
232
|
const body = bp.text;
|
|
234
233
|
if (body.length > BREAKPOINT_MAX) {
|
|
235
234
|
throw new Error(
|
|
236
|
-
`✗
|
|
235
|
+
`✗ 子项太长(第 ${lineNo} 行 ${body.length} 字 > ${BREAKPOINT_MAX})—— 子项只写「改到哪个文件哪一步」。\n` +
|
|
237
236
|
` 写不下的内容请分流:实施报告/验证数据 → abs log;经验与坑 → abs note;长设计 → .brain/sources/ 页。\n` +
|
|
238
237
|
` 原文开头: ${body.slice(0, 60)}...`,
|
|
239
238
|
);
|
|
240
239
|
}
|
|
241
240
|
if (REPORT_MARKERS.some((re) => re.test(body)) || sentenceCount(body) > 2) {
|
|
242
241
|
throw new Error(
|
|
243
|
-
`✗
|
|
242
|
+
`✗ 子项里像是塞了报告/清单(第 ${lineNo} 行)—— 子项是一句话(最多两句),不是文档。\n` +
|
|
244
243
|
` todo 只能加 todo:要做的拆成任务,做完的写 abs log,经验写 abs note。\n` +
|
|
245
244
|
` 原文开头: ${body.slice(0, 60)}...`,
|
|
246
245
|
);
|
|
247
246
|
}
|
|
248
247
|
continue;
|
|
249
248
|
}
|
|
250
|
-
// 裸文本行(2026-10-11):Todo
|
|
249
|
+
// 裸文本行(2026-10-11):Todo 区里既不是任务行也不是子项的行 = 总结/报告/清单。
|
|
251
250
|
// 前面的修法都挂在「行的形态」上(超长断点、多句断点),把总结写成裸文本行就全部绕过
|
|
252
251
|
// ——同一个坑第三次复发。改成白名单后,换形态也落回这里。
|
|
253
252
|
// 按区判:Todo 区才管;Done/Archived 区有日期分组与归档链接,判据不同。
|
|
254
253
|
// 存量行(prevSig)放行,交给 lint 报 —— 否则老库直接被锁死写不进去。
|
|
255
254
|
if (todoSectionOf(lines, i) === 'Todo' && !isAllowedTodoLine(l) && !isLegacy(l)) {
|
|
256
255
|
throw new Error(
|
|
257
|
-
`✗ todo 的 Todo 区不允许裸文本(第 ${lineNo} 行)—— 这里只放任务行(- [ ]
|
|
256
|
+
`✗ todo 的 Todo 区不允许裸文本(第 ${lineNo} 行)—— 这里只放任务行(- [ ] …)与子项( - [ ] …)。\n` +
|
|
258
257
|
` 「总结/实施报告/清单」请分流:流水与测试数据 → abs log;经验与坑 → abs note;长设计与方案 → .brain/sources/ 页。\n` +
|
|
259
258
|
` 原文开头: ${l.trim().slice(0, 60)}...`,
|
|
260
259
|
);
|
package/src/todo.js
CHANGED
|
@@ -8,3 +8,4 @@ export { todoTemplate, rebuildStructure, dropRetiredSections, enforceBrainFormat
|
|
|
8
8
|
export { checkFileShape, violationSignatures, assertNoStrayBlank, assertTodoContent } from './todo/validate.js';
|
|
9
9
|
export { doneDateOf, doneKindOf, withDoneKind, collapseDone, archiveDoneInText, renderArchiveBody, upsertArchiveSection, groupDoneSection, insertDoneGrouped } from './todo/archive.js';
|
|
10
10
|
export { readTodo, ensureTodo, setStateMark, addTask, upsertTask, idOfTaskLine, findTaskLine, setBreakpoint, setBreakpointDone, setMemo, readMemo, boardText } from './todo/tasks.js';
|
|
11
|
+
export { migrateLegacyChild, isTaskLine, isChildLine, CHILD_INDENT } from './todo/common.js';
|
package/src/wrapup.js
CHANGED
|
@@ -37,7 +37,7 @@ async function rotateIfNeeded(p) {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/** 从 todo 文本/快照块提取任务清单。兼容顶层(todo.md `- [ ]`)与缩进(快照 ` - [ ]`)两种行。
|
|
40
|
-
* body = 去掉 `- [ ]` 前缀、`(认领|完成 date)` 标注后的核心文本。bp 去掉
|
|
40
|
+
* body = 去掉 `- [ ]` 前缀、`(认领|完成 date)` 标注后的核心文本。bp 去掉 `[ ]`/`[x]` 勾选前缀。
|
|
41
41
|
* Done 区(## Done 下)不采。返回 [{ body, bp }],bp 为附属断点数组(或空)。 */
|
|
42
42
|
export function extractOpenTasks(todoText) {
|
|
43
43
|
const lines = String(todoText || '').split('\n');
|
|
@@ -60,16 +60,18 @@ export function extractOpenTasks(todoText) {
|
|
|
60
60
|
.trim();
|
|
61
61
|
if (taskBody && !isPlaceholderBody(taskBody)) {
|
|
62
62
|
const bp = [];
|
|
63
|
-
|
|
63
|
+
// 子项:缩进 `- [ ]`(标准 md)。旧快照里的 `↳` 行也能读 —— 那是历史
|
|
64
|
+
// wrapup.log 的序列化格式,不能因为改了新格式就让旧快照的日志读不出来。
|
|
65
|
+
while (i + 1 < lines.length && (/^\s+-\s*\[[ x]\]/.test(lines[i + 1]) || /^\s*↳/.test(lines[i + 1]))) {
|
|
64
66
|
i++;
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
+
// 子项快照带上勾选标记(`[ ] 内容` / `[x] 内容`)—— 快照是给人看的,
|
|
68
|
+
// 不保留勾选就分不清「当时已完成」与「还没做」。
|
|
67
69
|
const parsed = parseChild(lines[i]);
|
|
68
70
|
if (parsed) {
|
|
69
71
|
bp.push(`${parsed.done ? '[x]' : '[ ]'} ${parsed.text}`);
|
|
70
72
|
} else {
|
|
71
|
-
//
|
|
72
|
-
bp.push(lines[i].trim().replace(/^↳\s*(
|
|
73
|
+
// 旧快照的非标准 ↳ 行:剥掉 ↳ 与可选标签,内容原样保留
|
|
74
|
+
bp.push(lines[i].trim().replace(/^↳\s*(断点|卡点|内容)?[::]?\s*/, ''));
|
|
73
75
|
}
|
|
74
76
|
}
|
|
75
77
|
out.push({ body: taskBody, bp });
|
|
@@ -85,13 +87,16 @@ function isPlaceholderBody(body) {
|
|
|
85
87
|
return /^(待办任务|无|(无)|None|null)$/.test(body) || /^待办任务/.test(body);
|
|
86
88
|
}
|
|
87
89
|
|
|
88
|
-
/**
|
|
89
|
-
*
|
|
90
|
+
/** 序列化一个快照块:首行时间戳+归属,后随未完成任务(主体+子项)。
|
|
91
|
+
* 子项 bp 元素已自带勾选前缀(`[ ] 内容` / `[x] 内容`,见 extractOpenTasks)。
|
|
92
|
+
* 新写入一律用标准 md 缩进子项(旧 `↳ 断点:` 形态已废弃)。 */
|
|
90
93
|
function blockText(root, tasks) {
|
|
91
94
|
const head = `[${localStamp()}] wrapup proj=${root}`;
|
|
92
95
|
if (!tasks.length) return head; // 无滞留,仍记一行「干净结束」
|
|
93
96
|
const rows = tasks.map((t) => {
|
|
94
|
-
const bps = t.bp.map((b) => (b.startsWith('[')
|
|
97
|
+
const bps = t.bp.map((b) => (b.startsWith('[')
|
|
98
|
+
? ` - ${b}`
|
|
99
|
+
: ` - [ ] ${b}`));
|
|
95
100
|
return ` - [ ] ${t.body}${bps.length ? '\n' + bps.join('\n') : ''}`;
|
|
96
101
|
});
|
|
97
102
|
return [head, ...rows].join('\n');
|
|
@@ -119,13 +124,14 @@ export function parseWrapup(text) {
|
|
|
119
124
|
}
|
|
120
125
|
continue;
|
|
121
126
|
}
|
|
122
|
-
|
|
127
|
+
// 子项:新快照写缩进 `- [ ]`;旧快照(历史 wrapup.log)写的是 `↳ …`,两者都要读
|
|
128
|
+
const bm = line.match(/^\s+-\s*\[[ x]\]/) || line.match(/^\s*↳/);
|
|
123
129
|
if (bm && cur.tasks.length) {
|
|
124
130
|
// 回读时统一成 `[ ] 内容` / `[x] 内容` 形态(新写的就是这个;旧快照无勾 → 当未勾)
|
|
125
131
|
const parsed = parseChild(line);
|
|
126
132
|
cur.tasks[cur.tasks.length - 1].bp.push(parsed
|
|
127
133
|
? `${parsed.done ? '[x]' : '[ ]'} ${parsed.text}`
|
|
128
|
-
: line.trim().replace(/^↳\s*(
|
|
134
|
+
: line.trim().replace(/^↳\s*(断点|卡点|内容)?[::]?\s*/, ''));
|
|
129
135
|
}
|
|
130
136
|
}
|
|
131
137
|
return byProj;
|