@fanchao8609/agent_brain_sync 1.1.0 → 1.2.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, cmdBoard, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdShow, cmdRepair, cmdWrapup } from '../src/store.js';
5
+ import { cmdInit, cmdBoard, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdShow, cmdRepair, cmdWrapup, cmdTeardownCheck } from '../src/store.js';
6
6
  import { runInstall, runUninstall, installSummary } from '../src/install.js';
7
7
  import { readFileSync } from 'node:fs';
8
8
  import { join, dirname, resolve } from 'node:path';
@@ -69,6 +69,7 @@ function parseArgv(args) {
69
69
  else if (a === '--id') { o.id = args[++i]; }
70
70
  else if (a === '--section') { o.section = args[++i]; }
71
71
  else if (a === '--note') { o.note = args[++i]; }
72
+ else if (a === '--payload') { o.payload = args[++i]; }
72
73
  else if (a === '--yes') { o.yes = true; }
73
74
  else if (a === '--repair') { o.repair = true; }
74
75
  else if (a === '--no-mcp') { o.mcp = false; }
@@ -155,6 +156,11 @@ async function main() {
155
156
  console.log(await cmdWrapup({ dir: opts.dir }));
156
157
  break;
157
158
  }
159
+ // Stop hook 用: 判定是否注入收尾指令。stdout 给 shell (`push:<json>` / `{}`), 无额外输出。
160
+ case 'teardown-check': {
161
+ process.stdout.write((await cmdTeardownCheck({ dir: opts.dir, payload: opts.payload })) + '\n');
162
+ break;
163
+ }
158
164
  case 'update': await cmdUpdate({ yes: opts.yes }); break;
159
165
  case '--version': case '-v': case 'version': console.log(pkgVersion()); break;
160
166
  case 'help': case undefined: case '--help': console.log(usage); break;
package/hooks/event.sh CHANGED
@@ -15,6 +15,18 @@ EVENT="__EVENT__"
15
15
 
16
16
  PAYLOAD=$(cat 2>/dev/null | head -c 500)
17
17
 
18
+ # ── Stop 收尾注入 (CC/Co​dex 的"主动推") ─────────────────────────────────
19
+ # 守卫同 pi 插件四条件: ①本会话真改过文件 ②本项目有 .brain ③log.md 今日无条目 ④每会话一次。
20
+ # 判定在 CLI 里做(cmdTeardownCheck),这里只解析结果。
21
+ # 关键: 回宿主的 decision:block 必须走 stdout 同步输出,所以判定要在前台算完,不能放后台子 shell。
22
+ BAIL='{}'
23
+ if [ "$EVENT" = "Stop" ]; then
24
+ TEARDOWN=$("$NODE_BIN" "$ABS_BIN" teardown-check --payload "$PAYLOAD" 2>/dev/null)
25
+ case "$TEARDOWN" in
26
+ push:*) BAIL=$(printf '%s' "$TEARDOWN" | sed 's/^push://') ;;
27
+ esac
28
+ fi
29
+
18
30
  # 后台执行,hook 立即返回——宿主热路径零阻塞。
