@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 CHANGED
@@ -62,16 +62,15 @@ pi 与 omp 宿主额外多一层 UI(其它宿主没有):**编辑器上方
62
62
  ─────────────────────────────────────────────────
63
63
  📋 todo (3) — fanchao · ☕ 靠咖啡续命
64
64
  ├─ [进行中] some-task ●●○ — 干活中
65
- │ ↳ 断点: hooks/abs.pi.ts:120
65
+ │ - [ ] hooks/abs.pi.ts:120
66
66
  ├─ [滞留中] waiting — 等外部输入
67
67
  └─ [进行中] another ●●● — …
68
68
  ```
69
69
 
70
70
  - **只显示未完成**,Done 归计数不占位(数量在标题里)
71
71
  - **按当前使用者过滤**:显示 `[[我]]` 的 + 没标作者的(老任务/手写),别人的不显示
72
- - **断点行 `↳` 挂在父任务下**;`├─` / `└─` 表结构
73
- - **断点也可以是任务**:` ↳ [ ] <id> — 说明` 与普通任务同等对待(上看板、可 `done` / `note` / `state`);
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 bp-done TASK-1 [--n 2] # 勾子项(n=第几条,缺省最后一条)
113
- abs todo bp-undone TASK-1 [--n 2] # 取消勾(勾错了退回)
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 bp-done <id> 勾断点 → `↳ [x] 断点:`(做完落一句)',
316
- ' abs todo bp-undone <id> 取消勾(勾错了退回)',
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
- 'bp-done': 'bp-done', // 勾断点:`↳ [ ] 断点:` → `↳ [x] 断点:`
382
- 'bp-undone': 'bp-undone', // 取消勾断点(勾错了退回)
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 / bp-done / bp-undone / memo / state / done / archive\n` +
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 === 'bp-done' || action === 'bp-undone') {
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 看板(含断点/Next-Step)。先 resolve 得到项目,再传其根目录。',
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`)/ bp_done、bp_undone 勾/取消勾子项(n=第几条,缺省最后一条)/ memo 任务随手记(多句/追加/不上看板)/ state 改状态 / done 完成归位 Done(done 时 note=结语文字,as=结语类型:落地|否决|仅方案)。必填: cwd, action, id。多会话并行时,start/add 带上 session(值见 system prompt 的 [会话] 段)以标记「这条任务谁在做」。',
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', 'bp_done', 'bp_undone', 'memo', 'state']),
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('bp_done/bp_undone 用:第几条子项(1-based,缺省=最后一条)'),
120
- cwd: z.string().describe('项目根目录(.brain/ 所在处)'), note: z.string().optional().describe('add/start=做什么; note=断点(文件/到哪步,一句话); memo=随手记(可多句、不定长,追加式;不传=查看已有笔记); state=进行中|讨论中|滞留中|搁置(搁置=用户改方向/不做了;滞留中=还要做只是卡住); done=结语文字'),
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
- // bp_done → bp-done(MCP 参数名不用连字符,内部 action 用)
134
- const act2 = act === 'bp_done' ? 'bp-done' : act === 'bp_undone' ? 'bp-undone' : 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
- // 两种前缀都收:`- [ ]`(普通任务)与 `↳ [ ]`(断点任务,2026-10-16 新增)。
376
- // 断点任务必须显示 —— 它就是一等待任务(写得进去、看得见、能 done)。
377
- const m = line.match(/^\s*(?:-\s*\[ \]|↳\s*\[ \])\s*(?:\[([^\]]+)\]\s*)?(\S+)\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
- const s = String(l || '')
421
- const old = s.match(/^(\s*)↳\s*(?:\[[ x]\]\s*)?(?:断点[::]\s*)?(.*)$/)
422
- if (old) {
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 会话永远不记断点(见 concept opencode-inject-channel-verdict
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
- /** 本会话是否已记过自动任务断点。模块级(重复注册时闭包 flag 不共享 —— 同 agentEndSeen 的坑)。
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
- // —— 实测:别人任务的断点会显示在我的任务下面(2026-10-03 审查发现)。
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
- // 两种前缀都收:`- [ ]`(普通任务)与 `↳ [ ]`(断点任务,2026-10-16 新增)。
747
- // 断点任务必须显示 —— 它就是一等等任务(写得进去、看得见、能 done),
746
+ // 只认 `- [ ]`(2026-10-20 旧 `↳ [ ]` 写法已废弃)。
747
+ // 子项任务必须显示 —— 它就是一等等任务(写得进去、看得见、能 done),
748
748
  // 否则落回「静默死区」(用户在 .brain 里报的原症状)。
749
- const m = line.match(/^\s*(?:-\s*\[ \]|↳\s*\[ \])\s*(?:\[([^\]]+)\]\s*)?(\S+)\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
- const s = String(l || '')
806
- const old = s.match(/^(\s*)↳\s*(?:\[[ x]\]\s*)?(?:断点[::]\s*)?(.*)$/)
807
- if (old) {
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
- // 自动登记断点(2026-10-05):真的动文件时,把「改到哪个」记进本会话的自动任务。
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
- // 断点改由 agent 在用 abs_task 时自己写(那时它本来就在跑,不额外起进程)。
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 把当前项目未完成任务+断点追加到 wrapup.log (跨会话收尾保险)。
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.19.6",
3
+ "version": "1.20.0",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 日期组顶部,断点(`↳` 行)随迁;超 3 天**且整天已完成**的组由 `abs wrapup` 迁进当天快照归档段(任一天还有未完成则整天不归档,不拆半天)。
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>`;没做完的补断点 `abs todo note <id> --note "接到哪"`。
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` 断点、卡住的转 `滞留中`。别让干完的事留在 Todo。
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 把「未完成 + 断点」快照进 `~/.abs/log/wrapup.log`,下会话 `abs load` 自动把滞留顶到顶部。**不往对话里插收尾提醒** —— 插 turn 就是打断。
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} (断点 ${bp.length} 字 > ${LINT_BREAKPOINT_MAX};` +
153
- `断点只写「改到哪个文件哪一步」,报告迁 sessions/ 或 sources/)`,
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('[') ? `↳ ${b}` : `↳ 断点: ${b}`)).join('\n ')}` : '';
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
- const m = line.match(/^\s*(?:↳\s*断点:\s*)?(?:阻塞|等待|等)[::]\s*(.+)$/);
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
- // 勾/取消勾断点:断点是待办,做完要勾(2026-10-16)。
961
+ // 勾/取消勾子项:子项也是待办,做完要勾。
961
962
  // 单独两个子命令而不是 `--done true/false` —— 勾与取消是两件事,各自一个动词更顺手。
962
- if (action === 'bp-done' || action === 'bp-undone') {
963
- const r = await setBreakpointDone(root, { id, n, done: action === 'bp-done' });
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
- if (nx.trimStart().startsWith('↳')) {
1043
- const ch = lines[++i];
1044
- if (/^\s*↳\s*\[ \]/.test(ch)) leftover.push(ch.trim().replace(/^\s*↳\s*\[ \]\s*/, ''));
1045
- else bp.push(ch);
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 bp-done ${wantId} (勾最后一条;指定第 N 条:bp-done ${wantId} --n N)\n` +
1075
+ ` • 做完了 → abs todo child-done ${wantId} (勾最后一条;指定第 N 条:child-done ${wantId} --n N)\n` +
1069
1076
  ` • 不做了 → 删掉那几行子项,再 abs todo done ${wantId}`,
