@fanchao8609/agent_brain_sync 1.13.3 → 1.14.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/bin/abs.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // bin/abs.js — abs CLI 入口。
3
3
  // abs <cmd> [args]
4
4
  // 命令: init / board / status / load / task / install / uninstall / help
5
- import { cmdInit, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdConcept, cmdShow, cmdRepair, cmdWrapup, cmdRule, cmdTeardownCheck, cmdTodoArchive, cmdResolve, cmdSupersede, cmdReview } from '../src/store.js';
5
+ import { cmdInit, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdConcept, cmdShow, cmdRepair, cmdWrapup, cmdRule, cmdTeardownCheck, cmdTodoArchive, cmdResolve, cmdSupersede, cmdReview, cmdAutoTask, cmdAutoTaskSweep } from '../src/store.js';
6
6
  import { setUser, getUser, userConfigPath } from '../src/userconfig.js';
7
7
  import { runInstall, runUninstall } from '../src/install.js';
8
8
  import { readFileSync } from 'node:fs';
@@ -92,6 +92,10 @@ const FLAG_SPEC = {
92
92
  // ★ 漏声明就是真 bug(2026-10-05 本机实测):不在 FLAG_SPEC 的选项会被静默丢掉,
93
93
  // 于是 `abs log "x" --kind note` 全写成默认的 dev,而 cmdLog 的枚举校验看不见它。
94
94
  'kind': { type: 'string' },
95
+ // auto-task 用(hook 调):--session 作幂等键(一会话一条),--prompt 抽任务关键词。
96
+ // ★ 同上:不在这声明就会被 parseArgv 静默丢掉,登记变成"永远首次"、闸门形同虚设。
97
+ 'session': { type: 'string' },
98
+ 'prompt': { type: 'string' },
95
99
  'by': { type: 'string' },
96
100
  'all': { type: 'boolean' },
97
101
  'keep-days': { type: 'string' },
@@ -527,7 +531,16 @@ async function main() {
527
531
  // 内部命令(hook 专用,不出现在 help):Stop 时机械快照未完成任务
528
532
  case 'wrapup': {
529
533
  rejectExtra(opts._, 'abs wrapup(内部命令, 供 hook 调用)');
530
- console.log(await cmdWrapup({ dir: opts.dir }));
534
+ const r = await cmdWrapup({ dir: opts.dir });
535
+ // 收尾时顺带清理 hook 自动登记的条目(它们不代表人工接管的成果)
536
+ const swept = await cmdAutoTaskSweep({ dir: opts.dir }).catch(() => null);
537
+ console.log([r, swept].filter(Boolean).join('\n') || '(无滞留任务)');
538
+ break;
539
+ }
540
+ // hook 自动登记(pi/opencode 侧调): 一会话一条,同会话只更新断点
541
+ case 'auto-task': {
542
+ const r = await cmdAutoTask({ dir: opts.dir, session: opts.session, prompt: opts.prompt, note: opts.note });
543
+ if (r) console.log(r); // 无事发生则静默(不制造空动作噪音)
531
544
  break;
532
545
  }
533
546
  // Stop hook 用: 判定是否注入收尾指令。stdout 给 shell (`push:<json>` / `{}`), 无额外输出。
package/hooks/abs.pi.ts CHANGED
@@ -18,7 +18,9 @@ import { homedir } from "node:os"
18
18
  import { join } from "node:path"
19
19
  import { spawn } from "node:child_process"
20
20
 
21
- const ABS_BIN = "@@ABS_BIN@@"
21
+ // abs CLI 路径。安装时替换为 stableBinPath(全局优先),
22
+ // 但允许 ABS_BIN_PATH 覆盖 —— 测试需隔离到沙盒副本,开发机也可能同时有多份。
23
+ const ABS_BIN = process.env.ABS_BIN_PATH || "@@ABS_BIN@@"
22
24
 
23
25
  async function logHook(evt: string): Promise<void> {
24
26
  const dir = process.env.ABS_LOG_DIR || join(homedir(), ".abs", "log")
@@ -32,6 +34,51 @@ async function logHook(evt: string): Promise<void> {
32
34
 
33
35
  // 会话结束时快照当前项目滞留任务到 wrapup.log(detached fire-and-forget,wrapup 自身幂等去重不刷屏)。
34
36
  // cwd 用事件 ctx.cwd(当前项目),让 abs 在该目录定位 .brain/。
37
+
38
+ /** 会话 id(作自动登记的幂等键,一会话一条)。取不到返回空串 —— 那时不登记,
39
+ * 因为空键会让每条指令都"首次",看板被刷爆。 */
40
+ function sessionIdFor(ctx: any): string {
41
+ try {
42
+ const id = ctx?.sessionManager?.getSessionId?.()
43
+ return String(id || '').replace(/[^\w-]/g, '').slice(0, 16)
44
+ } catch { return '' }
45
+ }
46
+
47
+ /** 调 abs auto-task(hook 自动登记)。detached fire-and-forget,不阻塞 turn。
48
+ *
49
+ * 为何走 spawn 而非 import:与 snapshotWrapup 同源 —— 扩展进程里不能阻塞用户 turn,
50
+ * 且 abs 的写入要经过它自己的文件锁(跨进程锁才有效)。 */
51
+ function spawnAutoTask(cwd: string, args: string[]): void {
52
+ try {
53
+ const child = spawn(process.execPath, [ABS_BIN, "auto-task", ...args], {
54
+ cwd: cwd || process.cwd(), stdio: "ignore", detached: true,
55
+ })
56
+ child.unref()
57
+ } catch {} // fire-and-forget: 登记失败不得影响宿主
58
+ }
59
+
60
+ /** 会改文件的工具名。用于判定「真的开始干活了」—— 排查类会话先跑一堆读命令,
61
+ * 若用「第一次 tool_call」当判据,断点会被写在与任务无关的文件上。
62
+ *
63
+ * 为何含 bash:大量修改是经 bash 完成的(heredoc / sed -i / > 重定向),
64
+ * 漏掉它 = 纯 bash 会话永远不记断点(见 concept opencode-inject-channel-verdict
65
+ * 的同源教训:白名单必须按宿主实际用法穷举,漏一个就把功能静默关掉)。
66
+ */
67
+ const WRITE_TOOL = /^(write|edit|apply_patch|patch|multiedit|notebookedit|bash|shell)$/i
68
+
69
+ /** bash 命令里没有写入迹象则不算「干活」—— 否则 `ls`/`git status` 也会记断点。 */
70
+ const BASH_WRITES = /(^|[^\w])(>|>>|sed\s+-i|tee\b|mkdir\b|rm\b|mv\b|cp\b|touch\b|cat\s*>)/
71
+
72
+ /** 这次 tool_call 是否算「真的动了文件」。 */
73
+ function isWriteCall(name: string, input: any): boolean {
74
+ if (!WRITE_TOOL.test(String(name || ''))) return false
75
+ // bash 要额外甄别:只有带写入操作的命令才算
76
+ if (/^(bash|shell)$/i.test(String(name || ''))) {
77
+ return BASH_WRITES.test(String(input?.command ?? ''))
78
+ }
79
+ return true
80
+ }
81
+
35
82
  function snapshotWrapup(cwd: string): void {
36
83
  try {
37
84
  const child = spawn(process.execPath, [ABS_BIN, "wrapup"], {
@@ -61,6 +108,22 @@ let agentEndSeen = false
61
108
  * 同样在模块级,理由同上(闭包 flag 在重复注册时不共享)。 */
62
109
  let pendingTodoTool = false
63
110
 
111
+ /** 本会话是否已记过自动任务断点。模块级(重复注册时闭包 flag 不共享 —— 同 agentEndSeen 的坑)。
112
+ * 只记第一次写文件,不是每次都记:断点是「记到哪」不是流水账。 */
113
+ let autoTaskSeen = false
114
+
115
+ /** 本会话最后被写到的文件(agent_end 落断点用)。
116
+ * 只存文件名,不存内容 —— 断点是"记到哪",不是快照。 */
117
+ let lastTouchedFile = ''
118
+
119
+ /** 最近一轮的用户指令原文。只用于「第一次真改文件时」抽任务关键词 ——
120
+ * 登记时机已从 before_agent_start 挪到 tool_call(用户实报:一按回车就登记太早)。 */
121
+ let pendingPrompt = ''
122
+
123
+ /** 最近一次渲染拿到的 tui 引用。hidePanel 用它请求重绘 —— 面板消失时动画
124
+ * 定时器已停,没有别的重绘通道(见 hidePanel 的坑注解)。 */
125
+ let lastTui: any = null
126
+
64
127
  /** 会话边界重置埋点状态。
65
128
  * 不重置 → 同进程第二个会话继承 true 永久不留痕(2026-09-13 实测过的坑)。 */
66
129
  function resetThrottle(): void {
@@ -502,15 +565,28 @@ export function renderPanelLines(
502
565
  if (data.hidden > 0) lines.push(clipToWidth(fg('dim', ` +${data.hidden} more`), width))
503
566
  return lines
504
567
  }
568
+ /** 隐藏面板。**必须主动 requestRender** —— 否则数据清了、屏幕还留着旧画面。
569
+ *
570
+ * 坑(2026-10-05 用户实报): 任务全标完后面板不消失, 按 ESC 或重启才正常。
571
+ * 根因: 唯一的重绘入口是动画定时器里的 requestRender, 而"任务全完成"恰好让
572
+ * needAnim=false → stopAnimTimer() → 从此没有任何人请求重绘 → 屏幕冻结在旧帧。
573
+ * 即"消失"这个动作本身把它自己的重绘通道关掉了。
574
+ * 故消失路径要自己 requestRender 一次。 */
575
+ function hidePanel(ui: any): void {
576
+ try { ui.setWidget(PANEL_KEY, undefined) } catch {}
577
+ stopAnimTimer()
578
+ try { lastTui?.requestRender?.() } catch {}
579
+ }
580
+
505
581
  async function refreshTodoPanel(ui: any, cwd: string): Promise<void> {
506
582
  if (String(process.env.ABS_TODO_PANEL || '') === '0') return
507
583
  if (!ui || typeof ui.setWidget !== 'function') return
508
584
  try {
509
- if (!(await hasBrain(cwd))) { ui.setWidget(PANEL_KEY, undefined); stopAnimTimer(); return }
585
+ if (!(await hasBrain(cwd))) { hidePanel(ui); return }
510
586
  const md = await readFile(join(cwd, '.brain', 'todo.md'), 'utf8')
511
587
  const who = await currentUser()
512
588
  const data = parseOpenTasks(md, PANEL_MAX_ROWS, who)
513
- if (data.total === 0) { ui.setWidget(PANEL_KEY, undefined); stopAnimTimer(); return }
589
+ if (data.total === 0) { hidePanel(ui); return }
514
590
  // factory 形式:render(width) 每帧拿真实宽度 → 按宽度截断,不再断字。
515
591
  // 动画:phase 存在模块级,render 时读当前值;定时器只在本帧重绘,不重建 widget。
516
592
  let tuiRef: any = null
@@ -518,6 +594,7 @@ async function refreshTodoPanel(ui: any, cwd: string): Promise<void> {
518
594
  PANEL_KEY,
519
595
  (tui: any, theme: any) => {
520
596
  tuiRef = tui
597
+ lastTui = tui // 供 hidePanel 在"无动画"时也能请求重绘
521
598
  return {
522
599
  render: (width: number) =>
523
600
  renderPanelLines(
@@ -538,8 +615,7 @@ async function refreshTodoPanel(ui: any, cwd: string): Promise<void> {
538
615
  if (needAnim) startAnimTimer(() => tuiRef)
539
616
  else stopAnimTimer()
540
617
  } catch {
541
- try { ui.setWidget(PANEL_KEY, undefined) } catch {}
542
- stopAnimTimer()
618
+ hidePanel(ui)
543
619
  }
544
620
  }
545
621
 
@@ -548,6 +624,10 @@ export default function absPiHook(pi: ExtensionAPI): void {
548
624
  resetThrottle()
549
625
 
550
626
  pi.on("session_start", (event: any, ctx: any) => {
627
+ // 新会话 = 重新登记:不重置则同进程的第二个会话永远不再落断点
628
+ autoTaskSeen = false
629
+ lastTouchedFile = ''
630
+ pendingPrompt = ''
551
631
  resetThrottle()
552
632
  // 本会话的随机昵称 —— 在这里抽一次(不是每帧抽,否则面板会疯狂闪)。
553
633
  // 每次 session_start(startup/reload/new/resume/fork)重抽 → “每次打开 pi 都是随机的”。
@@ -574,7 +654,7 @@ export default function absPiHook(pi: ExtensionAPI): void {
574
654
  // 故:tool_call 认出“刚才调的是 todo 工具”→ 置标记;tool_execution_end 看到标记就刷 → 清标记。
575
655
  // 始终保留 turn_end 兜底(实测每条 assistant 消息都触发,约 4 秒一次)。
576
656
  // 标记在模块级(重复注册时闭包 flag 不共享 —— 见 agentEndSeen 的注解)。
577
- pi.on("tool_call", (event: any) => {
657
+ pi.on("tool_call", (event: any, ctx: any) => {
578
658
  const name = String(event?.toolName || '')
579
659
  const input = event?.input || {}
580
660
  // 两种形态:① MCP 代理入口 mcp__abs + input.tool=abs_task;② 直接工具名 abs_task
@@ -583,6 +663,24 @@ export default function absPiHook(pi: ExtensionAPI): void {
583
663
  pendingTodoTool = true
584
664
  logHook(`tool_call todo_tool name=${name} target=${target || '-'}`).catch(() => {})
585
665
  }
666
+ // 自动登记断点(2026-10-05):真的动文件时,把「改到哪个」记进本会话的自动任务。
667
+ // 为何不用「第一次 tool_call」:排查类会话先跑一堆读命令,断点会落在无关文件上。
668
+ if (!autoTaskSeen && isWriteCall(name, input)) {
669
+ autoTaskSeen = true
670
+ const cwd = (ctx && ctx.cwd) || process.cwd()
671
+ const file = String(input?.path ?? input?.file_path ?? input?.filePath ?? '')
672
+ const bp = file ? `改了 ${file.slice(0, 120)}` : '开始改文件'
673
+ if (file) lastTouchedFile = file.slice(0, 120)
674
+ const sid = sessionIdFor(ctx)
675
+ // 首次真改文件 = "真开工了" → 此刻才登记(带用户指令抽关键词)+ 落断点。
676
+ // 这是唯一登记入口:光说/说问/讨论都不登记(2026-10-05 用户实报时机太早)。
677
+ if (sid) {
678
+ const args = ['--session', sid, '--note', bp]
679
+ if (pendingPrompt) args.push('--prompt', pendingPrompt)
680
+ spawnAutoTask(cwd, args)
681
+ }
682
+ logHook(`tool_call auto_task name=${name} sid=${sid || '-'}`).catch(() => {})
683
+ }
586
684
  })
587
685
 
588
686
  pi.on("tool_execution_end", (_event: any, ctx: any) => {
@@ -612,6 +710,11 @@ export default function absPiHook(pi: ExtensionAPI): void {
612
710
  }
613
711
  const ok = injectTodoGuidelines(event?.systemPromptOptions)
614
712
  logHook(`before_agent_start todo_guide=${ok ? 'on' : 'off'} cwd=${cwd}`).catch(() => {})
713
+
714
+ // 只【记住】本轮用户指令,不在这里登记(2026-10-05 用户实报时机太早)。
715
+ // 旧行为: 一按回车就登记 → 用户还在说/聊、需求没成型,看板上就已经开出了任务。
716
+ // 现在: 留到第一次真改文件时(tool_call)才登记,那时才算"真开工了"。
717
+ if (event?.prompt) pendingPrompt = String(event.prompt).slice(0, 200)
615
718
  } catch (e: any) {
616
719
  logHook(`before_agent_start error=${e?.message || 'unknown'}`).catch(() => {})
617
720
  }
@@ -626,11 +729,23 @@ export default function absPiHook(pi: ExtensionAPI): void {
626
729
  // 每项目弹,都还是在打断用户。故不再有 teardownNudged/inFlight/loggedToday 判据。
627
730
  // 只保留 agent_end:seen 埋点:区分「事件没触发」与「被守卫拦下」。
628
731
  pi.on("agent_end", async (_event: any, ctx: any) => {
629
- if (agentEndSeen) return
630
- agentEndSeen = true
631
732
  const cwd = (ctx && ctx.cwd) || process.cwd()
632
733
  const brain = await findBrain(cwd)
633
- await logHook(`agent_end:seen cwd=${cwd} brain=${brain || 'none'}`).catch(() => {})
734
+ // 可观测性: 每会话首次留一行痕(区分"事件没触发"与"被守卫拦下")。
735
+ if (!agentEndSeen) {
736
+ agentEndSeen = true
737
+ await logHook(`agent_end:seen cwd=${cwd} brain=${brain || 'none'}`).catch(() => {})
738
+ }
739
+ // 每轮结束落一次断点(2026-10-05): 本会话若真动过文件, 把"干到哪"推进到最新。
740
+ // 为何只在动过文件后: 纯对话/查询轮不需要推进断点, 否则白 spawn 一堆进程。
741
+ // 为何不放在 session_shutdown: 那是进程退出, 而"一轮干完"才是自然的落盘点 ——
742
+ // 用户可能一直开着 pi 跨很多轮, 断点必须活着的时候就已经是最新的。
743
+ if (!brain || !autoTaskSeen) return
744
+ const sid = sessionIdFor(ctx)
745
+ if (!sid) return
746
+ const last = lastTouchedFile
747
+ spawnAutoTask(cwd, ['--session', sid, '--note', last ? `本轮到 ${last}` : '本轮有改动'])
748
+ logHook(`agent_end auto_task breakpoint sid=${sid}`).catch(() => {})
634
749
  })
635
750
 
636
751
  pi.on("session_shutdown", (_e: any, ctx: any) => {
@@ -640,4 +755,4 @@ export default function absPiHook(pi: ExtensionAPI): void {
640
755
  })
641
756
  }
642
757
 
643
- @@MARK@@
758
+ @@MARK@@
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.13.3",
3
+ "version": "1.14.1",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/store.js CHANGED
@@ -1162,6 +1162,150 @@ export async function cmdTask({ dir, action, id, section, note, as }) {
1162
1162
  throw new Error(`unknown task action: ${action}`);
1163
1163
  }
1164
1164
 
1165
+ // ---------- 自动登记(hook 专用,2026-10-05) ----------
1166
+ // 背景: pi 的 promptGuidelines 常驻指引已注入(实检 414 次有痕), 但行为没变 ——
1167
+ // 指引假设 agent 已决定"要开一个任务", 而排查类工作没有清晰起点("我先看看怎么回事"
1168
+ // 看明白了活儿已经干完一半)。故改为 hook 侧自动登记, 不靠 agent 自觉。
1169
+ //
1170
+ // 粒度: **一会话一条**(幂等键 = 会话 id), 后续改动只更新断点 —— 不是一文件一条。
1171
+ // 否则一个会话改 20 个文件就塞 20 条, 看板被垃圾淹没(违"读取侧不得随规模增长")。
1172
+ const AUTO_TASK_PREFIX = 'auto-';
1173
+
1174
+ /** 从用户指令抽任务关键词。抽不到返回 null —— 宁可不登记, 也不脏看板。
1175
+ *
1176
+ * 为何不抽不到就用机器 id: 看板是给人看的, `auto-c3f1` 认不出是什么;
1177
+ * "在吗"/"继续"/"abs" 这类纯对话开头抽出的是噪声, 登了反而增加负担。
1178
+ */
1179
+ export function autoTaskId(text) {
1180
+ const s = String(text || '').replace(/[\u200b\u200c\u200d\ufeff]/g, '').trim();
1181
+ if (!s) return null;
1182
+ // 纯对话/寒暄不算指令
1183
+ if (/^(在吗|在不在|你好|hi|hello|ok|好的|收到|继续|谢了|谢谢|abs|嗯|对|是的|行)[\s!!。.~]*$/i.test(s)) return null;
1184
+ // 疑问句不算开工指令(2026-10-05 实报): 用户问"为什么任务是这个啊"被登记成了任务 ——
1185
+ // 提问/质疑/求助不是"让我动手",登记它等于把用户的疑问变成待办,很荒唐。
1186
+ // 判据用两档: ①句中有疑问词 ②句尾是问号(含全角)。
1187
+ if (/[??]\s*$/.test(s)) return null;
1188
+ if (/(为什么|为啥|怎么会|怎么|如何|什么是|是什么|哪里|哪个|能不能|可不可以|是否|吗\s*[??。!!]?$|呢\s*[??。!!]?$)/.test(s)) return null;
1189
+ // 取首个意群(标点/换行切)作为 id 来源
1190
+ const head = s.split(/[\n。!?!?;;,,]/)[0].trim();
1191
+ if (head.length < 4) return null;
1192
+ // 中文取字,英文/数字取词;保留可读性,限长
1193
+ const words = head.match(/[A-Za-z0-9_.-]+/g) || [];
1194
+ const cjk = head.replace(/[A-Za-z0-9_.\-\s]+/g, '').slice(0, 12);
1195
+ const slug = (words.length && cjk.length < 2 ? words.slice(0, 4).join('-') : cjk)
1196
+ .replace(/[^\w\u4e00-\u9fa5-]/g, '')
1197
+ .slice(0, 24);
1198
+ return slug.length >= 2 ? slug : null;
1199
+ }
1200
+
1201
+ /** 从断点文本提一个像样的 slug。
1202
+ *
1203
+ * 断点形如 `改了 src/store.js` / `本轮到 hooks/abs.pi.ts`;直接当 slug 会得到
1204
+ * `改了`(首词)这种废名。这里取【文件名】当 slug —— 「改了哪个文件」比「改了」有意义。
1205
+ */
1206
+ export function autoTaskSlugFromNote(note) {
1207
+ const s = String(note || '').replace(/[\u200b\u200c\u200d\ufeff]/g, '');
1208
+ // 文件路径优先(含 / 或 . 扩展名)
1209
+ const m = s.match(/[\w./-]*[\w-]+\.[A-Za-z0-9]{1,8}/);
1210
+ if (m) {
1211
+ const base = m[0].split('/').pop().replace(/\.[A-Za-z0-9]{1,8}$/, '');
1212
+ if (base && base.length >= 2) return `改 ${base}`;
1213
+ }
1214
+ // 退而求其次:抽到的关键词(可能为空)
1215
+ return autoTaskId(s);
1216
+ }
1217
+
1218
+ /** hook 自动登记: 一会话一条 + 断点更新。
1219
+ *
1220
+ * @param {object} o
1221
+ * @param {string} o.dir 项目目录
1222
+ * @param {string} o.session 会话 id(幂等键)
1223
+ * @param {string} o.prompt 用户指令原文(抽 id 用,仅首次需要)
1224
+ * @param {string} o.note 断点内容(后续调用时更新到同一条)
1225
+ * @returns {Promise<string|null>} 动作说明;无事发生返回 null
1226
+ */
1227
+ export async function cmdAutoTask({ dir, session, prompt, note }) {
1228
+ if (String(process.env.ABS_AUTO_TASK || '') === '0') return null;
1229
+ const root = await requireBrain(dir || process.cwd());
1230
+ const sid = String(session || '').replace(/[^\w-]/g, '').slice(0, 16);
1231
+ if (!sid) return null;
1232
+
1233
+ // 幂等键藏在标记里而不是 id 里(2026-10-05 实报): 原先 id = `auto-<会话uuid前16位>`,
1234
+ // 用户看到 `auto-01a10b96-372d-71` 完全认不出是什么。id 是给人看的,改用关键词;
1235
+ // 会话 id 退成机器用的隐藏键(`(自动登记 <sid>)`),sweep 靠它认领自动条目。
1236
+ const MARK = `自动登记 ${sid}`;
1237
+ const todoPath = brainPath(root, 'todo.md');
1238
+
1239
+ // 已登记过 → 只更新断点(一会话一条)
1240
+ let existingId = null;
1241
+ await editFile(todoPath, (cur) => {
1242
+ if (cur === null) return SKIP;
1243
+ for (const l of cur.split('\n')) {
1244
+ const id = idOfTaskLine(l);
1245
+ if (id && id.startsWith(AUTO_TASK_PREFIX) && l.includes(MARK)) { existingId = id; break; }
1246
+ }
1247
+ return SKIP;
1248
+ });
1249
+ if (existingId) {
1250
+ if (!note) return null;
1251
+ const r = await setBreakpoint(root, { id: existingId, text: note });
1252
+ return r?.changed ? `↳ ${existingId} 断点更新` : null;
1253
+ }
1254
+
1255
+ // 首次登记: 关键词优先从用户指令抽;抽不出(寒暄/提问/无指令)时用断点兜底。
1256
+ //
1257
+ // 为何 note 也能兜底(2026-10-05 时机改动后新增):登记时机已挪到"第一次真改文件",
1258
+ // 那一刻【已经动过文件】就是最可靠的"这是件事"的证据 —— 此时因抽不出关键词而放弃,
1259
+ // 等于用户寒暄两句后开始干活却什么都没记。用文件名当兜底 slug 比不登记有用。
1260
+ // 兜底仅在【有断点】时启用 —— 断点的存在本身就意味着"刚动过文件"。
1261
+ // 只有 prompt 而抽不出关键词(纯寒暄/提问、且没动文件)→ 依然不登记(用户实报的那个坑)。
1262
+ const slug = autoTaskId(prompt) || (note ? autoTaskSlugFromNote(note) : null);
1263
+ if (!slug) return null;
1264
+ // id = 关键词(可读);同 slug 已存在则加序号,避免两条不同活撞同一个 id。
1265
+ let id = `${AUTO_TASK_PREFIX}${slug}`;
1266
+ let n = 1;
1267
+ await editFile(todoPath, (cur) => {
1268
+ if (cur === null) return SKIP;
1269
+ const ids = new Set(cur.split('\n').map((l) => idOfTaskLine(l)).filter(Boolean));
1270
+ while (ids.has(id)) id = `${AUTO_TASK_PREFIX}${slug}-${++n}`;
1271
+ return SKIP;
1272
+ });
1273
+ const who = await requireUser();
1274
+ await ensurePersonPage(root, who);
1275
+ await upsertTask(root, {
1276
+ section: SEC.todo,
1277
+ text: `[进行中] ${id} ${atTag(who)} — ${slug} (${MARK})`,
1278
+ });
1279
+ if (note) await setBreakpoint(root, { id, text: note });
1280
+ return `✓ 自动登记 ${id} — ${slug}`;
1281
+ }
1282
+
1283
+ /** 收尾: 把自动登记的未完成任务标 done(用户没显式接管它们)。
1284
+ * 结语用【仅方案】—— 自动条目本身不代表"成果落地",不该冒充【落地】。 */
1285
+ export async function cmdAutoTaskSweep({ dir }) {
1286
+ const root = await requireBrain(dir || process.cwd());
1287
+ const todoPath = brainPath(root, 'todo.md');
1288
+ const ids = [];
1289
+ await editFile(todoPath, (cur) => {
1290
+ if (cur === null) return SKIP;
1291
+ for (const l of cur.split('\n')) {
1292
+ const id = idOfTaskLine(l);
1293
+ // 自动条目的判据是「自动登记」标记, 不是 id 前缀 —— id 现在是给人看的关键词,
1294
+ // `auto-fix-panel` 这种名字人工也可能起得出来, 靠前缀认领会误杀真任务。
1295
+ if (id && /自动登记\s+\S+/.test(l)) ids.push(id);
1296
+ }
1297
+ return SKIP;
1298
+ });
1299
+ let n = 0;
1300
+ for (const id of ids) {
1301
+ try {
1302
+ await markDone(todoPath, id, '仅方案', '自动登记未接管,收尾清理');
1303
+ n++;
1304
+ } catch { /* 单条失败不阻塞其余 */ }
1305
+ }
1306
+ return n ? `✓ 自动任务收尾 ${n} 条` : null;
1307
+ }
1308
+
1165
1309
  async function markDone(file, id, kind = '落地', conclusion) {
1166
1310
  const res = await editFile(file, (text) => {
1167
1311
  const lines = text.split('\n');