@fanchao8609/agent_brain_sync 1.17.2 → 1.19.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/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,15 +734,19 @@ 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
- // `- [ ] [状态] <id> …`;状态标记可能缺省
742
- const m = line.match(/^-\s+\[\s\]\s+(?:\[([^\]]+)\]\s+)?(\S+)\s*(.*)$/)
745
+ // `- [ ] [状态] <id> …`;状态标记可能缺省。
746
+ // 两种前缀都收:`- [ ]`(普通任务)与 `↳ [ ]`(断点任务,2026-10-16 新增)。
747
+ // 断点任务必须显示 —— 它就是一等等任务(写得进去、看得见、能 done),
748
+ // 否则落回「静默死区」(用户在 .brain 里报的原症状)。
749
+ const m = line.match(/^\s*(?:-\s*\[ \]|↳\s*\[ \])\s*(?:\[([^\]]+)\]\s*)?(\S+)\s*(.*)$/)
743
750
  if (!m) continue
744
751
  const state = m[1] || '进行中'
745
752
  const id = m[2]
@@ -757,12 +764,12 @@ export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
757
764
  .replace(/^\s*—\s*/, '')
758
765
  .replace(/\s*\(认领[^)]*\)\s*$/, '')
759
766
  .trim()
760
- parsed.push({ state, id, desc, note: '', authors, sid })
767
+ parsed.push({ state, id, desc, kids: [], authors, sid })
761
768
  }
762
769
  const mine = who
763
770
  ? parsed.filter((r) => r.authors.length === 0 || r.authors.includes(who))
764
771
  : parsed
765
- 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 }))
766
773
  return { total: rows.length, rows: rows.slice(0, max), hidden: Math.max(0, rows.length - max) }
767
774
  }
768
775
 