1070
1077
  );
1071
1078
  }
@@ -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 l of bodyLines) {
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 嵌套下子项本身就是任务行 —— 靠缩进区分层级,不靠形态。)
@@ -104,47 +104,56 @@ export function sentenceCount(s) {
104
104
  * 子任务:缩进的 `- [ ]` 行(标准 Markdown 嵌套列表,2 空格一层)。
105
105
  * 两者**都是任务行**(子任务可各自勾选);层级的区分看缩进,不在本函数。
106
106
  *
107
- * 为何也收 `↳ [ ]`:2026-10-16 早上刚把断点任务升级成一等任务(存量数据里有),
108
- * 现在改用标准 md 嵌套列表,旧形态继续认(存量不能变“看不见”)。 */
107
+ * 2026-10-20:旧 `↳ [ ]` 写法已彻底废弃,本函数不再认它。
108
+ * 存量由 migrateLegacyChild 在 load 时就地转成标准 md(见下)。 */
109
109
  export function isTaskLine(l) {
110
- // 先排旧式断点行:`↳ [ ] 断点: x` 形似任务行,但它带 `断点:` ⇒ 是子项文本,不是任务。
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
- if (/^\s*↳/.test(l)) return true; // 旧形态(含 `↳ [ ]` 断点任务)
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 用户定):标准 Markdown 嵌套列表,不搞自定义前缀。
121
+ * 格式(2026-10-16 用户定,2026-10-20 彻底收口):标准 Markdown 嵌套列表。
129
122
  * - [ ] [进行中] 主任务
130
123
  * - [x] 已完成的子项
131
124
  * - [ ] 还没做的子项
132
- * 旧格式 `↳ 断点: 内容` **继续承认**(存量数据不能坏),新写入一律用标准 md。 */
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 s = String(l || '');
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
- // 再剥具体的 `- [ ] ` / `↳ [ ] ` 前缀(CLI 传完整行时)。
208
+ // 再剥具体的 `- [ ] ` 前缀(CLI 传完整行时)。
200
209
  const t = String(text)
201
- .replace(/^\s*(?:-\s*\[[ x]\]|↳\s*\[[ x]\])\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 (/↳\s*(?:\[[ x]\]\s*)?断点[::]/.test(rest)) return '断点要另起一行写 ` ↳ [ ] 断点: …`';
175
+ // 子项不能挤在同一行(应另起 ` - [ ] `)
176
+ if (/↳/.test(rest) || /\[\s*\]\s*断点/.test(rest)) return '子项要另起一行写 ` - [ ] …`';
177
177
  return null;
178
178
  },
179
179
  },
