@fanchao8609/agent_brain_sync 1.18.0 → 1.19.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 fanchao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -108,7 +108,9 @@ abs load # 开机读状态(Rules + 图谱计数 + 看板
108
108
  abs todo # 看板 Todo / Done(行首带状态标记)
109
109
 
110
110
  abs todo add TASK-1 --note "要做什么"
111
- abs todo note TASK-1 --note "改到 X 文件 L40" # 实时断点(一句话,上看板)
111
+ 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] # 取消勾(勾错了退回)
112
114
  abs todo memo TASK-1 "随手记一段(可多句,不上看板)" # 任务笔记 → sessions/;无参=查看
113
115
  abs todo state TASK-1 --note "进行中|讨论中|滞留中" # 改行首状态标记
114
116
  abs todo done TASK-1
@@ -129,6 +131,9 @@ abs update # 升级到最新版并刷新四宿主
129
131
  ```
130
132
 
131
133
  > `abs todo start` 与 `abs todo add` 等价(都登记任务)。
134
+ > **子项就是标准 Markdown 嵌套列表**:任务下缩进 2 空格的 `- [ ] …`,可多条、可各自勾选;
135
+ > 面板按缩进画树线,归档时原样保留缩进。旧格式 `↳ 断点:` 仍能读(存量不坏)。
136
+ > 任务 id 不能含空格(用连字符),含空格会被明确拒掉。
132
137
  > 旧版 `abs task ...` / `abs board` 已废,打它们会报「未知命令」并退出码 1。
133
138
  > `abs wrapup` / `abs teardown-check` 是 hook 内部命令,无需手动调用。
134
139
  > **作者名要填真的**:`tester` / `foo` / `aaa` 这类占位名会被拒 —— 因为 `{user}` 是全局单值,
package/bin/abs.js CHANGED
@@ -116,6 +116,8 @@ const FLAG_SPEC = {
116
116
  'title': { type: 'string' },
117
117
  'desc': { type: 'string' },
118
118
  'state': { type: 'string' },
119
+ // 勾子项用:第几条(1-based,缺省=最后一条)
120
+ 'n': { type: 'string' },
119
121
  // log 流水类型:note|dev|concept|ingest(cmdLog 会校枚举,非枚举直接报错)。
120
122
  // ★ 漏声明就是真 bug(2026-10-05 本机实测):不在 FLAG_SPEC 的选项会被静默丢掉,
121
123
  // 于是 `abs log "x" --kind note` 全写成默认的 dev,而 cmdLog 的枚举校验看不见它。
@@ -310,6 +312,8 @@ const subUsage = {
310
312
  ' abs todo add <id> [--note "做什么"] [--section 讨论中|滞留中|搁置]',
311
313
  ' abs todo rename <旧id> --note "<新id>" 改任务 id(起错名/不可读时)',
312
314
  ' abs todo note <id> --note "断点/进度"',
315
+ ' abs todo bp-done <id> 勾断点 → `↳ [x] 断点:`(做完落一句)',
316
+ ' abs todo bp-undone <id> 取消勾(勾错了退回)',
313
317
  ' abs todo memo <id> "随手记(可多句)" 任务笔记 → sessions/,不上看板;无文字=查看',
314
318
  ' abs todo state <id> --note 进行中|讨论中|滞留中|搁置',
315
319
  ' abs todo done <id> [--as 落地|否决|仅方案] [结语文字]',
@@ -374,6 +378,8 @@ const TODO_ACTIONS = {
374
378
  add: 'start',
375
379
  start: 'start', // add 的别名(老习惯保留)
376
380
  note: 'note',
381
+ 'bp-done': 'bp-done', // 勾断点:`↳ [ ] 断点:` → `↳ [x] 断点:`
382
+ 'bp-undone': 'bp-undone', // 取消勾断点(勾错了退回)
377
383
  memo: 'memo', // 任务随手记:多句/追加/不占看板(无文字时=查看)
378
384
  state: 'state', // 改行首状态标记:进行中|讨论中|滞留中|搁置(原地,不搬区)
379
385
  rename: 'rename', // 改任务 id:人工起错名、或旧 id 不可读时用
@@ -469,7 +475,7 @@ async function main() {
469
475
  if (!action) {
470
476
  throw new Error(
471
477
  `✗ 未知子命令 "${sub}"\n` +
472
- ` 可用: add / start / note / memo / state / done / archive\n` +
478
+ ` 可用: add / start / note / bp-done / bp-undone / memo / state / done / archive\n` +
473
479
  ` 看板: abs todo(不带参数)\n` +
474
480
  ` 登记任务: abs todo add <id> --note "做什么"`,
475
481
  );
@@ -492,6 +498,15 @@ async function main() {
492
498
  console.log(await cmdTask({ dir: opts.dir, action, id, note, session: opts.session }));
493
499
  break;
494
500
  }
501
+ // 勾/取消勾子项:只收 <id>(子项位置 = 「某任务的第 N 条」,没有自己的 id)
502
+ if (action === 'bp-done' || action === 'bp-undone') {
503
+ rejectExtra(rest2, `abs todo ${sub} <id> [--n N]`);
504
+ if (!id) throw new Error(`✗ 缺 <id>\n 用法: abs todo ${sub} <id> [--n N]`);
505
+ const n = opts.n ? Number(opts.n) : undefined;
506
+ if (opts.n && (!Number.isInteger(n) || n < 1)) throw new Error(`✗ --n 要是正整数(收到 "${opts.n}")`);
507
+ console.log(await cmdTask({ dir: opts.dir, action, id, n }));
508
+ break;
509
+ }
495
510
  rejectExtra(rest2, usage);
496
511
  if (!id) throw new Error(`✗ 缺 <id>\n 用法: ${usage}`);
497
512
  console.log(await cmdTask({ dir: opts.dir, action, id, section: opts.section, note: opts.note, as: opts.as, session: opts.session }));
package/bin/mcp.js CHANGED
@@ -112,12 +112,12 @@ tool(
112
112
 
113
113
  tool(
114
114
  'abs_task',
115
- '任务实时落盘(幂等键 = id)。start 登记进 Todo / note 补断点(改到哪文件哪行) / memo 任务随手记(多句/追加/不上看板)/ state 改状态 / done 完成归位 Done(done 时 note=结语文字,as=结语类型:落地|否决|仅方案)。必填: cwd, action, id。多会话并行时,start/add 带上 session(值见 system prompt 的 [会话] 段)以标记「这条任务谁在做」。',
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 的 [会话] 段)以标记「这条任务谁在做」。',
116
116
  {
117
- action: z.enum(['add', 'start', 'done', 'note', 'memo', 'state']),
117
+ action: z.enum(['add', 'start', 'done', 'note', 'bp_done', 'bp_undone', 'memo', 'state']),
118
118
  id: z.string().describe('任务幂等键,如 TASK-xxx 或子任务名'),
119
- cwd: z.string().describe('项目根目录(.brain/ 所在处)'),
120
- note: z.string().optional().describe('add/start=做什么; note=断点(文件/到哪步,一句话); memo=随手记(可多句、不定长,追加式;不传=查看已有笔记); state=进行中|讨论中|滞留中|搁置(搁置=用户改方向/不做了;滞留中=还要做只是卡住); done=结语文字'),
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=结语文字'),
121
121
  as: z.enum(['落地', '否决', '仅方案']).optional().describe('仅 done:结语类型。默认 落地。做了又撤/评估后不做用 否决,只设计过用 仅方案 —— 别让假【落地】污染看板'),
122
122
  // 会话 id:多会话并行时标记「这条任务是谁在做」。
123
123
  // 值取自 system prompt 里的 `[会话] 本会话 id = xxx`(hook 注入),由模型原样回传。
@@ -125,12 +125,14 @@ tool(
125
125
  // (协议没有,PI_* 环境变量也没有);模型本来就知道自己的 sid,直接收下最省。
126
126
  session: z.string().optional().describe('仅 start/add:本会话 id(取自 system prompt 的 [会话] 段)。多会话并行时用来标记「这条任务谁在做」'),
127
127
  },
128
- async ({ action, id, cwd, note, as, session }) => {
128
+ async ({ action, id, cwd, note, as, session, n }) => {
129
129
  const root = await findBrainRoot(cwd || process.cwd());
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
- return { content: [{ type: 'text', text: await cmdTask({ dir: root, action: act, id, note, as, session }) }] };
133
+ // bp_done → bp-done(MCP 参数名不用连字符,内部 action 用)
134
+ const act2 = act === 'bp_done' ? 'bp-done' : act === 'bp_undone' ? 'bp-undone' : act;
135
+ return { content: [{ type: 'text', text: await cmdTask({ dir: root, action: act2, id, note, as, session, n }) }] };
134
136
  }
135
137
  );
136
138
 
package/hooks/abs.omp.ts CHANGED
@@ -230,7 +230,9 @@ export function boardGuideline(md: string, who: string, touched: string[] = [],
230
230
  let tag = ''
231
231
  if (r.sid) tag = r.sid === mySid ? '[本会话] ' : `[会话 ${r.sid}] `
232
232
  const head = ` ${tag}${r.state ? `[${r.state}] ` : ''}${r.id}${r.desc ? ` — ${r.desc}` : ''}`
233
- return r.note ? `${head}\n ↳ ${r.note}` : head
233
+ // 子项带上(与面板同形),丢了等于对齐了也接不上
234
+ if (!r.kids || !r.kids.length) return head
235
+ return [head, ...r.kids.map((k) => ` - ${k.done ? '[x]' : '[ ]'} ${k.text}`)].join('\n')
234
236
  })
235
237
  const more = total > rows.length ? ` …另 ${total - rows.length} 条` : ''
236
238
  const touchLines = touched.length
@@ -349,12 +351,12 @@ export function pickNickname(rnd: () => number = Math.random): string {
349
351
  * (老任务/手写的没标,漏掉比多显示更糟)。标了别人的则不收。 */
350
352
  export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
351
353
  total: number
352
- rows: { state: string; id: string; desc: string; note: string; sid: string }[]
354
+ rows: { state: string; id: string; desc: string; kids: { done: boolean; text: string }[]; sid: string }[]
353
355
  hidden: number
354
356
  } {
355
357
  // 两阶段:先无过滤地按序解析(断点归属需要「上一条」是原文里真正的前一条),
356
358
  // 再按作者过滤。若边解析边过滤,被滤掉的条目下方的断点会挂到它上面那一条。
357
- type Row = { state: string; id: string; desc: string; note: string; authors: string[]; sid: string }
359
+ type Row = { state: string; id: string; desc: string; kids: { done: boolean; text: string }[]; authors: string[]; sid: string }
358
360
  const parsed: Row[] = []
359
361
  let inTodo = false
360
362
  for (const raw of String(md || '').split('\n')) {
@@ -362,11 +364,11 @@ export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
362
364
  if (/^##\s+Todo\s*$/.test(line)) { inTodo = true; continue }
363
365
  if (/^##\s+/.test(line)) { inTodo = false; continue }
364
366
  if (!inTodo) continue
365
- // 断点行:` ↳ 断点: …`(缩进)→ 挂到上一条任务
366
- const note = line.match(/^\s+↳\s*断点:\s*(.*)$/)
367
- if (note) {
367
+ // 子项行(标准 md:缩进的 `- [ ]`;旧格式 `↳ 断点:` 继续认)→ 挂到上一条任务
368
+ const kid = parseChildLine(line)
369
+ if (kid && kid.indent > 0) {
368
370
  const last = parsed[parsed.length - 1]
369
- if (last && !last.note) last.note = note[1].trim()
371
+ if (last) last.kids.push(kid)
370
372
  continue
371
373
  }
372
374
  // `- [ ] [状态] <id> …`;状态标记可能缺省。
@@ -386,12 +388,12 @@ export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
386
388
  .replace(/^\s*—\s*/, '')
387
389
  .replace(/\s*\(认领[^)]*\)\s*$/, '')
388
390
  .trim()
389
- parsed.push({ state, id, desc, note: '', authors, sid })
391
+ parsed.push({ state, id, desc, kids: [], authors, sid })
390
392
  }
391
393
  const mine = who
392
394
  ? parsed.filter((r) => r.authors.length === 0 || r.authors.includes(who))
393
395
  : parsed
394
- const rows = mine.map(({ state, id, desc, note, sid }) => ({ state, id, desc, note, sid }))
396
+ const rows = mine.map(({ state, id, desc, kids, sid }) => ({ state, id, desc, kids, sid }))
395
397
  return { total: rows.length, rows: rows.slice(0, max), hidden: Math.max(0, rows.length - max) }
396
398
  }
397
399
 
@@ -413,6 +415,19 @@ async function currentUser(): Promise<string> {
413
415
  /** 按显示宽度截断(自己实现,不 import 宿主 tui 包)。
414
416
  * 依赖宿主内部 alias 太脆 —— pi 版实测在测试环境会 ERR_MODULE_NOT_FOUND。
415
417
  * 宽度规则:CJK/全角记 2 列,其余记 1 列。 */
418
+ /** 解析子项行(与 src/todo/common.js 的 parseChild 同规则,双份实现 —— 见 pi 版说明)。 */
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*(.*)$/)
427
+ if (!m) return null
428
+ return { done: m[2] === 'x', text: m[3].trim(), indent: m[1].length }
429
+ }
430
+
416
431
  function dispWidth(s: string): number {
417
432
  let w = 0
418
433
  let i = 0
@@ -493,7 +508,7 @@ export function todoAnimFrame(phase: number): string {
493
508
  }
494
509
 
495
510
  export function renderPanelLines(
496
- data: { total: number; rows: { state: string; id: string; desc: string; note: string }[]; hidden: number },
511
+ data: { total: number; rows: { state: string; id: string; desc: string; kids?: { done: boolean; text: string }[] }[]; hidden: number },
497
512
  who: string,
498
513
  width: number,
499
514
  fg: (color: string, s: string) => string,
@@ -521,12 +536,21 @@ export function renderPanelLines(
521
536
  const isLast = i === lastIdx
522
537
  const branch = isLast ? '└─' : '├─'
523
538
  const anim = t.state === '进行中' && animPhase >= 0 ? ' ' + fg('accent', todoAnimFrame(animPhase)) : ''
524
- const head = `${fg('dim', branch)} ${fg(colorOf(t.state), `[${t.state}]`)} ${fg('text', t.id)}${anim}`
539
+ // 复选框 `[ ]`:面板是 todo.md 的只读视图,就该长成 todo 原貌。
540
+ const box = fg('dim', '[ ]')
541
+ const head = `${fg('dim', branch)} ${box} ${fg(colorOf(t.state), `[${t.state}]`)} ${fg('text', t.id)}${anim}`
525
542
  const desc = t.desc ? ` ${fg('dim', '—')} ${fg('muted', t.desc)}` : ''
526
543
  lines.push(clipToWidth(head + desc, width))
527
- if (t.note) {
528
- const cont = isLast ? ' ' : fg('dim', '│ ')
529
- lines.push(clipToWidth(`${cont} ${fg('dim', '↳ ' + t.note)}`, width))
544
+ if (t.kids && t.kids.length) {
545
+ // 子项:标准 md 嵌套列表 → 画成树
546
+ const stem = isLast ? ' ' : fg('dim', '│ ')
547
+ const n = t.kids.length
548
+ t.kids.forEach((k, ki) => {
549
+ const kb = ki === n - 1 ? '└─' : '├─'
550
+ const kbox = fg('dim', k.done ? '[x]' : '[ ]')
551
+ const ktext = fg(k.done ? 'dim' : 'muted', k.text)
552
+ lines.push(clipToWidth(`${stem} ${fg('dim', kb)} ${kbox} ${ktext}`, width))
553
+ })
530
554
  }
531
555
  })
532
556
 
package/hooks/abs.pi.ts CHANGED
@@ -336,8 +336,11 @@ export function boardGuideline(md: string, who: string, touched: string[] = [],
336
336
  let tag = ''
337
337
  if (r.sid) tag = r.sid === mySid ? '[本会话] ' : `[会话 ${r.sid}] `
338
338
  const head = ` ${tag}${r.state ? `[${r.state}] ` : ''}${r.id}${r.desc ? ` — ${r.desc}` : ''}`
339
- // 断点必须带上:它就是「改到哪一步」,丢了等于对齐了也接不上。
340
- return r.note ? `${head}\n ↳ ${r.note}` : head
339
+ // 子项必须带上:它就是「改到哪一步」,丢了等于对齐了也接不上(2026-10-05 实测)。
340
+ // 与面板同形(`- [ ]` 缩进),让 LLM 看到的就是文件里的结构。
341
+ if (!r.kids || !r.kids.length) return head
342
+ const kidLines = r.kids.map((k) => ` - ${k.done ? '[x]' : '[ ]'} ${k.text}`)
343
+ return [head, ...kidLines].join('\n')
341
344
  })
342
345
  const more = total > rows.length ? ` …另 ${total - rows.length} 条` : ''
343
346
  // 本会话动过的文件 —— 与看板并列摆出,让 LLM 自己对照(不判断算不算任务)。
@@ -717,13 +720,13 @@ export function pickNickname(rnd: () => number = Math.random): string {
717
720
  * 断点行(缩进的 ↳)归属到它上一条任务,不单独成条。 */
718
721
  export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
719
722
  total: number
720
- rows: { state: string; id: string; desc: string; note: string; sid: string }[]
723
+ rows: { state: string; id: string; desc: string; kids: { done: boolean; text: string }[]; sid: string }[]
721
724
  hidden: number
722
725
  } {
723
726
  // 两阶段:先无过滤地按序解析(断点归属需要“上一条”是原文里真正的前一条),
724
727
  // 再按作者过滤。若边解析边过滤,被滤掉的条目下方的断点会挂到它上面那一条
725
728
  // —— 实测:别人任务的断点会显示在我的任务下面(2026-10-03 审查发现)。
726
- type Row = { state: string; id: string; desc: string; note: string; authors: string[]; sid: string }
729
+ type Row = { state: string; id: string; desc: string; kids: { done: boolean; text: string }[]; authors: string[]; sid: string }
727
730
  const parsed: Row[] = []
728
731
  let inTodo = false
729
732
  for (const raw of String(md || '').split('\n')) {
@@ -731,11 +734,12 @@ export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
731
734
  if (/^##\s+Todo\s*$/.test(line)) { inTodo = true; continue }
732
735
  if (/^##\s+/.test(line)) { inTodo = false; continue }
733
736
  if (!inTodo) continue
734
- // 断点行:` ↳ 断点: …`(缩进)→ 挂到上一条任务
735
- const note = line.match(/^\s+↳\s*断点:\s*(.*)$/)
736
- if (note) {
737
+ // 子项行(标准 md:缩进的 `- [ ]`;旧格式 `↳ 断点:` 继续认)→ 挂到上一条任务。
738
+ // 子项可多条、可各自勾选(2026-10-16 用户定)。
739
+ const kid = parseChildLine(line)
740
+ if (kid && kid.indent > 0) {
737
741
  const last = parsed[parsed.length - 1]
738
- if (last && !last.note) last.note = note[1].trim()
742
+ if (last) last.kids.push(kid)
739
743
  continue
740
744
  }
741
745
  // `- [ ] [状态] <id> …`;状态标记可能缺省。
@@ -760,12 +764,12 @@ export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
760
764
  .replace(/^\s*—\s*/, '')
761
765
  .replace(/\s*\(认领[^)]*\)\s*$/, '')
762
766
  .trim()
763
- parsed.push({ state, id, desc, note: '', authors, sid })
767
+ parsed.push({ state, id, desc, kids: [], authors, sid })
764
768
  }
765
769
  const mine = who
766
770
  ? parsed.filter((r) => r.authors.length === 0 || r.authors.includes(who))
767
771
  : parsed
768
- const rows = mine.map(({ state, id, desc, note, sid }) => ({ state, id, desc, note, sid }))
772
+ const rows = mine.map(({ state, id, desc, kids, sid }) => ({ state, id, desc, kids, sid }))
769
773
  return { total: rows.length, rows: rows.slice(0, max), hidden: Math.max(0, rows.length - max) }
770
774
  }
771
775
 
@@ -792,6 +796,23 @@ async function currentUser(): Promise<string> {
792
796
  *
793
797
  * 宽度规则:CJK/全角记 2 列,其余记 1 列。足够让面板不溢出(不能处理 emoji ZWJ 序列,
794
798
  * 但面板里没有—— todo 内容是任务名与说明)。 */
799
+ /** 解析子项行(与 src/todo/common.js 的 parseChild 同规则,双份实现)。
800
+ *
801
+ * 为何不 import src:面板是宿主扩展,单独装到 ~/.pi/extensions/,与包内 src/ 不同目录
802
+ * (装完就断了)。故两端各一份 —— 规则简单(两步正则),且两边测试都盖。
803
+ * 标准 md:缩进的 `- [ ] 内容`;旧格式 `↳ [ ] 断点: 内容` / `↳ 断点: 内容` 继续认。 */
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*(.*)$/)
812
+ if (!m) return null
813
+ return { done: m[2] === 'x', text: m[3].trim(), indent: m[1].length }
814
+ }
815
+
795
816
  function dispWidth(s: string): number {
796
817
  let w = 0
797
818
  let i = 0
@@ -887,7 +908,7 @@ export function todoAnimFrame(phase: number): string {
887
908
  }
888
909
 
889
910
  export function renderPanelLines(
890
- data: { total: number; rows: { state: string; id: string; desc: string; note: string }[]; hidden: number },
911
+ data: { total: number; rows: { state: string; id: string; desc: string; kids?: { done: boolean; text: string }[] }[]; hidden: number },
891
912
  who: string,
892
913
  width: number,
893
914
  fg: (color: string, s: string) => string,
@@ -931,13 +952,26 @@ export function renderPanelLines(
931
952
  const branch = isLast ? '└─' : '├─'
932
953
  // 进行中的行带一个小动画(其余行不加,避免满屏都在跳)
933
954
  const anim = t.state === '进行中' && animPhase >= 0 ? ' ' + fg('accent', todoAnimFrame(animPhase)) : ''
934
- const head = `${fg('dim', branch)} ${fg(colorOf(t.state), `[${t.state}]`)} ${fg('text', t.id)}${anim}`
955
+ // 复选框 `[ ]`(2026-10-16 用户要求):面板是 todo.md 的只读视图,
956
+ // 就该长成 todo 原貌 —— 摆成与文件里 `- [ ]` 同形,读到就认得出对应哪一行。
957
+ const box = fg('dim', '[ ]')
958
+ const head = `${fg('dim', branch)} ${box} ${fg(colorOf(t.state), `[${t.state}]`)} ${fg('text', t.id)}${anim}`
935
959
  const desc = t.desc ? ` ${fg('dim', '—')} ${fg('muted', t.desc)}` : ''
936
960
  lines.push(clipToWidth(head + desc, width))
937
- if (t.note) {
938
- // 断点行:挂在父任务下。末条时用空格对齐(└─ 下方无续);否则用 │ 延伸。
939
- const cont = isLast ? ' ' : fg('dim', '│ ')
940
- lines.push(clipToWidth(`${cont} ${fg('dim', '↳ ' + t.note)}`, width))
961
+ if (t.kids && t.kids.length) {
962
+ // 子项:标准 md 嵌套列表 → 画成树
963
+ // ├─ [ ] 子项一
964
+ // └─ [x] 子项二
965
+ // 树线前缀 = 父任务的 │(非末条)或空格(末条),与父任务分支对齐。
966
+ // 勾过的用 dim 压暗(一眼看出哪些已完成),未勾用正常色。
967
+ const stem = isLast ? ' ' : fg('dim', '│ ')
968
+ const n = t.kids.length
969
+ t.kids.forEach((k, ki) => {
970
+ const kb = ki === n - 1 ? '└─' : '├─'
971
+ const kbox = fg('dim', k.done ? '[x]' : '[ ]')
972
+ const ktext = fg(k.done ? 'dim' : 'muted', k.text)
973
+ lines.push(clipToWidth(`${stem} ${fg('dim', kb)} ${kbox} ${ktext}`, width))
974
+ })
941
975
  }
942
976
  })
943
977
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.18.0",
3
+ "version": "1.19.1",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,6 +36,7 @@
36
36
  "abs": "node bin/abs.js",
37
37
  "mcp": "node bin/mcp.js",
38
38
  "test": "node --test 'test/*.test.js' 'skill/**/*.test.js'",
39
+ "fuzz": "node scripts/fuzz.mjs 800",
39
40
  "lint": "eslint .",
40
41
  "check": "node --experimental-vm-modules --no-warnings scripts/check-syntax.mjs && eslint .",
41
42
  "prepack": "node -e \"require('fs').chmodSync('bin/abs.js',0o755);require('fs').chmodSync('bin/mcp.js',0o755)\"",
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 } from './todo/common.js';
8
+ import { isAllowedTodoLine, todoSectionOf, parseChild } from './todo/common.js';
9
9
 
10
10
  // ---------- 图谱遍历 ----------
11
11
 
@@ -143,8 +143,10 @@ async function checkRootFiles(vault) {
143
143
  `总结/报告/清单请分流:流水 → abs log,经验 → abs note,长设计 → sources/)`,
144
144
  );
145
145
  }
146
- if (!l.trimStart().startsWith('↳ 断点:')) continue;
147
- const bp = l.trimStart().slice('↳ 断点:'.length).trim();
146
+ // 断点行(新旧格式同一个解析器 —— 不再自己切前缀)
147
+ const bpLine = parseChild(l);
148
+ if (!bpLine) continue;
149
+ const bp = bpLine.text;
148
150
  if (bp.length > LINT_BREAKPOINT_MAX) {
149
151
  issues.push(
150
152
  `TODO-BREAKPOINT-BLOAT: ${BRAIN_DIR}/${file}:${i + 1} (断点 ${bp.length} 字 > ${LINT_BREAKPOINT_MAX};` +
@@ -329,8 +331,12 @@ async function checkFiles(vault, pages, indexLinks) {
329
331
  if (doneLines > DONE_MAX) {
330
332
  issues.push(`DONE-PILED-UP: Done 区 ${doneLines} 行 > ${DONE_MAX}; 跑 \`abs todo archive\` 迁出旧日期组`);