19
31
  (
20
32
  # 幂等: 同一 payload 指纹在 60s 内只落一行 (防重复触发)。
@@ -36,10 +48,29 @@ PAYLOAD=$(cat 2>/dev/null | head -c 500)
36
48
  SESSION_ID=$(printf '%s' "$PAYLOAD" | sed -n 's/.*"session_id":"\([^"]*\)".*/\1/p' | head -1)
37
49
  LOG_DIR="${ABS_LOG_DIR:-$HOME/.abs/log}"
38
50
  mkdir -p "$LOG_DIR" 2>/dev/null
51
+
52
+ # 日志轮转: hooks.log 是 append-only 热路径, 无上限会无限长。
53
+ # 用 wc -c 判大小(/bin/sh 无 GNU stat -c); 超限就轮转一份 .1 并截断。
54
+ # 阈值可经 ABS_LOG_MAX_BYTES 覆盖; 只要一份历史(.1), 不做多层——排查靠近期痕迹, 旧的不值当留。
55
+ ABS_LOG_MAX="${ABS_LOG_MAX_BYTES:-1048576}" # 1 MiB
56
+ LOGF="$LOG_DIR/hooks.log"
57
+ if [ -f "$LOGF" ]; then
58
+ SZ=$(wc -c < "$LOGF" 2>/dev/null | tr -d ' ')
59
+ case "$SZ" in ''|*[!0-9]*) SZ=0 ;; esac
60
+ if [ "$SZ" -gt "$ABS_LOG_MAX" ] 2>/dev/null; then
61
+ mv -f "$LOGF" "$LOGF.1" 2>/dev/null || : > "$LOGF"
62
+ printf '[%s] logrotate hooks.log (%s bytes -> .1)\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$SZ" >> "$LOGF" 2>/dev/null || true
63
+ fi
64
+ fi
39
65
  {
40
66
  printf '[%s] %s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$EVENT" "$SHORT"
41
67
  } >> "$LOG_DIR/hooks.log" 2>/dev/null || true
42
68
 
69
+ case "$BAIL" in
70
+ '{}') ;;
71
+ *) printf '[%s] %s teardown-nudge 注入收尾指令\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$EVENT" >> "$LOG_DIR/hooks.log" 2>/dev/null || true ;;
72
+ esac
73
+
43
74
  # Stop 专用: 低噪声 wrap-up 提醒 (独立文件, 不进 per-event hooks.log, 不碰图谱 todo/log.md)。
44
75
  # 纯机械信号——"一个会话结束了"; 是否真有滞留任务/经验由 agent 自觉判断, hook 不替做。
45
76
  if [ "$EVENT" = "Stop" ]; then
@@ -54,5 +85,5 @@ PAYLOAD=$(cat 2>/dev/null | head -c 500)
54
85
  ) &
55
86
 
56
87
  # Claude Code 等宿主要求 stdout 以 { 开头才不告警。
57
- printf '{}\n'
88
+ printf '%s\n' "$BAIL"
58
89
  exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/install.js CHANGED
@@ -286,12 +286,16 @@ const server = async ({ client, directory }) => {
286
286
  }
287
287
  }
288
288
 
289
+ // 判定"今天是否收尾过": 必须匹配 log.md 的条目头 '## [YYYY-MM-DD HH:MM]'。
290
+ // 坑: 曾用裸日期 substring(includes('2026-09-10')), 结果正文/任务行里任何一处
291
+ // 提到今天就能把 nudge 永久压掉(误判为已收尾), 守卫形同虚设。
289
292
  async function loggedToday(brain) {
290
293
  try {
291
294
  const txt = await readFile(join(brain, "log.md"), "utf8")
292
295
  const d = new Date()
293
296
  const pad = (n) => String(n).padStart(2, "0")
294
- return txt.includes(d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()))
297
+ const today = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate())
298
+ return new RegExp("^## \\\\[" + today + " \\\\d{2}:\\\\d{2}\\\\]", "m").test(txt)
295
299
  } catch { return false }
296
300
  }
297
301
 