@@ -789,6 +796,23 @@ async function currentUser(): Promise<string> {
789
796
  *
790
797
  * 宽度规则:CJK/全角记 2 列,其余记 1 列。足够让面板不溢出(不能处理 emoji ZWJ 序列,
791
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
+
792
816
  function dispWidth(s: string): number {
793
817
  let w = 0
794
818
  let i = 0
@@ -884,7 +908,7 @@ export function todoAnimFrame(phase: number): string {
884
908
  }
885
909
 
886
910
  export function renderPanelLines(
887
- 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 },
888
912
  who: string,
889
913
  width: number,
890
914
  fg: (color: string, s: string) => string,
@@ -928,13 +952,26 @@ export function renderPanelLines(
928
952
  const branch = isLast ? '└─' : '├─'
929
953
  // 进行中的行带一个小动画(其余行不加,避免满屏都在跳)
930
954
  const anim = t.state === '进行中' && animPhase >= 0 ? ' ' + fg('accent', todoAnimFrame(animPhase)) : ''
931
- 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}`
932
959
  const desc = t.desc ? ` ${fg('dim', '—')} ${fg('muted', t.desc)}` : ''
933
960
  lines.push(clipToWidth(head + desc, width))
934
- if (t.note) {
935
- // 断点行:挂在父任务下。末条时用空格对齐(└─ 下方无续);否则用 │ 延伸。
936
- const cont = isLast ? ' ' : fg('dim', '│ ')
937
- 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
+ })
938
975
  }
939
976
  })
940
977
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.17.2",
3
+ "version": "1.19.0",
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/hosts.js CHANGED
@@ -45,6 +45,16 @@ export const HOSTS = [
45
45
  skillSub: join('agent', 'skills'), // pi 用户级 skill 在 ~/.pi/agent/skills (非 ~/.pi/skills)
46
46
  events: ['SessionStart', 'UserPromptSubmit', 'Stop'],
47
47
  },
48
+ {
49
+ key: 'omp',
50
+ label: 'omp (oh-my-pi)',
51
+ // 落盘: <configRoot>/agent/extensions/abs.ts(native provider 自动发现 .ts/.js)
52
+ // 与 pi 的差异(源码核对,见 hooks/abs.omp.ts 文件头):before_agent_start 只给
53
+ // systemPrompt 整体替换(无 promptGuidelines);session_start 无 reason 字段。
54
+ configRoot: () => process.env.ABS_OMP_HOME || join(HOME, '.omp'),
55
+ skillSub: join('agent', 'skills'), // omp 用户级 skill 在 ~/.omp/agent/skills
56
+ events: ['SessionStart', 'UserPromptSubmit', 'Stop'],
57
+ },
48
58
  ];
49
59
 
50
60
  export function hostByKey(key) {
package/src/install.js CHANGED
@@ -854,12 +854,65 @@ async function uninstallPi() {
854
854
  return steps;
855
855
  }
856
856
 
857
+ // ============================ omp (oh-my-pi, TS extension) ============================
858
+ // 与 pi 宿主**不同构**:omp 的 before_agent_start 只给 `systemPrompt: string[]`(整体替换),
859
+ // 没有 pi 的 promptGuidelines;session_start 也没有 reason 字段。故用专用模板 abs.omp.ts。
860
+ // MCP 落 <configRoot>/agent/mcp.json 的 mcpServers(omp 官方首选用户级路径,见 omp://mcp-config.md)。
861
+ function ompPluginSource() {
862
+ return renderPluginTemplate('abs.omp.ts', {
863
+ '@@ABS_BIN@@': stableBinPath('abs.js'),
864
+ '@@MARK@@': MARK,
865
+ });
866
+ }
867
+
868
+ async function installOmp({ withMcp, withSkill }) {
869
+ const steps = [];
870
+ const dir = join(hostConfigRoot('omp'), 'agent', 'extensions');
871
+ const p = join(dir, 'abs.ts');
872
+ await atomicWrite(p, ompPluginSource());
873
+ steps.push(`✓ hook(ts extension) → ${p}`);
874
+ if (withMcp) {
875
+ // omp native MCP 配置:<configRoot>/agent/mcp.json 的 mcpServers(stdio)。
876
+ // 不用 .mcp.json / 项目根 mcp.json —— 那两个是「可移植 fallback」,优先级最低。
877
+ const mcpP = join(hostConfigRoot('omp'), 'agent', 'mcp.json');
878
+ await backup(mcpP);
879
+ const cfg = await readJson(mcpP);
880
+ cfg.mcpServers = cfg.mcpServers || {};
881
+ cfg.mcpServers['abs'] = { type: 'stdio', command: process.execPath, args: [mcpEntryPath()] };
882
+ await atomicWrite(mcpP, JSON.stringify(cfg, null, 2));
883
+ steps.push(`✓ MCP → ${mcpP} (mcpServers.abs, stdio)`);
884
+ }
885
+ if (withSkill) {
886
+ steps.push(...await installSkills('omp'));
887
+ }
888
+ return steps;
889
+ }
890
+
891
+ async function uninstallOmp() {
892
+ const steps = [];
893
+ await fs.rm(join(hostConfigRoot('omp'), 'agent', 'extensions', 'abs.ts'), { force: true });
894
+ steps.push(`✓ extension 已删除`);
895
+ steps.push(...await uninstallSkills('omp'));
896
+ const mcpP = join(hostConfigRoot('omp'), 'agent', 'mcp.json');
897
+ const cfg = await readJson(mcpP, { strict: false });
898
+ if (cfg === null) {
899
+ steps.push(`⚠ ${mcpP} 无法解析为 JSON,已跳过(未改动你的配置)`);
900
+ }
901
+ if (cfg && cfg.mcpServers && cfg.mcpServers.abs) {
902
+ delete cfg.mcpServers.abs;
903
+ await atomicWrite(mcpP, JSON.stringify(cfg, null, 2));
904
+ steps.push(`✓ MCP 已从 ${mcpP} 移除`);
905
+ }
906
+ return steps;
907
+ }
908
+
857
909
  // ============================ 向导入口 ============================
858
910
  const INSTALLERS = {
859
911
  'claude-code': { on: installClaudeCode, off: uninstallClaudeCode },
860
912
  codex: { on: installCodex, off: uninstallCodex },
861
913
  opencode: { on: installOpenCode, off: uninstallOpenCode },
862
914
  pi: { on: installPi, off: uninstallPi },
915
+ omp: { on: installOmp, off: uninstallOmp },
863
916
  };
864
917
 
865
918
  export function installSummary() {
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};` +
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, 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';
@@ -350,7 +350,7 @@ export async function cmdLoad({ dir }) {
350
350
  );
351
351
  if (stranded.length) {
352
352
  const rows = stranded.map((t) => {
353
- const bp = t.bp.length ? `\n ${t.bp.map((b) => `↳ 断点: ${b}`).join('\n ')}` : '';
353
+ const bp = t.bp.length ? `\n ${t.bp.map((b) => (b.startsWith('[') ? `↳ ${b}` : `↳ 断点: ${b}`)).join('\n ')}` : '';
354
354
  return ` - ${t.body}${bp}`;
355
355
  });
356
356
  // 插在项目行之后、其它内容之前
@@ -807,7 +807,18 @@ function recentLogLines(text, n) {
807
807
  // ---------- task: 登记/推进(幂等键 = 行首 id;hook 也调这个) ----------
808
808
  // 纪律: task 过程动作(start/done/note/blocked)只改 todo.md, 不写 log.md。
809
809
  // log.md 是「工作成果沉淀摘要」(用户/AI 主动 abs log "..." 记), 不收工具动作流水。
810
- export async function cmdTask({ dir, action, id, section, note, as, session }) {
810
+ export async function cmdTask({ dir, action, id, section, note, as, session, n }) {
811
+ // ★ id 校验(2026-10-16 模糊测试找出来的真 bug):id 不能含空白/方括号。
812
+ // 不校就是**静默丢信息**:id="带 空格" 写进文件时被 extractId 截成 "带",
813
+ // 后面的 "空格" 与描述一起消失,不报错(实测:`abs todo start "带 空格"` 落成 `- [ ] 带`)。
814
+ // 宁可明确拒掉,也不替人猜哪个词算 id。
815
+ const idStr = id === undefined || id === null ? '' : String(id);
816
+ if (idStr && (idStr.includes(' ') || idStr.includes('\t'))) {
817
+ throw new Error(
818
+ `✗ 任务 id 不能含空格(收到 "${idStr}")—— id 是一个词,空白会被当分隔符。\n` +
819
+ ` 想要带空格的名字:用连字符或下划线;完整名称写进 --note。`,
820
+ );
821
+ }
811
822
  const root = await requireBrain(dir || process.cwd());
812
823
  // 写操作守卫:无姓名不落盘(hook 调的 wrapup/teardown-check 不经过这里,不受影响)
813
824
  const who = await requireUser();
@@ -929,6 +940,12 @@ export async function cmdTask({ dir, action, id, section, note, as, session }) {
929
940
  const r = await setBreakpoint(root, { id, text: note });
930
941
  return r.msg;
931
942
  }
943
+ // 勾/取消勾断点:断点是待办,做完要勾(2026-10-16)。
944
+ // 单独两个子命令而不是 `--done true/false` —— 勾与取消是两件事,各自一个动词更顺手。
945
+ if (action === 'bp-done' || action === 'bp-undone') {
946
+ const r = await setBreakpointDone(root, { id, n, done: action === 'bp-done' });
947
+ return r.msg;
948
+ }
932
949
  if (action === 'memo') {
933
950
  // 任务随手记:多句/不定长/可追加,不占看板。无 note 时 = 查看已有笔记。
934
951
  if (!id) return '用法: abs todo memo <id> "随手记(可多句)" | abs todo memo <id>(查看)';
@@ -982,7 +999,7 @@ async function markDone(file, id, kind = '落地', conclusion, bySid = '') {
982
999
  // done 未给 sid → 沿用认领的(无新信息,不凭空编)
983
1000
  const claimSid = sessionOf(l);
984
1001
  const doneSid = bySid || claimSid;
985
- const base = stripStateMark(l).replace('- [ ]', '- [x]').replace(/\s*\(认领[^)]*\)/, '');
1002
+ const base = checkTaskBox(stripStateMark(l)).replace(/\s*\(认领[^)]*\)/, '');
986
1003
  const parts = [];
987
1004
  if (claimSid && doneSid && claimSid !== doneSid) {
988
1005
  parts.push(`(认领 ${today()} ${claimSid})`);
@@ -990,7 +1007,16 @@ async function markDone(file, id, kind = '落地', conclusion, bySid = '') {
990
1007
  parts.push(`(完成 ${today()}${doneSid ? ' ' + doneSid : ''})`);
991
1008
  const head = withDoneKind(`${base}${tail} ${parts.join(' ')}`, kind);
992
1009
  const bp = [];
993
- while (i + 1 < lines.length && lines[i + 1].trimStart().startsWith('↳')) bp.push(lines[++i]);
1010
+ // 子项随任务一起搬运(标准 md:缩进的 `- [ ]`;旧格式 `↳ …` 也认)。
1011
+ // 原实现只认 `↳`,换成标准 md 后子项会留在原区——即「任务走了子项没走」。
1012
+ while (i + 1 < lines.length) {
1013
+ const nx = lines[i + 1];
1014
+ if (!nx.trim()) break;
1015
+ const kid = parseChild(nx);
1016
+ if (kid && kid.indent > 0) bp.push(lines[++i]);
1017
+ else if (nx.trimStart().startsWith('↳')) bp.push(lines[++i]);
1018
+ else break;
1019
+ }
994
1020
  moved = [head, ...bp];
995
1021
  continue;
996
1022
  }
@@ -23,10 +23,15 @@ export function doneKindOf(line) {
23
23
 
24
24
  export function withDoneKind(line, kind) {
25
25
  if (!kind) return line;
26
- const base = String(line).replace(/【(落地|否决|仅方案)】/g, '').replace(/\s{2,}/g, ' ').trimEnd();
27
- const i = base.lastIndexOf(' (完成 ');
28
- if (i === -1) return `${base} 【${kind}】`;
29
- return `${base.slice(0, i)} 【${kind}】${base.slice(i)}`;
26
+ const s = String(line);
27
+ // 行首缩进必须原样保留 —— 断点任务(` ↳ [ ] …`)靠缩进表达它挂在父任务下。
28
+ // 原实现整体 `.replace(/\s{2,}/g,' ')` 会把两空格缩进压成一个,
29
+ // 完成行归位 Done 后缩进就丢了(2026-10-16 实测)。
30
+ const lead = (s.match(/^\s*/) || [''])[0];
31
+ const rest = s.slice(lead.length).replace(/【(落地|否决|仅方案)】/g, '').replace(/\s{2,}/g, ' ').trimEnd();
32
+ const i = rest.lastIndexOf(' (完成 ');
33
+ if (i === -1) return `${lead}${rest} 【${kind}】`;
34
+ return `${lead}${rest.slice(0, i)} 【${kind}】${rest.slice(i)}`;
30
35
  }
31
36
 
32
37
  /** 判断一行是否为任务行(- [x] / - [ ],允许缩进)。 */
@@ -36,11 +41,13 @@ function parseDoneUnits(bodyLines) {
36
41
  let cur = null;
37
42
  for (const l of bodyLines) {
38
43
  if (/^### /.test(l.trim()) || l.trim() === '') { cur = null; continue; } // 分组标题/空行断开会话
39
- if (isTaskLine(l)) {
44
+ // 先判子项再判任务:缩进的 `- [ ]` 两个判据都为真,而它应挂到父任务。
45
+ // (标准 md 嵌套下子项本身就是任务行 —— 靠缩进区分层级,不靠形态。)
46
+ if (cur && isChildLine(l)) {
47
+ cur.lines.push(l);
48
+ } else if (isTaskLine(l)) {
40
49
  cur = { date: doneDateOf(l), lines: [l] };
41
50
  units.push(cur);
42
- } else if (isChildLine(l) && cur) {
43
- cur.lines.push(l); // 附属行挂到上一个任务
44
51
  }
45
52
  }
46
53
  return units;
@@ -217,7 +224,10 @@ export function archiveDoneInText(text, { keepDays = 3, from = today(), maxLines
217
224
  date,
218
225
  lines: gLines,
219
226
  slug: slugFor(date),
220
- 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,
221
231
  });
222
232
  }
223
233
 
@@ -98,13 +98,61 @@ export function sentenceCount(s) {
98
98
  * 现在直接报错,让写入方自己写对。
99
99
  * 允许的位置:H1 与首个标签之间、标签与首条目之间、区与区之间(排版需要)。 */
100
100
 
101
+ /** 任务行:`- [ ] …` / `- [x] …`。
102
+ *
103
+ * 顶层任务:顶格(缩进 0)的 `- [ ]` 行。
104
+ * 子任务:缩进的 `- [ ]` 行(标准 Markdown 嵌套列表,2 空格一层)。
105
+ * 两者**都是任务行**(子任务可各自勾选);层级的区分看缩进,不在本函数。
106
+ *
107
+ * 为何也收 `↳ [ ]`:2026-10-16 早上刚把断点任务升级成一等任务(存量数据里有),
108
+ * 现在改用标准 md 嵌套列表,旧形态继续认(存量不能变“看不见”)。 */
101
109
  export function isTaskLine(l) {
102
- return /^\s*- \[[ x]\]/.test(l);
110
+ // 先排旧式断点行:`↳ [ ] 断点: x` 形似任务行,但它带 `断点:` ⇒ 是子项文本,不是任务。
111
+ if (/^\s*↳\s*\[[ x]\]\s*断点[::]/.test(l)) return false;
112
+ return /^\s*(?:-\s*\[[ x]\]|↳\s*\[[ x]\])/.test(l);
103
113
  }
104
- /** 判断一行是否为任务附属行(↳ 开头)。 */
105
114
 
115
+ /** 一行是否任务附属行(子项):缩进的 `- [ ]`,或旧的 `↳ …` 形态。
116
+ *
117
+ * 标准 md 下子项就是缩进的列表项 —— 它本身也是任务行(isTaskLine 为真),
118
+ * 但**不单独成条**:它挂在父任务下,不占顶层 id、不参与 `abs todo done`。
119
+ * 故 isChild 的判据是「有缩进」,不是「形态不同」。 */
106
120
  export function isChildLine(l) {
107
- return /^\s*↳/.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}`;
108
156
  }
109
157
 
110
158
  /** Todo 分区里「合法行」以外的东西 = 裸文本(总结/报告/清单)。
@@ -148,9 +196,16 @@ export function isPlaceholder(line) {
148
196
 
149
197
  export function extractId(text) {
150
198
  // 先剥行首状态标记(`[进行中] <id>`),否则 id 会取成 "[进行中]"(2026-09-13 实测)。
151
- const t = String(text).replace(/^\[[^\]]+\]\s+/, '');
152
- const m = t.match(/^([A-Za-z][\w-]*)\b/);
153
- 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;
154
209
  }
155
210
 
156
211
  /** 提取 " — " 后的 note 部分(剥掉紧跟在 id 后的 `@author` 标记及尾部 `(认领 ...)`)。
package/src/todo/spec.js CHANGED
@@ -140,8 +140,8 @@ export const ENTRY_SHAPES = {
140
140
  if (m[1] === ' ' && !/^\[[^\]]+\]\s*\S/.test(rest)) {
141
141
  return '未完成任务应为 `- [ ] [状态] …`(状态:进行中/讨论中/滞留中)';
142
142
  }
143
- // 断点附属行不能挤在同一行(应另起 ` ↳ 断点: `)
144
- if (/↳\s*断点:/.test(rest)) return '断点要另起一行写 ` ↳ 断点: …`';
143
+ // 断点附属行不能挤在同一行(应另起 ` ↳ [ ] 断点: `)
144
+ if (/↳\s*(?:\[[ x]\]\s*)?断点[::]/.test(rest)) return '断点要另起一行写 ` ↳ [ ] 断点: …`';
145
145
  return null;
146
146
  },
147
147
  },
@@ -299,26 +299,34 @@ export function normalizeTodo(text) {
299
299
  return out.join('\n');
300
300
  }
301
301
 
302
- /** 给任务行补行首状态标记(已有则不覆盖)。`- [ ] id …` → `- [ ] [状态] id …` */
303
-
302
+ /** 给任务行补行首状态标记(已有则不覆盖)。`- [ ] id …` → `- [ ] [状态] id …`
303
+ * 两种前缀(`- [ ]` / 断点任务 `↳ [ ]`)与缩进都认 —— 否则存量迁移时断点任务补不上标记。 */
304
304
  export function ensureStateMark(line, state) {
305
- const m = String(line).match(/^(- \[[ x]\] )(\[[^\]]+\]\s+)?(.*)$/);
305
+ const m = String(line).match(/^(\s*(?:-\s*\[[ x]\]|↳\s*\[[ x]\])\s*)(\[[^\]]+\]\s+)?(.*)$/);
306
306
  if (!m) return line;
307
307
  if (m[2] && TASK_STATES.includes(m[2].trim().replace(/[\x5B\x5D]/g, ''))) return line;
308
308
  return `${m[1]}[${state}] ${m[3]}`;
309
309
  }
310
310
 
311
- /** 取任务行的状态标记;无标记返回 null。 */
312
-
311
+ /** 读任务行的状态标记;两种前缀(`- [ ]` / 断点任务 `↳ [ ]`)与缩进都认。 */
313
312
  export function stateOfTaskLine(line) {
314
- const m = String(line).match(/^- \[[ x]\] \[([^\]]+)\]/);
313
+ const m = String(line).match(/^\s*(?:-\s*\[[ x]\]|↳\s*\[[ x]\])\s*\[([^\]]+)\]/);
315
314
  return m && TASK_STATES.includes(m[1]) ? m[1] : null;
316
315
  }
317
316
 
318
- /** 改任务行的状态标记(原地)。未找到或非法状态返回原样。 */
317
+ /** 剥掉任务行的状态标记(`- [ ] [进行中] id …` → `- [ ] id …`),保留前缀与缩进。 */
319
318
 
320
319
  export function stripStateMark(line) {
321
- return String(line).replace(/^(- \[[ x]\] )\[[^\]]+\] /, '$1');
320
+ // 两种前缀都剥:`- [ ] `(普通任务)与 `↳ [ ] `(断点任务,带缩进)。
321
+ // 原正则写死 `^- \[[ x]\] ` —— 断点任务(2026-10-16 新增形态)匹配不上,
322
+ // 后果是状态标记没被剥掉、markDone 的 `- [ ]`→`- [x]` 也落空(实测:完成行仍是 `[ ]`)。
323
+ return String(line).replace(/^(\s*(?:-\s*\[[ x]\]|↳\s*\[[ x]\])\s*)\[[^\]]+\]\s*/, '$1');
324
+ }
325
+
326
+ /** 把未完成任务行的复选框从 `[ ]` 翻到 `[x]`(保留原前缀与缩进)。
327
+ * 单独抽出:断点任务的 `↳ ` 前缀 + 缩进必须原样保留,用固定的 `- [ ]` 字面替换会漏。 */
328
+ export function checkTaskBox(line) {
329
+ return String(line).replace(/^(\s*(?:-\s*|↳\s*))\[ \]/, '$1[x]');
322
330
  }
323
331
 
324
332
  /** 从已完成任务行提取 `(完成 YYYY-MM-DD)` 日期;无则返回 ''。兼容中英文括号。 */