331
333
  }
334
+ // ★ 只数**顶格**任务行(缩进 0)—— 子项也是 `- [x]`,带 \s* 会把子项一起算进来,
335
+ // 于是「父任务有结语有日期、子项没有」也被报成缺结语(实测 DONE-NO-KIND 误报
336
+ // 一条子项 `- [x] 不急,等下次碰这块再拆`)。与 archive.js 数任务时的判据保持一致。
337
+ const isTopDone = (l) => /^- \[x\]/.test(l);
332
338
  const doneBody = todoTxt.split('\n').slice(di + 1);
333
- const noKind = doneBody.filter((l) => /^\s*- \[x\]/.test(l) && !doneKindOf(l));
339
+ const noKind = doneBody.filter((l) => isTopDone(l) && !doneKindOf(l));
334
340
  if (noKind.length) {
335
341
  const sample = (noKind[0].match(/- \[x\] (\S+)/) || [, '?'])[1];
336
342
  issues.push(
@@ -338,7 +344,7 @@ async function checkFiles(vault, pages, indexLinks) {
338
344
  `逐条补 \`--as 落地|否决|仅方案\`(新条目:abs todo done <id> --as …)`,
339
345
  );
340
346
  }
341
- const noDate = doneBody.filter((l) => /^\s*- \[x\]/.test(l) && !doneDateOf(l));
347
+ const noDate = doneBody.filter((l) => isTopDone(l) && !doneDateOf(l));
342
348
  if (noDate.length) {
343
349
  const sample = (noDate[0].match(/- \[x\] (\S+)/) || [, '?'])[1];
344
350
  issues.push(
package/src/lock.js CHANGED
@@ -14,6 +14,24 @@ export class LockTimeout extends Error {}
14
14
  * 返回 null = 该文件不归闸门管;否则返回整理后的 text。 */
15
15
  let formatGate = null;
16
16
  export function setFormatGate(fn) { formatGate = fn; }
17
+
18
+ /** 三个受管文件名 —— 它们存在就是为了当“数据库表”,格式必须恒定。 */
19
+ const MANAGED = new Set(['index.md', 'log.md', 'todo.md']);
20
+
21
+ /** 懒加载闸门:**漏了 setFormatGate 就等于没闸门**,而注册是 import 副作用,
22
+ * 谁少 import 一次 store.js 就静默裂开(2026-10-18 实测:只 import lock.js 时
23
+ * 裸散文直接写进了 index.md)。故这里不信任外部注册,缺失时自己补上。
24
+ *
25
+ * 补不上(= store.js 没调 setFormatGate)就对受管文件硬拒:宁可写失败也不要格崩坏
26
+ * ——写不进去能重试,写脏了没人知道。 */
27
+ async function gateFor(file) {
28
+ if (!formatGate) await import('./store.js'); // 它的模块顶层会调 setFormatGate
29
+ if (formatGate) return formatGate;
30
+ if (MANAGED.has(basename(file))) {
31
+ throw new Error(`✗ ${basename(file)} 是受管文件,但格式闸门未注册 —— 拒绝写入(防止格崩坏)`);
32
+ }
33
+ return null;
34
+ }
17
35
  const LOCK_WAIT_BASE_MS = 15; // 指数退避起始重试间隔
18
36
  const LOCK_WAIT_MAX_MS = 150; // 指数退避上限
19
37
  // 抢锁总预算:排队等锁的进程须依次排完。多进程高并发(CLI/MCP/hook 同刻抢一文件)下,
@@ -76,7 +94,14 @@ export async function editFile(file, mutator) {
76
94
  let write = typeof res === 'string' ? res : res && typeof res.text === 'string' ? res.text : null;
77
95
  // 格式闸门:所有写 index/todo/log 的路径(CLI/MCP/hook 共 ~15 处)都在此收口,
78
96
  // 不必逐个改调用点 —— 破坏格式的写入在这里被整理回标准形态(见 todo.js)。
79
- if (write && formatGate) write = formatGate(file, write, current) ?? write;
97
+ //
98
+ // ★ 对三个受管文件,闸门**必须存在**:它们不是自由文本,是数据库表。
99
+ // 闸门缺失时以前会静默跳过(直写盘),现在改为自己补加载;补不上就硬拒,
100
+ // 宁可写失败也不要格崩坏——写不进去能重试,写脏了没人知道。
101
+ if (write) {
102
+ const gate = await gateFor(file);
103
+ if (gate) write = gate(file, write, current) ?? write;
104
+ }
80
105
  if (write && write !== current) {
81
106
  await fs.writeFile(file, write, 'utf8');
82
107
  }
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, setMemo, readMemo, setStateMark, TASK_STATES, insertDoneGrouped, idOfTaskLine, archiveDoneInText, upsertArchiveSection, DONE_KINDS, withDoneKind, collapseDone, SEC, rebuildStructure, enforceBrainFormat, sessionOf } 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 } 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';
@@ -56,9 +56,11 @@ export async function cmdInit({ dir }) {
56
56
 
57
57
  async function createSkeleton(root, brain) {
58
58
  for (const d of BRAIN_DIRS) await fs.mkdir(join(brain, d), { recursive: true });
59
- await fs.writeFile(join(brain, 'index.md'), indexTemplate(), 'utf8');
60
- await fs.writeFile(join(brain, 'log.md'), logTemplate(), 'utf8');
61
- await fs.writeFile(join(brain, 'todo.md'), todoTemplate(), 'utf8');
59
+ // 走 editFile 而不是 fs.writeFile:三个文件是受管表,写入必须过同一道闸门
60
+ // (2026-10-18:原来直写盘,是个绕过口)。模板本身合法,闸门只做归一。
61
+ for (const [f, tpl] of [['index.md', indexTemplate], ['log.md', logTemplate], ['todo.md', todoTemplate]]) {
62
+ await editFile(join(brain, f), (cur) => (cur === null ? { text: tpl() } : SKIP));
63
+ }
62
64
  // 建完就提示设姓名 —— 否则用户一路到第一次 todo add 才撞墙,
63
65
  // 中间 init/load 都不提,根本不知道该设。
64
66
  const who = await getUser();
@@ -116,7 +118,8 @@ export async function cmdRepair({ dir }) {
116
118
  try {
117
119
  await fs.access(join(brain, f));
118
120
  } catch {
119
- await fs.writeFile(join(brain, f), tpl(), 'utf8');
121
+ // 同 createSkeleton:补齐也走 editFile 的口子(已存在则 cur 非 null,不覆盖)
122
+ await editFile(join(brain, f), (cur) => (cur === null ? { text: tpl() } : SKIP));
120
123
  fixed.push(f);
121
124
  }
122
125
  }
@@ -350,7 +353,7 @@ export async function cmdLoad({ dir }) {
350
353
  );
351
354
  if (stranded.length) {
352
355
  const rows = stranded.map((t) => {
353
- const bp = t.bp.length ? `\n ${t.bp.map((b) => `↳ 断点: ${b}`).join('\n ')}` : '';
356
+ const bp = t.bp.length ? `\n ${t.bp.map((b) => (b.startsWith('[') ? `↳ ${b}` : `↳ 断点: ${b}`)).join('\n ')}` : '';
354
357
  return ` - ${t.body}${bp}`;
355
358
  });
356
359
  // 插在项目行之后、其它内容之前
@@ -807,7 +810,18 @@ function recentLogLines(text, n) {
807
810
  // ---------- task: 登记/推进(幂等键 = 行首 id;hook 也调这个) ----------
808
811
  // 纪律: task 过程动作(start/done/note/blocked)只改 todo.md, 不写 log.md。
809
812
  // log.md 是「工作成果沉淀摘要」(用户/AI 主动 abs log "..." 记), 不收工具动作流水。
810
- export async function cmdTask({ dir, action, id, section, note, as, session }) {
813
+ export async function cmdTask({ dir, action, id, section, note, as, session, n }) {
814
+ // ★ id 校验(2026-10-16 模糊测试找出来的真 bug):id 不能含空白/方括号。
815
+ // 不校就是**静默丢信息**:id="带 空格" 写进文件时被 extractId 截成 "带",
816
+ // 后面的 "空格" 与描述一起消失,不报错(实测:`abs todo start "带 空格"` 落成 `- [ ] 带`)。
817
+ // 宁可明确拒掉,也不替人猜哪个词算 id。
818
+ const idStr = id === undefined || id === null ? '' : String(id);
819
+ if (idStr && (idStr.includes(' ') || idStr.includes('\t'))) {
820
+ throw new Error(
821
+ `✗ 任务 id 不能含空格(收到 "${idStr}")—— id 是一个词,空白会被当分隔符。\n` +
822
+ ` 想要带空格的名字:用连字符或下划线;完整名称写进 --note。`,
823
+ );
824
+ }
811
825
  const root = await requireBrain(dir || process.cwd());
812
826
  // 写操作守卫:无姓名不落盘(hook 调的 wrapup/teardown-check 不经过这里,不受影响)
813
827
  const who = await requireUser();
@@ -929,6 +943,12 @@ export async function cmdTask({ dir, action, id, section, note, as, session }) {
929
943
  const r = await setBreakpoint(root, { id, text: note });
930
944
  return r.msg;
931
945
  }
946
+ // 勾/取消勾断点:断点是待办,做完要勾(2026-10-16)。
947
+ // 单独两个子命令而不是 `--done true/false` —— 勾与取消是两件事,各自一个动词更顺手。
948
+ if (action === 'bp-done' || action === 'bp-undone') {
949
+ const r = await setBreakpointDone(root, { id, n, done: action === 'bp-done' });
950
+ return r.msg;
951
+ }
932
952
  if (action === 'memo') {
933
953
  // 任务随手记:多句/不定长/可追加,不占看板。无 note 时 = 查看已有笔记。
934
954
  if (!id) return '用法: abs todo memo <id> "随手记(可多句)" | abs todo memo <id>(查看)';
@@ -966,6 +986,7 @@ async function markDone(file, id, kind = '落地', conclusion, bySid = '') {
966
986
  let changed = false;
967
987
  let moved = null;
968
988
  const kept = [];
989
+ const leftBehind = [];
969
990
  const wantId = String(id).replace(/\u200b/g, '');
970
991
  for (let i = 0; i < lines.length; i++) {
971
992
  const l = lines[i];
@@ -990,19 +1011,65 @@ async function markDone(file, id, kind = '落地', conclusion, bySid = '') {
990
1011
  parts.push(`(完成 ${today()}${doneSid ? ' ' + doneSid : ''})`);
991
1012
  const head = withDoneKind(`${base}${tail} ${parts.join(' ')}`, kind);
992
1013
  const bp = [];
993
- while (i + 1 < lines.length && lines[i + 1].trimStart().startsWith('↳')) bp.push(lines[++i]);
1014
+ const leftover = [];
1015
+ // 子项搬运:已勾选的随任务进 Done;**未勾选的不搬**,留在 Todo 区。
1016
+ //
1017
+ // 为何(2026-10-18 实测):Done 区被 collapseDone 折叠成计数,搬进去即「看板里
1018
+ // 消失」。实测 factory-docs-slim 就带着「待做六链」的 `- [ ]` 落地,那条待办
1019
+ // 此后无处可寻,同时父任务盖着【落地】——看板与事实矛盾。
1020
+ //
1021
+ // 为何不是硬拦(A 方案被否):`abs todo note` 写的子项与断点记录**同形**(都是
1022
+ // ` - [ ] …`),无法从字段上区分「待办」与「过程记录」;硬拦会把所有带断点的
1023
+ // 任务一起拦住(实测弄挂两条既有测试)。改为「不搬 + 点名」,信息不丢且不误伤。
1024
+ while (i + 1 < lines.length) {
1025
+ const nx = lines[i + 1];
1026
+ if (!nx.trim()) break;
1027
+ const kid = parseChild(nx);
1028
+ if (kid && kid.indent > 0) {
1029
+ // 无论勾没勾都要 i++(否则原地打转 —— 2026-10-18 实测 OOM)
1030
+ const ch = lines[++i];
1031
+ if (kid.done) bp.push(ch);
1032
+ else leftover.push(kid.text);
1033
+ continue;
1034
+ }
1035
+ if (nx.trimStart().startsWith('↳')) {
1036
+ const ch = lines[++i];
1037
+ if (/^\s*↳\s*\[ \]/.test(ch)) leftover.push(ch.trim().replace(/^\s*↳\s*\[ \]\s*/, ''));
1038
+ else bp.push(ch);
1039
+ continue;
1040
+ }
1041
+ break;
1042
+ }
994
1043
  moved = [head, ...bp];
1044
+ // 未勾子项升为独立任务留在 Todo 区:原地留着不行 —— 子项行一旦失去父任务,
1045
+ // 重建分区时就是无主杂行(实测被清掉,待办静默消失)。
1046
+ //
1047
+ // ★ 2026-10-18 补:升格的行必须有**合法 id** 与**父任务线索**。
1048
+ // 原来直接写 `- [ ] ${txt}`,实测 idOfTaskLine 会把正文开头的几个字当成 id
1049
+ // (`- [ ] [进行中] 子任务带 id ABC-1` → id="子任务带"),后续 `abs todo done`
1050
+ // 靠 id 全等定位,于是变成靠句子前几个字匹配 —— 换个措辞就断,两条子项
1051
+ // 开头相同时互相误命中。作者标记沿用父任务的,不另取名(拿不到就留空)。
1052
+ const author = (l.match(/\[\[([^\]]+)\]\]/) || [])[1];
1053
+ leftover.forEach((txt, k) => {
1054
+ const restId = `${wantId}-rest${k + 1}`;
1055
+ kept.push(`- [ ] [进行中] ${restId}${author ? ` [[${author}]]` : ''} — ${txt}(原 ${wantId} 遗留)`);
1056
+ });
1057
+ leftBehind.push(...leftover);
995
1058
  continue;
996
1059
  }
997
1060
  kept.push(l);
998
1061
  }
999
1062
  if (!changed || !moved) return SKIP;
1000
1063
  // 归位 + 按日期分组:insertDoneGrouped 一次重建 Done 区(新日期在前,未标日期归尾)
1001
- return { text: insertDoneGrouped(kept.join('\n'), moved) };
1064
+ return { text: insertDoneGrouped(kept.join('\n'), moved), leftBehind };
1002
1065
  });
1003
1066
  return res === SKIP
1004
1067
  ? `[NO_MATCH] 未找到含 "${id}" 的未完成任务行`
1005
- : `✓ 已完成并归位 Done: ${id} 【${kind}】`;
1068
+ : `✓ 已完成并归位 Done: ${id} 【${kind}】` +
1069
+ (res.leftBehind?.length
1070
+ ? `\n ⚠ ${res.leftBehind.length} 条未勾子项留在 Todo 区(未跟入 Done,仍可在看板看到):\n` +
1071
+ res.leftBehind.map((p) => ` - [ ] ${p}`).join('\n')
1072
+ : '');
1006
1073
  }
1007
1074
 
1008
1075
  // ---------- show: 查看 index/todo/log(只读面) ----------
@@ -41,11 +41,13 @@ function parseDoneUnits(bodyLines) {
41
41
  let cur = null;
42
42
  for (const l of bodyLines) {
43
43
  if (/^### /.test(l.trim()) || l.trim() === '') { cur = null; continue; } // 分组标题/空行断开会话
44
- if (isTaskLine(l)) {
44
+ // 先判子项再判任务:缩进的 `- [ ]` 两个判据都为真,而它应挂到父任务。
45
+ // (标准 md 嵌套下子项本身就是任务行 —— 靠缩进区分层级,不靠形态。)
46
+ if (cur && isChildLine(l)) {
47
+ cur.lines.push(l);
48
+ } else if (isTaskLine(l)) {
45
49
  cur = { date: doneDateOf(l), lines: [l] };
46
50
  units.push(cur);
47
- } else if (isChildLine(l) && cur) {
48
- cur.lines.push(l); // 附属行挂到上一个任务
49
51
  }
50
52
  }
51
53
  return units;
@@ -222,7 +224,10 @@ export function archiveDoneInText(text, { keepDays = 3, from = today(), maxLines
222
224
  date,
223
225
  lines: gLines,
224
226
  slug: slugFor(date),
225
- count: gLines.filter((l) => /^\s*- \[x\]/.test(l)).length,
227
+ // 只数顶层任务(缩进 0)—— 子项也是 `- [x]`,用 /^\s*- \[x\]/
228
+ // 会把子项一起计数(实测:1 任务 + 1 子项 → 报 "完成任务 2 条")。
229
+ // 归档标记的语义是「完成了多少条任务」,子项不算。
230
+ count: gLines.filter((l) => /^- \[x\]/.test(l)).length,
226
231
  });
227
232
  }
228
233
 
@@ -98,22 +98,61 @@ export function sentenceCount(s) {
98
98
  * 现在直接报错,让写入方自己写对。
99
99
  * 允许的位置:H1 与首个标签之间、标签与首条目之间、区与区之间(排版需要)。 */
100
100
 
101
- /** 任务行:`- [ ] …` / `- [x] …`(允许缩进),以及**断点任务** `↳ [ ] …`。
101
+ /** 任务行:`- [ ] …` / `- [x] …`。
102
102
  *
103
- * 为何把 `↳ [ ]` 也算任务行(2026-10-16 用户报):
104
- * 原先 `↳` 只被当作「附属断点行」(`↳ 断点: 一句话`),于是 ` ↳ [ ] xxxxx`
105
- * 落进**静默死区** —— 写入闸门放行(isChildLine 认 `↳`)、lint 不报,
106
- * 但任何按 id 的操作(done/note/state)都找不到它,面板也不显示。
107
- * 即「写得进去、看不见、完不成」。
108
- * 现在 `↳ [ ]` 是**一等任务**:可显示、可 done、可 note。
109
- * 注意:`↳ 断点: …`(冒号形态)**不变**,仍是附属行、不算任务(用户 2026-10-16 明确保留)。 */
103
+ * 顶层任务:顶格(缩进 0)的 `- [ ]` 行。
104
+ * 子任务:缩进的 `- [ ]` 行(标准 Markdown 嵌套列表,2 空格一层)。
105
+ * 两者**都是任务行**(子任务可各自勾选);层级的区分看缩进,不在本函数。
106
+ *
107
+ * 为何也收 `↳ [ ]`:2026-10-16 早上刚把断点任务升级成一等任务(存量数据里有),
108
+ * 现在改用标准 md 嵌套列表,旧形态继续认(存量不能变“看不见”)。 */
110
109
  export function isTaskLine(l) {
110
+ // 先排旧式断点行:`↳ [ ] 断点: x` 形似任务行,但它带 `断点:` ⇒ 是子项文本,不是任务。
111
+ if (/^\s*↳\s*\[[ x]\]\s*断点[::]/.test(l)) return false;
111
112
  return /^\s*(?:-\s*\[[ x]\]|↳\s*\[[ x]\])/.test(l);
112
113
  }
113
- /** 判断一行是否为任务附属行(↳ 开头,且**不是**断点任务 —— `↳ [ ]` 已是任务行)。 */
114
114
 
115
+ /** 一行是否任务附属行(子项):缩进的 `- [ ]`,或旧的 `↳ …` 形态。
116
+ *
117
+ * 标准 md 下子项就是缩进的列表项 —— 它本身也是任务行(isTaskLine 为真),
118
+ * 但**不单独成条**:它挂在父任务下,不占顶层 id、不参与 `abs todo done`。
119
+ * 故 isChild 的判据是「有缩进」,不是「形态不同」。 */
115
120
  export function isChildLine(l) {
116
- return /^\s*↳/.test(l) && !/^\s*↳\s*\[[ x]\]/.test(l);
121
+ if (/^\s*↳/.test(l)) return true; // 旧形态(含 `↳ [ ]` 断点任务)
122
+ return /^\s+-\s*\[[ x]\]/.test(l); // 缩进的 `- [ ]`(新形态)
123
+ }
124
+
125
+ // ---------- 子项行(标准 md 嵌套列表:缩进 2 空格 + `- [ ]`) ----------
126
+ /** 子项 = 挂在任务下的可勾条目(旧的「断点」就是一条子项)。
127
+ *
128
+ * 格式(2026-10-16 用户定):标准 Markdown 嵌套列表,不搞自定义前缀。
129
+ * - [ ] [进行中] 主任务
130
+ * - [x] 已完成的子项
131
+ * - [ ] 还没做的子项
132
+ * 旧格式 `↳ 断点: 内容` **继续承认**(存量数据不能坏),新写入一律用标准 md。 */
133
+ export const CHILD_INDENT = ' ';
134
+
135
+ /** 解析子项行 → { done, text, indent };不是子项返回 null。
136
+ * 两种形态都收:新的 ` - [ ] 内容` 与旧的 ` ↳ [ ] 断点: 内容` / ` ↳ 断点: 内容`。 */
137
+ 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*(.*)$/);
148
+ if (!m) return null;
149
+ return { done: m[2] === 'x', text: m[3].trim(), indent: m[1].length };
150
+ }
151
+
152
+ /** 生成子项行(标准 md,统一入口 —— 格式只有这一个真源)。
153
+ * indent 默认 2(挂在顶层任务下);多层嵌套时传更深的值。 */
154
+ export function renderChild(text, { done = false, indent = CHILD_INDENT } = {}) {
155
+ return `${indent}- ${done ? '[x]' : '[ ]'} ${text}`;
117
156
  }
118
157
 
119
158
  /** Todo 分区里「合法行」以外的东西 = 裸文本(总结/报告/清单)。
@@ -157,9 +196,16 @@ export function isPlaceholder(line) {
157
196
 
158
197
  export function extractId(text) {
159
198
  // 先剥行首状态标记(`[进行中] <id>`),否则 id 会取成 "[进行中]"(2026-09-13 实测)。
160
- const t = String(text).replace(/^\[[^\]]+\]\s+/, '');
161
- const m = t.match(/^([A-Za-z][\w-]*)\b/);
162
- return m ? m[1] : null;
199
+ // 再剥具体的 `- [ ] ` / `↳ [ ] ` 前缀(CLI 传完整行时)。
200
+ const t = String(text)
201
+ .replace(/^\s*(?:-\s*\[[ x]\]|↳\s*\[[ x]\])\s*/, '')
202
+ .replace(/^\[[^\]]+\]\s+/, '');
203
+ // id = 到第一个空白 / `[` 为止的连续字符(id 不允许含空格或方括号)。
204
+ // ★ 必须支持**中文 id**(2026-10-16 实测):原正则 `^[A-Za-z][\w-]*` 要求英文开头,
205
+ // 中文 id 一律返回 null → findTaskLine 永远找不到 → 同 id 重复 start 每次都新建
206
+ // (幂等静默失效,实测同一 id 落了两行)。中文写作里拿短语当 id 很自然,不能只收 ASCII。
207
+ const m = t.match(/^([^\s[\]]+)/);
208
+ return m ? m[1].replace(/\u200b/g, '') : null;
163
209
  }
164
210
 
165
211
  /** 提取 " — " 后的 note 部分(剥掉紧跟在 id 后的 `@author` 标记及尾部 `(认领 ...)`)。
package/src/todo/spec.js CHANGED
@@ -70,11 +70,28 @@ export const ENTRY_SHAPES = {
70
70
  'index.md': [
71
71
  {
72
72
  name: 'INDEX-ENTRY',
73
- // 条目行:`- [[页名]] — 一句话`(是 6 个链接区里的一行)
73
+ // 条目行:`- [[页名]] — 一句话`(是 5 个链接区里的一行)
74
+ //
75
+ // ★ 2026-10-18 补的白名单另一半:原规则只检查 `- ` 开头的行,**不以 `- ` 开头的
76
+ // 散文全部原样放行**。于是 Sources 区被塞进 `场景:…` 段落、裸编号列表项、
77
+ // `## 标题` 前缀也都存下来了(实测真库被污染 5 条,index 从 69 行膨到 88 行)。
78
+ // 链接区里只应有两种行:`- [[…]]` 条目、空行。其余一律拒。
74
79
  test: (l) => {
75
- if (!/^-\s/.test(l)) return null; // 非条目行(规则短句/说明)由分区规则另判
80
+ if (!l.trim()) return null; // 空行由 STRAY-BLANK 规则另判
81
+ if (!/^-\s/.test(l)) return '链接区只能放 `- [[页名]] — 一句话`(正文段落/编号列表/代码块都不行)';
76
82
  if (!/^-\s*\[\[[^\]]+\]\]/.test(l)) return '应以 `- [[页名]] …` 开头';
77
83
  if (!/^-\s*\[\[[^\]]+\]\]\s*[—-]\s*\S/.test(l)) return '`[[页名]]` 后要跟 `— 一句话` 描述';
84
+ // 摘要里不许带 Markdown 结构符(实测污染就是摘要取了页面正文开头)
85
+ if (/^\s*-\s*\[\[[^\]]+\]\]\s*[—-]\s*#+\s/.test(l)) return '摘要不能带 `## ` 标题前缀(取页面首行标题了)';
86
+ // ★ 2026-10-18 补:摘要正文本身必须是**纯文本一句话**。
87
+ // 原规则只拦了 `## ` 前缀,实测下列全放行:摘要写成 `- 列表项`(变成嵌套列
88
+ // 表)、```js(开代码围栏,把后面所有条目吞进去)、`| a | b |`(表格)。
89
+ // index 是表,摘要是一列纯文本,不能携带任何块级结构。
90
+ const desc = l.replace(/^\s*-\s*\[\[[^\]]+\]\]\s*[—-]\s*/, '');
91
+ if (/^(?:[-*+]\s|```|~~~|\||>\s|#{1,6}\s)/.test(desc)) {
92
+ return '摘要必须以文字开头(不能是列表项/代码围栏/表格/引用/标题)';
93
+ }
94
+ if (desc.includes('```')) return '摘要不能含代码围栏 ```(会吞掉后续所有条目)';
78
95
  return null;
79
96
  },
80
97
  },
@@ -83,7 +100,15 @@ export const ENTRY_SHAPES = {
83
100
  {
84
101
  name: 'LOG-ENTRY',
85
102
  test: (l) => {
86
- if (!/^##\s/.test(l)) return null; // 非 `## ` 行(H1/注释)不管
103
+ // ★ 2026-10-18 补的白名单:原来只检查 `## ` 开头的行,**其余行全放行**,
104
+ // 于是裸散文/报告能直接写进 log(实测能落盘)。log 只应有四种行:
105
+ // H1、`## Log` 分区标题、`## [时间]` 条目、HTML 注释。
106
+ if (!l.trim()) return null; // 空行另由 STRAY-BLANK 判
107
+ if (/^#\s/.test(l)) return null; // H1
108
+ if (/^<!--/.test(l.trim())) return null; // 模板提示注释
109
+ if (/^##\s+Log\s*$/.test(l)) return null; // 唯一分区标题
110
+ if (!/^##\s/.test(l)) return 'log 里只能有 `## [时间] [[作者]] kind | 正文` 条目行(散文/报告请开一条条目)';
111
+ // 以下只对 `## ` 行判形状
87
112
  // ★ log 没有分区概念:`## ` 开头的只能是条目。
88
113
  // 否则 `## 2026-10-05 随便写点`(缺方括号/作者/竖线)会被当成「分区标题」滑过去 ——
89
114
  // 而 log.md 的标签白名单是 null(不校验),这就是一个漏网口(实测)。
@@ -95,6 +120,9 @@ export const ENTRY_SHAPES = {
95
120
  const m = l.match(/^##\s*\[(\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2})?)\]\s*\[\[([^\]]+)\]\]\s*([A-Za-z]+)\s*\|\s*(\S.*)$/);
96
121
  if (!m) return '应为 `## [YYYY-MM-DD HH:MM] [[作者]] kind | 正文`(log 只能有这种行)';
97
122
  if (!LOG_KINDS.includes(m[3])) return `kind 只能是 ${LOG_KINDS.join('/')}(收到 \`${m[3]}\`)`;
123
+ // ★ 2026-10-18:正文不能含代码围栏。一条 log 正文里出现 ``` 会把它之后的
124
+ // 所有条目都吞进代码块(渲染与 grep 双输),而这里本来就是单行条目。
125
+ if (m[4].includes('```')) return 'log 正文不能含 ``` —— 条目是单行摘要,代码请贴文件路径或拆成 source 页';
98
126
  // 「正文只有个任务 id」不在这里拒(见下 lintOnly 规则):它是**内容质量**
99
127
  // 问题不是**结构格式**问题,当写入闸门会把存量脏库锁死(实测:真库仅一条
100
128
  // 就让所有 abs log 失败)。留在此处只会误伤正常短标题(如 `log-0`)。
@@ -140,8 +168,8 @@ export const ENTRY_SHAPES = {
140
168
  if (m[1] === ' ' && !/^\[[^\]]+\]\s*\S/.test(rest)) {
141
169
  return '未完成任务应为 `- [ ] [状态] …`(状态:进行中/讨论中/滞留中)';
142
170
  }
143
- // 断点附属行不能挤在同一行(应另起 ` ↳ 断点: `)
144
- if (/↳\s*断点:/.test(rest)) return '断点要另起一行写 ` ↳ 断点: …`';
171
+ // 断点附属行不能挤在同一行(应另起 ` ↳ [ ] 断点: `)
172
+ if (/↳\s*(?:\[[ x]\]\s*)?断点[::]/.test(rest)) return '断点要另起一行写 ` ↳ [ ] 断点: …`';
145
173
  return null;
146
174
  },
147
175
  },
package/src/todo/tasks.js CHANGED
@@ -4,7 +4,7 @@ import { promises as fs } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { brainPath } from '../index.js';
6
6
  import { editFile, SKIP } from '../lock.js';
7
- import { today, localStamp, extractId, extractNote, extractAuthor, isLegacyAuthorTag, claimOf } from './common.js';
7
+ import { today, localStamp, extractId, extractNote, extractAuthor, isLegacyAuthorTag, claimOf, parseChild, renderChild } from './common.js';
8
8
  import { todoTemplate, normalizeTodo, stateOfTaskLine, stripStateMark } from './structure.js';
9
9
  import { TASK_STATES } from './spec.js';
10
10
  import { collapseDone, groupDoneSection } from './archive.js';
@@ -69,9 +69,13 @@ function insertTask(orig, section, text) {
69
69
  if (idx === -1) {
70
70
  lines.push('', header, `- [ ] ${text}`);
71
71
  } else {
72
- // 在该分区标题后、下一个分区标题前插入
72
+ // 在该分区标题后、下一个分区标题前插入。
73
+ // ★ 插入点要**跳过尾部空行**:分区末尾本来就有个排版空行(`## Done` 前的那个),
74
+ // 若插到它后面,新任务与前面的内容之间就隔了个空行 —— 每建一条多一个空行,
75
+ // 而闸门明写「条目之间不允许空行」(实测:建 4 条就有 3 个空行)。
73
76
  let insertAt = idx + 1;
74
77
  while (insertAt < lines.length && !lines[insertAt].startsWith('## ')) insertAt++;
78
+ while (insertAt > idx + 1 && !lines[insertAt - 1].trim()) insertAt--;
75
79
  lines.splice(insertAt, 0, `- [ ] ${text}`);
76
80
  }
77
81
  return lines.join('\n');
@@ -138,25 +142,73 @@ export function findTaskLine(lines, id) {
138
142
  return lines.findIndex((l) => idOfTaskLine(l) === want);
139
143
  }
140
144
 
141
- /** 实时断点: 在 id 任务行下原位补/换 `↳ 断点:` 附属行(不挪任务位置)。 */
145
+ /** 子项行解析:从 `idx+1` 起扫连续的缩进行(子项),返回它们的下标。
146
+ * 遇到非子项(下一个顶层任务 / 空行 / 分区标题)就停。 */
147
+ function childRange(lines, idx) {
148
+ const out = [];
149
+ for (let i = idx + 1; i < lines.length; i++) {
150
+ const line = lines[i];
151
+ if (!line.trim()) break; // 空行 = 子项块结束
152
+ const child = parseChild(line);
153
+ if (child && child.indent > 0) { out.push(i); continue; }
154
+ break; // 另一个顶层任务行 / 标题
155
+ }
156
+ return out;
157
+ }
142
158
 
159
+ /** 追加/更新一条子项(标准 md:缩进 2 空格 + `- [ ]`)。
160
+ *
161
+ * 语义(2026-10-16 用户定):子项就是 todo 的嵌套子任务 —— 可多条、可各自勾选。
162
+ * `abs todo note <id> "…"` **追加**一条(不再覆盖)—— 用户原话「不限,写多少算多少」。
163
+ * 仍保留幂等:与最后一条完全同文就不重复追加(防手滑重复调)。
164
+ * 旧的 `↳ 断点:` 存量行不动(parseChild 认得,继续显示);新写一律用标准 md。 */
143
165
  export async function setBreakpoint(brainRoot, { id, text }) {
144
166
  const p = await ensureTodo(brainRoot);
145
- const bp = ` ↳ 断点: ${text}`;
167
+ let added = false;
146
168
  const res = await editFile(p, (orig) => {
147
169
  const lines = orig.split('\n');
148
170
  const idx = findTaskLine(lines, id);
149
171
  if (idx === -1) return SKIP;
150
- if (lines[idx + 1] && lines[idx + 1].trimStart().startsWith('↳ 断点:')) {
151
- lines[idx + 1] = bp; // 幂等: 更新原附属行
152
- } else {
153
- lines.splice(idx + 1, 0, bp);
154
- }
172
+ const kids = childRange(lines, idx);
173
+ const last = kids.length ? parseChild(lines[kids[kids.length - 1]]) : null;
174
+ if (last && last.text === String(text).trim()) return SKIP; // 幂等:同文不重复追加
175
+ lines.splice(idx + 1 + kids.length, 0, renderChild(text));
176
+ added = true;
177
+ return { text: lines.join('\n'), ok: true };
178
+ });
179
+ if (res === SKIP) {
180
+ return added
181
+ ? { ok: true, msg: `✓ 子项已追加 → ${id}` }
182
+ : { ok: false, msg: `[NO_MATCH] 未找到含 "${id}" 的未完成任务行(或子项已存在)` };
183
+ }
184
+ return { ok: true, msg: `✓ 子项已追加 → ${id}\n ${renderChild(text)}` };
185
+ }
186
+
187
+ /** 勾/取消勾子项:`- [ ] …` ↔ `- [x] …`。
188
+ *
189
+ * 定位:子项没有自己的 id(身份 = 「某任务的第 N 条子项」),故按 n 定位(1-based)。
190
+ * n 缺省 = 勾最后一条(最常用的「刚写的这条做完了」)。
191
+ * 旧格式 `↳ 断点:` 也能勾 —— 勾一下就地升级成标准 md 形态。 */
192
+ export async function setBreakpointDone(brainRoot, { id, n, done = true }) {
193
+ const p = await ensureTodo(brainRoot);
194
+ let picked = null;
195
+ const res = await editFile(p, (orig) => {
196
+ const lines = orig.split('\n');
197
+ const idx = findTaskLine(lines, id);
198
+ if (idx === -1) return SKIP;
199
+ const kids = childRange(lines, idx);
200
+ if (!kids.length) return SKIP;
201
+ const target = n ? kids[n - 1] : kids[kids.length - 1];
202
+ if (target === undefined) return SKIP;
203
+ const child = parseChild(lines[target]);
204
+ picked = child;
205
+ lines[target] = renderChild(child.text, { done });
155
206
  return { text: lines.join('\n'), ok: true };
156
207
  });
157
- return res === SKIP
158
- ? { ok: false, msg: `[NO_MATCH] 未找到含 "${id}" 的未完成任务行` }
159
- : { ok: true, msg: `✓ 断点已落 → ${id}\n ${bp.trim()}` };
208
+ if (res === SKIP || !picked) {
209
+ return { ok: false, msg: `[NO_MATCH] "${id}" 下没有第 ${n || '(最后)'} 条子项(或任务不存在)` };
210
+ }
211
+ return { ok: true, msg: `✓ 子项 ${done ? '已勾' : '已取消勾'} → ${id}\n ${renderChild(picked.text, { done })}` };
160
212
  }
161
213
 
162
214
  // ---------- 任务随手记(memo)----------
@@ -1,7 +1,7 @@
1
1
  // src/todo/validate.js — 条目级闸门:形状校验、长度限制、空行检查。
2
2
 
3
3
  import { TODO_SECTIONS, isAllowedTodoSub, INDEX_SECTIONS, ENTRY_SHAPES, indexLinkSectionNeeds, BREAKPOINT_MAX, TASK_LINE_MAX } from './spec.js';
4
- import { sigOfLine, REPORT_MARKERS, sentenceCount, isTaskLine, isAllowedTodoLine, todoSectionOf } from './common.js';
4
+ import { sigOfLine, REPORT_MARKERS, sentenceCount, isTaskLine, isAllowedTodoLine, todoSectionOf, parseChild } from './common.js';
5
5
 
6
6
  export function checkFileShape(file, text, { lintMode = false } = {}) {
7
7
  const issues = [];
@@ -162,6 +162,29 @@ export function assertTodoContent(text, prevSig = null) {
162
162
  const l = lines[i];
163
163
  const lineNo = i + 1;
164
164
  if (l.startsWith('## ')) continue;
165
+ // 子项行要比任务行先判:标准 md 下子项(缩进的 `- [ ]`)**也是任务行**,
166
+ // 若让任务行分支先 continue,子项就只受 TASK_LINE_MAX(400) 管,
167
+ // BREAKPOINT_MAX(200) 的闸门永远走不到(实测:300 字子项静默写入)。
168
+ const kidEarly = parseChild(l);
169
+ if (kidEarly && kidEarly.indent > 0) {
170
+ if (isLegacy(l)) continue; // 存量:不阻写入(lint 报)
171
+ const kbody = kidEarly.text;
172
+ if (kbody.length > BREAKPOINT_MAX) {
173
+ throw new Error(
174
+ `✗ 子项太长(第 ${lineNo} 行 ${kbody.length} 字 > ${BREAKPOINT_MAX})—— 子项只写「改到哪个文件哪一步」。\n` +
175
+ ` 写不下的内容请分流:实施报告/验证数据 → abs log;经验与坑 → abs note;长设计 → .brain/sources/ 页。\n` +
176
+ ` 原文开头: ${kbody.slice(0, 60)}...`,
177
+ );
178
+ }
179
+ if (REPORT_MARKERS.some((re) => re.test(kbody)) || sentenceCount(kbody) > 2) {
180
+ throw new Error(
181
+ `✗ 子项里像是塞了报告/清单(第 ${lineNo} 行)—— 子项是一句话(最多两句),不是文档。\n` +
182
+ ` todo 只能加 todo:要做的拆成任务,做完的写 abs log,经验写 abs note。\n` +
183
+ ` 原文开头: ${kbody.slice(0, 60)}...`,
184
+ );
185
+ }
186
+ continue;
187
+ }
165
188
  // 任务行
166
189
  if (isTaskLine(l)) {
167
190
  if (l.length > TASK_LINE_MAX && !isLegacy(l)) {
@@ -173,10 +196,11 @@ export function assertTodoContent(text, prevSig = null) {
173
196
  }
174
197
  continue;
175
198
  }
176
- // 断点附属行
177
- if (l.trimStart().startsWith('↳ 断点:')) {
199
+ // 断点附属行(新旧格式用同一个解析器 —— 格式知识只有 common.js 一个真源)
200
+ const bp = parseChild(l);
201
+ if (bp) {
178
202
  if (isLegacy(l)) continue; // 存量超长断点:不阻写入(lint 报)
179
- const body = l.trimStart().slice('↳ 断点:'.length).trim();
203
+ const body = bp.text;
180
204
  if (body.length > BREAKPOINT_MAX) {
181
205
  throw new Error(
182
206
  `✗ 断点太长(第 ${lineNo} 行 ${body.length} 字 > ${BREAKPOINT_MAX})—— 断点只写「改到哪个文件哪一步」。\n` +
package/src/todo.js CHANGED
@@ -2,9 +2,9 @@
2
2
  // 保留这一层是为了对外接口稳定:store.js / lint.js / note.js / wrapup.js / 测试的
3
3
  // import 路径与名字都不用改。
4
4
 
5
- export { TODO_MAX_LINES, LOG_MAX_LINES, INDEX_MAX_LINES, today, localStamp, SEC, extractId, extractNote, extractAuthor, isLegacyAuthorTag, claimOf, sessionOf } from './todo/common.js';
5
+ export { TODO_MAX_LINES, LOG_MAX_LINES, INDEX_MAX_LINES, today, localStamp, SEC, extractId, extractNote, extractAuthor, isLegacyAuthorTag, claimOf, sessionOf, parseChild, renderChild } from './todo/common.js';
6
6
  export { LEGACY_SECTION_RENAMES, TODO_SECTIONS, TODO_SUBSECTIONS, isAllowedTodoSub, INDEX_SECTIONS, INDEX_LINK_SECTIONS, LOG_KINDS, H1_TO_FILE, ENTRY_SHAPES, TASK_STATES, RETIRED_SECTIONS, BREAKPOINT_MAX, TASK_LINE_MAX, DONE_KINDS, ARCHIVE_SECTION } from './todo/spec.js';
7
7
  export { todoTemplate, rebuildStructure, dropRetiredSections, enforceBrainFormat, normalizeTodo, ensureStateMark, stateOfTaskLine, stripStateMark, checkTaskBox } from './todo/structure.js';
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
- export { readTodo, ensureTodo, setStateMark, addTask, upsertTask, idOfTaskLine, findTaskLine, setBreakpoint, setMemo, readMemo, boardText } from './todo/tasks.js';
10
+ export { readTodo, ensureTodo, setStateMark, addTask, upsertTask, idOfTaskLine, findTaskLine, setBreakpoint, setBreakpointDone, setMemo, readMemo, boardText } from './todo/tasks.js';
package/src/wrapup.js CHANGED
@@ -8,7 +8,7 @@
8
8
  import { promises as fs } from 'node:fs';
9
9
  import { join } from 'node:path';
10
10
  import { absLogDir } from './index.js';
11
- import { readTodo, localStamp } from './todo.js';
11
+ import { readTodo, localStamp, parseChild } from './todo.js';
12
12
 
13
13
  export function wrapupLogPath() {
14
14
  return join(absLogDir(), 'wrapup.log');
@@ -62,7 +62,15 @@ export function extractOpenTasks(todoText) {
62
62
  const bp = [];
63
63
  while (i + 1 < lines.length && /^\s*↳/.test(lines[i + 1])) {
64
64
  i++;
65
- bp.push(lines[i].trim().replace(/^↳\s*(断点|卡点)?[::]?\s*/, ''));
65
+ // 断点快照带上勾选标记(`[ ] 内容` / `[x] 内容`)—— 快照是给人看的,
66
+ // 不保留勾选就分不清「当时已完成」与「还没做」(2026-10-16 断点可勾后必需)。
67
+ const parsed = parseChild(lines[i]);
68
+ if (parsed) {
69
+ bp.push(`${parsed.done ? '[x]' : '[ ]'} ${parsed.text}`);
70
+ } else {
71
+ // 非断点的 ↳ 行(如 `↳ [ ] 子任务`)仍照旧剥前缀
72
+ bp.push(lines[i].trim().replace(/^↳\s*(断点|卡点)?[::]?\s*/, ''));
73
+ }
66
74
  }
67
75
  out.push({ body: taskBody, bp });
68
76
  }
@@ -77,12 +85,13 @@ function isPlaceholderBody(body) {
77
85
  return /^(待办任务|无|(无)|None|null)$/.test(body) || /^待办任务/.test(body);
78
86
  }
79
87
 
80
- /** 序列化一个快照块:首行时间戳+归属,后随未完成任务(主体+断点)。 */
88
+ /** 序列化一个快照块:首行时间戳+归属,后随未完成任务(主体+断点)。
89
+ * 断点 bp 元素已自带勾选前缀(`[ ] 内容` / `[x] 内容`,见 extractOpenTasks)。 */
81
90
  function blockText(root, tasks) {
82
91
  const head = `[${localStamp()}] wrapup proj=${root}`;
83
92
  if (!tasks.length) return head; // 无滞留,仍记一行「干净结束」
84
93
  const rows = tasks.map((t) => {
85
- const bps = t.bp.map((b) => ` ↳ 断点: ${b}`);
94
+ const bps = t.bp.map((b) => (b.startsWith('[') ? ` ↳ ${b}` : ` ↳ 断点: ${b}`));
86
95
  return ` - [ ] ${t.body}${bps.length ? '\n' + bps.join('\n') : ''}`;
87
96
  });
88
97
  return [head, ...rows].join('\n');
@@ -112,7 +121,11 @@ export function parseWrapup(text) {
112
121
  }
113
122
  const bm = line.match(/^\s*↳/);
114
123
  if (bm && cur.tasks.length) {
115
- cur.tasks[cur.tasks.length - 1].bp.push(line.trim().replace(/^↳\s*(断点|卡点)?[::]?\s*/, ''));
124
+ // 回读时统一成 `[ ] 内容` / `[x] 内容` 形态(新写的就是这个;旧快照无勾 → 当未勾)
125
+ const parsed = parseChild(line);
126
+ cur.tasks[cur.tasks.length - 1].bp.push(parsed
127
+ ? `${parsed.done ? '[x]' : '[ ]'} ${parsed.text}`
128
+ : line.trim().replace(/^↳\s*(断点|卡点)?[::]?\s*/, ''));
116
129
  }
117
130
  }
118
131
  return byProj;