@@ -303,11 +307,24 @@ const server = async ({ client, directory }) => {
303
307
  "4) abs log \\"完成 X:...\\" 记一行工作成果,新页同步进 index。\\n" +
304
308
  "简洁执行,不要复述本条提醒。若本次确实没有可沉淀产出,直接回一句\\"无可沉淀\\"即可。"
305
309
 
310
+ // bash 里只跑查询类命令不算改文件 (与 pi 侧 READONLY_CMD 同义, 但生成代码里要写进模板串)
311
+ const READONLY_CMD = /^\\s*(ls|cat|grep|rg|find|head|tail|wc|git\\s+(status|log|diff|show|branch)|pwd|which|echo|node\\s+-v|npm\\s+(ls|view)|curl)\\b/
312
+
306
313
  return {
307
- // 观测真实写操作: write/edit/patch 类工具成功即标记
308
- "tool.execute.after": async ({ tool }) => {
314
+ // 观测真实写操作: write/edit/patch 类工具成功即标记。
315
+ // 坑: 曾漏掉 bash —— op​encode 里很多修改是经 bash(heredoc/sed) 完成的, 纯 bash 会话
316
+ // 永远不置位 wroteFiles, 于是收尾提醒静默不发。与 pi 侧 WRITE_TOOLS 保持一致。
317
+ "tool.execute.after": async ({ tool, args }) => {
309
318
  const t = String(tool || "").toLowerCase()
310
- if (["write", "edit", "patch", "multiedit", "apply_patch"].includes(t)) wroteFiles = true
319
+ if (["write", "edit", "patch", "multiedit", "apply_patch"].includes(t)) {
320
+ wroteFiles = true
321
+ return
322
+ }
323
+ // bash 里只有非只读命令算改文件(ls/cat/git status 之类不算)
324
+ if (t === "bash") {
325
+ const cmd = String((args && (args.command || args.cmd)) || "")
326
+ if (cmd && !READONLY_CMD.test(cmd)) wroteFiles = true
327
+ }
311
328
  },
312
329
 
313
330
  event: async ({ event }) => {
@@ -428,14 +445,19 @@ async function findBrain(cwd: string): Promise<string | null> {
428
445
  }
429
446
  }
430
447
 
431
- /** .brain/log.md 今天有记录吗? 有=已收尾, 不再打扰。 */
448
+ /**
449
+ * .brain/log.md 今天有记录吗? 有=已收尾, 不再打扰。
450
+ * 必须匹配条目头 '## [YYYY-MM-DD HH:MM]' —— 裸日期 substring 会被正文里任意一处
451
+ * 今天的日期误命中, 导致"已收尾"误判、nudge 永不触发。
452
+ */
432
453
  async function loggedToday(brain: string): Promise<boolean> {
433
454
  try {
434
455
  const txt = await readFile(join(brain, "log.md"), "utf8")
435
456
  const d = new Date()
436
457
  const pad = (n: number) => String(n).padStart(2, "0")
437
458
  const today = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate())
438
- return txt.includes(today)
459
+ // 注意: 这里是真实 TS, 不是生成代码的模板字符串。用拼接形式避免反斜杠逃逸歧义。
460
+ return new RegExp("^## \\\\[" + today + " \\\\d{2}:\\\\d{2}\\\\]", "m").test(txt)
439
461
  } catch { return false }
440
462
  }
441
463
 
package/src/store.js CHANGED
@@ -1,9 +1,10 @@
1
1
  // src/store.js — CLI 命令实现:图谱读写层。
2
2
  // 命令: init / board / status / load / task / query / lint
3
3
  import { promises as fs } from 'node:fs';
4
- import { join, resolve } from 'node:path';
4
+ import { join, resolve, dirname } from 'node:path';
5
+ import { homedir } from 'node:os';
5
6
  import { requireBrain, findBrainRoot, brainPath } from './index.js';
6
- import { addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, moveBlocked, insertDoneGrouped } from './todo.js';
7
+ import { addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, moveBlocked, insertDoneGrouped, idOfTaskLine } from './todo.js';
7
8
  import { editFile, SKIP } from './lock.js';
8
9
  import { appendWrapup, strandedFor, wrapupLogPath } from './wrapup.js';
9
10
 
@@ -192,6 +193,70 @@ export async function cmdWrapup({ dir }) {
192
193
  return appendWrapup(root);
193
194
  }
194
195
 
196
+ /**
197
+ * Stop hook 收尾注入判定 —— CC/Co​dex 的"主动推"。
198
+ * 与 pi 插件四条件一致: ①本会话真改过文件 ②有 .brain ③log.md 今日无条目 ④每会话一次。
199
+ * 输出给 shell: `push:<json>` = 注入, 其它 = 放行。JSON 走 stdout 回宿主的 decision:block。
200
+ * 所有异常一律放行(`{}`), 绝不因判定失败而卡住用户会话。
201
+ */
202
+ export async function cmdTeardownCheck({ dir, payload }) {
203
+ try {
204
+ let ev = {};
205
+ try { ev = JSON.parse(payload || '{}'); } catch { ev = {}; }
206
+
207
+ // 条件①: 本会话真改过文件。CC 的 Stop payload 不带工具足迹,
208
+ // 故以 transcript_path 为准; 取不到就退化为"本项目今日是否已有产物"判断。
209
+ const transcript = ev.transcript_path || ev.transcriptPath;
210
+ if (transcript) {
211
+ const txt = await readIfExists(transcript);
212
+ const wrote = /"(Write|Edit|MultiEdit|NotebookEdit)"/.test(txt);
213
+ if (!wrote) return '{}'; // 纯只读会话不打扰
214
+ }
215
+
216
+ // 条件②: 本项目有 .brain
217
+ const dir0 = dir || ev.cwd || process.cwd();
218
+ let root;
219
+ try { root = await requireBrain(dir0); } catch { return '{}'; }
220
+
221
+ // 条件③: log.md 今日尚无条目(条目头 '## [YYYY-MM-DD HH:MM]')
222
+ const logTxt = await readIfExists(brainPath(root, 'log.md'));
223
+ const stamp = localStamp(); // 'YYYY-MM-DD HH:MM'
224
+ const day = stamp.slice(0, 10);
225
+ if (new RegExp('^## \\[' + day + ' \\d{2}:\\d{2}\\]', 'm').test(logTxt)) return '{}';
226
+
227
+ // 条件④: 每会话一次。
228
+ // 双保险, 因为官方 stop_hook_active 有已知
229
+ // 不传播 bug(claude-code#54360): 同一 turn 内重复 fire 时它仍是 false。
230
+ // ① 官方契约: stop_hook_active=true = 本次 Stop 已是注入后的产物, 绝不再推(否则死循环)
231
+ if (ev.stop_hook_active === true) return '{}';
232
+ // ② 己方节流: 以 session_id 落 mark。缺 id 时退化为按项目+日期节流,
233
+ // 绝不"无节流"——否则一旦宿主张不到 id, decision:block 就会无限自激。
234
+ const sid = String(ev.session_id || ev.sessionId || '').replace(/[^\w-]/g, '');
235
+ const key = sid || 'nosession-' + day + '-' + root.replace(/[^\w]/g, '_');
236
+ const mark = join(homedir(), '.abs', 'log', `teardown-${key}.mark`);
237
+ try {
238
+ await fs.access(mark);
239
+ return '{}'; // 本会话(或本项目今日)已推过
240
+ } catch { /* 未推过 */ }
241
+ await fs.mkdir(dirname(mark), { recursive: true });
242
+ await fs.writeFile(mark, stamp).catch(() => {});
243
+
244
+ const msg = [
245
+ '[abs 收尾提醒] 本会话改过文件但 .brain/ 今天还没有记录。请立即走收尾循环:',
246
+ '1) 跑 abs load 看 Today 还有哪些未完成;',
247
+ '2) 实际做完漏登记的 abs task done <id>,做到一半的 abs task note <id> --note "断点";',
248
+ '3) 值得留的经验 abs note "..."(宁少勿滥,能从代码 grep 到的不记);',
249
+ '4) abs log "完成 X:..." 记一行工作成果,新页同步进 index。',
250
+ '简洁执行,不要复述本条提醒。若本次确实没有可沉淀产出,直接回一句"无可沉淀"即可。',
251
+ ].join('\n');
252
+
253
+ // Cl​aude Code Stop hook 契约: {"decision":"block","reason":"..."} = 阻止结束并把 reason 回灌给 agent
254
+ return 'push:' + JSON.stringify({ decision: 'block', reason: msg });
255
+ } catch {
256
+ return '{}'; // 永不阻塞宿主
257
+ }
258
+ }
259
+
195
260
  async function readIfExists(p) {
196
261
  try { return (await fs.readFile(p, 'utf8')).trim(); } catch { return ''; }
197
262
  }
@@ -255,9 +320,10 @@ async function markDone(file, id) {
255
320
  let changed = false;
256
321
  let moved = null;
257
322
  const kept = [];
323
+ const wantId = String(id).replace(/\u200b/g, '');
258
324
  for (let i = 0; i < lines.length; i++) {
259
325
  const l = lines[i];
260
- if (!moved && l.startsWith('- [ ]') && l.includes(id)) {
326
+ if (!moved && idOfTaskLine(l) === wantId) {
261
327
  changed = true;
262
328
  const head = l.replace('- [ ]', '- [x]').replace(/\(认领[^)]*\)/, '') + ` (完成 ${today()})`;
263
329
  const bp = [];
package/src/todo.js CHANGED
@@ -215,8 +215,8 @@ export async function upsertTask(brainRoot, { section, text }) {
215
215
  const out = await editFile(p, (orig) => {
216
216
  const base = normalizeTodo(orig);
217
217
  const lines = base.split('\n');
218
- // 在所有分区中找含该 id 的未完成任务行(Done 的已完成行不重复动)
219
- const idx = lines.findIndex((l) => l.startsWith('- [ ]') && id && l.includes(id));
218
+ // 在所有分区中找该 id 对应的未完成任务行(Done 的已完成行不重复动);全等比对
219
+ const idx = findTaskLine(lines, id);
220
220
  if (idx !== -1) {
221
221
  // 原位更新:保留断点附属行,只换任务行本体
222
222
  const oldNote = extractNote(lines[idx].replace(/^- \[ \] /, ''));
@@ -244,10 +244,25 @@ export function extractNote(text) {
244
244
  return i === -1 ? '' : String(text).slice(i + 3).replace(/\s*\(认领[^)]*\)\s*$/, '');
245
245
  }
246
246
 
247
- /** 定位含 id 的未完成任务行下标;附属断点行(↳ 开头)不算任务行。 */
247
+ /**
248
+ * 从任务行取出行首 id token(`- [ ] <id> …` 里的 <id>)。
249
+ * 坑: 不能用 l.includes(id) 定位任务 —— 那是子串匹配,`T1` 会命中 `T11`(同理
250
+ * TASK-1/TASK-10、fix-hook/fix-hook-2),导致 upsert 误判"已存在"而原地改写别的任务、
251
+ * 新任务静默消失。必须取出 id token 做全等比对。
252
+ * id 里可能混入零宽字符(历史瑕疵),比对前先剥掉。
253
+ */
254
+ export function idOfTaskLine(line) {
255
+ if (!line || !line.startsWith('- [ ]')) return null;
256
+ const m = line.match(/^- \[ \] (\S+)/);
257
+ return m ? m[1].replace(/\u200b/g, '') : null;
258
+ }
259
+
260
+ /** 定位 id 对应的未完成任务行下标;附属断点行(↳ 开头)不算任务行。
261
+ * 全等比对,不用 includes(否则 T1 会误命中 T11)。 */
248
262
  export function findTaskLine(lines, id) {
249
263
  if (!id) return -1;
250
- return lines.findIndex((l) => l.startsWith('- [ ]') && l.includes(id));
264
+ const want = String(id).replace(/\u200b/g, '');
265
+ return lines.findIndex((l) => idOfTaskLine(l) === want);
251
266
  }
252
267
 
253
268
  /** 实时断点: 在 id 任务行下原位补/换 `↳ 断点:` 附属行(不挪任务位置)。 */