@@ -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
- const src = String(text ?? '');
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
- const lines = text.split('\n');
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 text;
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*(?:-\s*\[[ x]\]|↳\s*\[[ x]\])\s*)(\[[^\]]+\]\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*(?:-\s*\[[ x]\]|↳\s*\[[ x]\])\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
- // 原正则写死 `^- \[[ x]\] ` —— 断点任务(2026-10-16 新增形态)匹配不上,
330
- // 后果是状态标记没被剥掉、markDone 的 `- [ ]`→`- [x]` 也落空(实测:完成行仍是 `[ ]`)。
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*(?:-\s*|↳\s*))\[ \]/, '$1[x]');
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
- // 原正则写死 `^(- \[[ x]\] )`,断点任务上补不回标记(2026-10-16 实测:state 报成功但盘上没变)。
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*(?:-\s*\[ \]|↳\s*\[ \])\s*(?:\[[^\]]+\]\s*)?(\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;
@@ -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]`、子任务 ` - [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
- `✗ 断点太长(第 ${lineNo} 行 ${body.length} 字 > ${BREAKPOINT_MAX})—— 断点只写「改到哪个文件哪一步」。\n` +
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
- `✗ 断点里像是塞了报告/清单(第 ${lineNo} 行)—— 断点是一句话(最多两句),不是文档。\n` +
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} 行)—— 这里只放任务行(- [ ] …)与断点行(↳ 断点: …)。\n` +
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
- while (i + 1 < lines.length && /^\s*↳/.test(lines[i + 1])) {
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
- // 断点快照带上勾选标记(`[ ] 内容` / `[x] 内容`)—— 快照是给人看的,
66
- // 不保留勾选就分不清「当时已完成」与「还没做」(2026-10-16 断点可勾后必需)。
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*(断点|卡点)?[::]?\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
- * 断点 bp 元素已自带勾选前缀(`[ ] 内容` / `[x] 内容`,见 extractOpenTasks)。 */
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('[') ? ` ↳ ${b}` : ` ↳ 断点: ${b}`));
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
- const bm = line.match(/^\s*↳/);
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*(断点|卡点)?[::]?\s*/, ''));
134
+ : line.trim().replace(/^↳\s*(断点|卡点|内容)?[::]?\s*/, ''));
129
135
  }
130
136
  }
131
137
  return byProj;