@fanchao8609/agent_brain_sync 1.9.2 → 1.9.3

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
@@ -115,16 +115,38 @@ async function loggedToday(brain: string): Promise<boolean> {
115
115
  }
116
116
 
117
117
 
118
+ /** 收尾注入的节流状态。
119
+ *
120
+ * 为何在**模块级**而不在 absPiHook() 里(2026-09-17 实测):扩展会被重复注册
121
+ * (session_start 11 分钟内触发 8 次),每次注册都新建一份闭包 → flag 不共享 →
122
+ * 实测同会话 5 分钟注入 6 次。模块级状态在同一进程内只有一份,注册多少次都共享。
123
+ *
124
+ * teardownNudged = 本会话已注入过(终态,session_start 才重置)
125
+ * teardownInFlight = 正在判定中(抢在 await 之前置位,堵住并发重入)
126
+ * agentEndSeen = 本会话已留过 seen 痕(可观测性,同样每会话一次)
127
+ */
128
+ let teardownNudged = false
129
+ let teardownInFlight = false
130
+ let agentEndSeen = false
131
+
132
+ /** 会话边界重置节流(session_start 与注册时都调)。
133
+ * 不重置 → 同进程第二个会话继承 true 永久静默(2026-09-13 实测过的坑)。 */
134
+ function resetThrottle(): void {
135
+ teardownNudged = false
136
+ teardownInFlight = false
137
+ agentEndSeen = false
138
+ }
139
+
118
140
  export default function absPiHook(pi: ExtensionAPI): void {
119
- // 收尾注入的节流状态。声明在**外层** + 在 session_start 里重置:
120
- // 否则同一进程的第二个会话会继承上一个会话的 true,永久不再提醒
121
- // (2026-09-13 实测:同一天多个会话时后半场全部静默)。
122
- let teardownNudged = false
123
- let agentEndSeen = false
141
+ // 收尾注入的节流状态。故意声明在**模块级**(不在被反复调用的工厂函数里),
142
+ // 因为**扩展会被重复注册**:实测 session_start 在 11 分钟内触发 8 次
143
+ // (2026-09-17 00:07:58 一口气 3 次)。注册几份就得到几份独立闭包,
144
+ // 各自的 flag 彼此看不见 → “每会话一次”变成“每实例一次” → 实测同会话 5 分钟注入 6 次。
145
+ // 模块级状态在同一进程内共享,注册多少次都只有一份。
146
+ resetThrottle()
124
147
 
125
148
  pi.on("session_start", (event: any, _ctx: any) => {
126
- teardownNudged = false
127
- agentEndSeen = false
149
+ resetThrottle()
128
150
  return logHook("session_start").catch(() => {})
129
151
  })
130
152
 
@@ -152,8 +174,18 @@ export default function absPiHook(pi: ExtensionAPI): void {
152
174
  })
153
175
 
154
176
  // 收尾注入: 每个会话最多一次, 避免反复打扰。
177
+ //
178
+ // 坑(2026-09-17 实测定根因):原实现是「先检查 flag → 若干 await → 才置位」,
179
+ // 而 agent_end **每轮 run 结束都触发**(官方 docs/extensions.md:569:它不等于会话结束,
180
+ // Pi 之后还可能继续跑 follow-up)。并发的多次调用因此**全部先通过 `if (nudged) return`**,
181
+ // 之后才各自置位 → 一起注入。实测同会话 5 分钟内注入 6 次,全日志 55 次。
182
+ // 修法:进函数**第一个动作**就抢占(置位在任何 await 之前),失败再把标志放回。
183
+ // 这样“检查+设置”之间没有让出点,并发调用只有一个能赢。
155
184
  pi.on("agent_end", async (event: any, ctx: any) => {
156
- if (teardownNudged) return
185
+ if (teardownNudged || teardownInFlight) return
186
+ teardownInFlight = true
187
+ // 从这里往下任何提前 return 都必须解除 in-flight(否则本次会话永久不再提醒)。
188
+ const release = () => { teardownInFlight = false }
157
189
  const cwd = (ctx && ctx.cwd) || process.cwd()
158
190
  const brain = await findBrain(cwd)
159
191
  // 可观测性: 本会话首次 agent_end 无条件留一行痕。
@@ -163,14 +195,15 @@ export default function absPiHook(pi: ExtensionAPI): void {
163
195
  // 带上 cwd 与命中的 brain —— 否则事后无法解释“为何这条 nudge 会出现”
164
196
  await logHook(`agent_end:seen cwd=${cwd} brain=${brain || 'none'}`).catch(() => {})
165
197
  }
166
- if (!brain) return // 无图谱=不在这项目沉淀, 不打扰
167
- if (!hasWriteWork(event?.messages ? collectToolResults(event.messages) : [])) return
198
+ if (!brain) return release() // 无图谱=不在这项目沉淀, 不打扰
199
+ if (!hasWriteWork(event?.messages ? collectToolResults(event.messages) : [])) return release()
168
200
  // 今日已收尾 → 只在**本会话还没真正干事**时才静默。
169
201
  // 旧行为:今天 log 有一行就整天闭口 —— 于是收尾之后的产出全部没人提醒。
170
202
  // 注意不能用 sessionNotes.length 当判据:用户每说一句话就会 push 一条,
171
203
  // 那会让 nudge 每轮都触发(噪音)。判据保持「今天已收尾」但配合下面的笔记消费。
172
- if (await loggedToday(brain)) return
204
+ if (await loggedToday(brain)) return release()
173
205
  teardownNudged = true
206
+ teardownInFlight = false
174
207
  await logHook("agent_end:teardown-nudge").catch(() => {})
175
208
  // 素材交给 AI 后清空:同一会话再触发时不该重复喂旧料。
176
209
  const notes = sessionNotes.splice(0, sessionNotes.length)
package/hooks/event.sh CHANGED
@@ -31,19 +31,36 @@ fi
31
31
  (
32
32
  # 幂等: 同一 payload 指纹在 60s 内只落一行 (防重复触发)。
33
33
  # mark 目录可经 ABS_MARK_DIR 覆盖(默认 /tmp)——测试注入沙盒目录隔离, 避免与真实/并发残留互扰。
34
+ #
35
+ # 坑(2026-09-17 实测定根因):原实现把 mark 名钉在 STAMP=$(date +%Y%m%d%H%M)(分钟级)上,
36
+ # 而文档/测试约定的是【60s 窗口】。两者不等价:两次调用只要**跨过分钟边界**,
37
+ # STAMP 不同 → mark 路径不同 → [ -e ] 不命中 → 各写一行,实际窗口最坏缩到 1 秒。
38
+ # 实测复现(同 payload、STAMP 从 2359 跳到 0000)→ 落 2 行,违反 60s 承诺。
39
+ # 也是 test/hook.test.js「同一 payload 60s 内幂等」偶发失败的真因(不是测试写得不好)。
40
+ #
41
+ # 修法:mark 名**只含指纹**(与时间无关),幂等判据改看**写在 mark 里的时间戳**。
42
+ # 为何不靠 find -newermt:BSD find(macOS) 不认 `@epoch` 格式(实测报
43
+ # `Can't parse date/time: @1789619554`)→ 守卫恒不命中 → 幂等彻底失效。
44
+ # 也不用 -mmin:只能到分钟级,正是原 bug 的同类误差。
45
+ # 把时间戳写进文件、用 shell 纯数字比 —— 不依赖任何平台工具的日期解析。
34
46
  FINGER=$(printf '%s' "$PAYLOAD" | cksum | cut -d' ' -f1)
35
- STAMP=$(date +%Y%m%d%H%M)
36
47
  MARK_DIR="${ABS_MARK_DIR:-/tmp}"
37
48
  mkdir -p "$MARK_DIR" 2>/dev/null
38
- MARK="$MARK_DIR/abs-hook-${FINGER}-${STAMP}.mark"
39
- [ -e "$MARK" ] && exit 0
40
- : > "$MARK" 2>/dev/null
41
- # mark 只增不减: STAMP 是分钟级 → 每分钟一批, 永不回收。实测堆了 361 个。
42
- # 幂等窗口只 60s, 非本分钟的 mark 不可能再命中 → 清掉。
43
- # 注意: 同一分钟内不同 payload 有不同 FINGER, 它们各自合法 —— 只按 STAMP 清, 不按 FINGER。
49
+ MARK="$MARK_DIR/abs-hook-${FINGER}.mark"
50
+ NOW=$(date +%s)
51
+ PREV=$(cat "$MARK" 2>/dev/null)
52
+ case "$PREV" in ''|*[!0-9]*) PREV=0 ;; esac
53
+ # 60s 内已记过 → 幂等退出。窗口是真 60 秒,与分钟边界无关。
54
+ [ "$PREV" -gt 0 ] && [ "$((NOW - PREV))" -lt 60 ] && exit 0
55
+ printf '%s\n' "$NOW" > "$MARK" 2>/dev/null
56
+ # 清理: 只删**超龄**(>60s)的 mark。
57
+ # 不按分钟批量删 —— 那会在跨分钟时误删刚写的 mark(旧 bug 的帮凶)。
44
58
  for old in "$MARK_DIR"/abs-hook-*.mark; do
45
59
  [ -e "$old" ] || continue
46
- case "$old" in *-"$STAMP".mark) continue ;; esac
60
+ [ "$old" = "$MARK" ] && continue
61
+ OV=$(cat "$old" 2>/dev/null)
62
+ case "$OV" in ''|*[!0-9]*) OV=0 ;; esac
63
+ [ "$OV" -gt 0 ] && [ "$((NOW - OV))" -lt 60 ] && continue
47
64
  rm -f "$old" 2>/dev/null
48
65
  done
49
66
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.9.2",
3
+ "version": "1.9.3",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: abs-agent-brain-sync
3
- description: abs (agent-brain-sync) 跨会话 AI 编码记忆与任务续接。开场续接状态(abs load/MCP abs_load),干活中任务/经验实时落盘(abs_task/abs_note),需要收尾时才走收尾循环。解决会话无状态:经验/进度/坑碎片化、重开失忆。遇 bug 排查时配合挂载 bug-hunter skill。
3
+ description: abs (agent-brain-sync) 跨会话 AI 编码记忆与任务续接。开场续接状态(abs load/MCP abs_load),干活中任务/经验实时落盘(abs_task/abs_note),需要收尾时才走收尾循环。解决会话无状态:经验/进度/坑碎片化、重开失忆。
4
4
  ---
5
5
 
6
6
  # abs — 跨会话记忆 (agent-brain-sync)
@@ -389,6 +389,3 @@ abs supersede <页名> --by <取代它的新页> # 不写 --by 也行 = 单纯
389
389
  - 只读写 `.brain/` 与目标代码,不动全局配置(一次性接入除外)。
390
390
  - 只写真实发生的事实;遵守容量纪律,宁缺毋滥。
391
391
  - 双链/frontmatter/index 必须自洽 —— 坏链 = 掰断接力棒。
392
- - **排查 bug 时挂载 `bug-hunter` skill**(随 abs 一并安装,安装名为 `abs-bug-hunter`,位于 ~/.pi/agent/skills/abs-bug-hunter/ 等宿主对应目录):
393
- 完整排查流程(列假设 → 复现 → 定位改动 → 打印 → 顺藤摸瓜 → 根因 → 验证)
394
- 与「给结论要明确」都在那份里。**排查方法不写在本文件**(两份维护必漂)。
package/src/relevant.js CHANGED
@@ -159,6 +159,22 @@ function ngrams(s) {
159
159
  return out;
160
160
  }
161
161
 
162
+ /** 是否走 2-gram 模糊兜底。
163
+ *
164
+ * 只对【含中文】的查询词生效(2026-09-17 实测根因)。
165
+ * 为何要这道门:字符 2-gram 是给中文设计的("并发写"→"互斥"),中文 gram 有区分度;
166
+ * 英文没有 —— "relevant" 的 7 个 gram(re,el,ev,va,an,nt) 在**任意**英文页里都存在,
167
+ * ratio 恒为 1.0,稳过 FUZZY_MIN=0.6 → 每页都命中。
168
+ * 实测(44 页图谱):query relevant → 30 命中全是 fuzzy score=10,而 "relevant" 在
169
+ * 页面里出现 0 次;query store → 42/44 命中。这些命中是巧合子串,不是相关。
170
+ * 收紧后同一图谱:relevant 30→0、store 42→16(剩下的 16 是真子串命中)。
171
+ * 反证该门不误伤:20 个真实中文查询(并发写/静默失效/陈旧路径…)实测 fuzzy 命中全为 0,
172
+ * 全走精确路径 —— 收紧后逐字不变。
173
+ */
174
+ export function hasCJK(s) {
175
+ return /[\u4e00-\u9fa5]/.test(String(s || ''));
176
+ }
177
+
162
178
  /** 模糊门槛 + 最小 gram 数(见 rankPage 注释) */
163
179
  export const FUZZY_MIN = 0.6;
164
180
  // 查询词去重后的 gram 数必须≥4:否则 "zzzz不存在" 只剩 [不存,存在] 两个 gram,
@@ -209,12 +225,44 @@ export function rankPage(body, pageName, queryWords) {
209
225
  }
210
226
  return { kind: 'exact', matched: exact, score, overlap, ratio, via };
211
227
  }
212
- const qGramCount = new Set(queryWords.flatMap((w) => ngrams(String(w).toLowerCase()))).size;
213
- if (qGramCount >= FUZZY_MIN_GRAMS && ratio >= FUZZY_MIN)
228
+ const fuzzyWords = queryWords.filter(hasCJK);
229
+ const qGramCount = new Set(fuzzyWords.flatMap((w) => ngrams(String(w).toLowerCase()))).size;
230
+ if (fuzzyWords.length && qGramCount >= FUZZY_MIN_GRAMS && ratio >= FUZZY_MIN)
214
231
  return { kind: 'fuzzy', matched: [], score: ratio * 10, overlap, ratio, via: { tag: [], title: [], body: [] } };
215
232
  return null;
216
233
  }
217
234
 
235
+ /**
236
+ * 给候选词按「主题级强度」打分,供 hint 挑词用。
237
+ *
238
+ * 为何需要(2026-09-17 实测):hint 原取词是 `keywords()` 保序 + `slice(0,2)` ——
239
+ * 拿的是**最前面**的两个词,没有任何质量信号。而 todo 行以
240
+ * `- [ ] [进行中] <任务id> [[作者]] — 描述` 开头,于是任务 id 和作者名
241
+ * 稳定占据前两位,提示语变成 `abs query queryhint-noise tester 进行`。
242
+ * 实测这三个词的强度全为 0~1(noise 0 命中 / 进行 1 命中),等于没提示。
243
+ *
244
+ * 尺子 = tag / 页名级命中数(rankPage 的 via.tag / via.title)。
245
+ * 为何不用「正文命中数」:正文子串命中在 44 页图谱上随口一词就有十几页
246
+ * (实测 store 16、覆盖 18),不区分好坏;tag/页名是人工提炼的主题信号。
247
+ * 实测分离度:hook 6 / todo 4 / lock 2 vs queryhint-noise 0 / 进行 0。
248
+ *
249
+ * @param {{name:string,body:string}[]} pages
250
+ * @param {string[]} words
251
+ * @returns {Map<string, number>} 词 → 主题级命中数
252
+ */
253
+ export function topicStrength(pages, words) {
254
+ const out = new Map();
255
+ for (const w of words) {
256
+ let n = 0;
257
+ for (const p of pages) {
258
+ const r = rankPage(p.body, p.name, [w]);
259
+ if (r && (r.via.tag.length || r.via.title.length)) n++;
260
+ }
261
+ out.set(w, n);
262
+ }
263
+ return out;
264
+ }
265
+
218
266
  /** 取页正文的摘要:优先 frontmatter 的 description,退化到首个非标题行。 */
219
267
  export function digest(body, maxLen = 200) {
220
268
  const lines = String(body || '').split('\n');
package/src/store.js CHANGED
@@ -7,7 +7,7 @@ import { requireUser, atTag, getUser } from './userconfig.js';
7
7
  import { stripStateMark, ensureStateMark, normalizeTodo, addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, setStateMark, TASK_STATES, insertDoneGrouped, idOfTaskLine, archiveDoneInText, upsertArchiveSection, DONE_KINDS, withDoneKind, doneKindOf, doneDateOf, collapseDone, SEC, rebuildStructure } from './todo.js';
8
8
  import { editFile, SKIP } from './lock.js';
9
9
  import { appendWrapup, strandedFor } from './wrapup.js';
10
- import { keywords, pickRelevant, renderRelevant, recentFiles, rankPage } from './relevant.js';
10
+ import { keywords, pickRelevant, renderRelevant, recentFiles, rankPage, topicStrength } from './relevant.js';
11
11
 
12
12
  // ---------- init: 建 .brain/ 骨架 ----------
13
13
  const BRAIN_DIRS = ['entities', 'concepts', 'sources', 'syntheses', 'sessions'];
@@ -400,19 +400,40 @@ export function extractBlocks(todoText) {
400
400
  * 且无法按需求变化调整词。正确做法是把词准备好,查询仍由调用方发。
401
401
  *
402
402
  * 词从两个环境事实推:活跃 todo 的关键词 + 最近改过的文件名。
403
- * 输出保持极短(两行)—— 它只是提示,不是报告。
403
+ *
404
+ * 挑词用【主题级强度】排序,不取词表前几个(2026-09-17 实测修正):
405
+ * 原实现 `pick = [...en.slice(0,2), ...zh.slice(0,1)]` 取的是最前面的词,
406
+ * 而 todo 行以 `<任务id> [[作者]]` 开头 → 任务 id 与用户名稳占前两位,
407
+ * 实测输出 `abs query queryhint-noise tester 进行`(强度 0/1/0,全是废词)。
408
+ * 现按 topicStrength(tag/页名命中数)降序取,强度 0 的一律不提示
409
+ * —— 命中不了任何页的词,建议去查它等于没建议。
410
+ * 失败静默:读页出错就当没有强词,退回不提示(不影响 load)。
411
+ *
412
+ * @param {string} root 项目根
413
+ * @param {string} todoText
414
+ * @param {{name:string,body:string}[]} [pages] 已读好的页(省一次磁盘扫描)
404
415
  */
405
- async function queryHint(root, todoText) {
416
+ async function queryHint(root, todoText, pages) {
406
417
  const active = String(todoText || '')
407
418
  .split('\n')
408
419
  .filter((l) => /^\s*-\s*\[\s*\]/.test(l))
420
+ // 剔掉任务行的结构记号再分词:`- [ ] [状态] <id> [[作者]] —` 里只有「—」后面是真内容。
421
+ // 实测教训(2026-09-17):不剔的话 `<id>` 与 `[[作者]]` 会进入词表,
422
+ // 而作者名恰好有自己的 entities/<作者>.md 页 → 命中**页名** → 强度 1 过了门槛,
423
+ // 于是提示语变成「查一下你自己的名字」。建议查作者页不是“别重踩的经验”。
424
+ // 用 task 行自身的形状剥(与 idOfTaskLine 同源),不维护人名/任务名黑名单。
425
+ .map((l) => l.replace(/^\s*-\s*\[\s*\]\s*(?:\[[^\]]*\]\s*)?(?:\S+\s*)?/, '').replace(/\[\[[^\]]*\]\]/g, ' '))
409
426
  .join(' ');
410
427
  const files = await recentFiles(root, { n: 5 }).catch(() => []);
411
428
  const src = [active, files.map((f) => f.name).join(' ')].filter(Boolean).join(' ');
412
429
  const kws = keywords(src);
413
- // 只取 3 个:英文词优先(中文 2-gram 单看无意义)
414
- const en = kws.filter((k) => /^[a-z][a-z0-9_-]+$/.test(k));
415
- const zh = kws.filter((k) => !/^[a-z][a-z0-9_-]+$/.test(k));
430
+ if (!kws.length) return '';
431
+ const all = pages || await listPages(brainPath(root)).catch(() => []);
432
+ const strength = topicStrength(all.map((p) => ({ name: p.slug, body: p.body })), kws);
433
+ // 英文词优先(中文 2-gram 单看无意义,只做补充);每组内按强度降序
434
+ const byStrength = (a, b) => (strength.get(b) || 0) - (strength.get(a) || 0) || a.localeCompare(b);
435
+ const en = kws.filter((k) => /^[a-z][a-z0-9_-]+$/.test(k)).sort(byStrength).filter((k) => strength.get(k) > 0);
436
+ const zh = kws.filter((k) => !/^[a-z][a-z0-9_-]+$/.test(k)).sort(byStrength).filter((k) => strength.get(k) > 0);
416
437
  const pick = [...en.slice(0, 2), ...zh.slice(0, 1)].slice(0, 3);
417
438
  if (!pick.length) return '';
418
439
  const rows = [
@@ -1,179 +0,0 @@
1
- ---
2
- name: abs-bug-hunter
3
- description: 排查 bug 的流程纪律 —— 先列假设再动手,用数据定位,修根因。排查完把踩坑写进 .brain/。
4
- ---
5
-
6
- # Bug 排查方法论
7
-
8
- ## 核心原则
9
-
10
- **不凭空猜测,用数据说话。但数据不是终点 —— 先复现,再定位,再理解根因。**
11
-
12
- 最常见的失败:把"能复现"当成"已定位"。定位到出错的**位置** ≠ 找到**根因**。
13
- 改症状不改根因,换一批数据就复发。
14
-
15
- ## 第 -1 步:列假设(不能跳过)
16
-
17
- **触发**:用户报了症状("打不开""报错了""不对"),但没给一手错误信息。
18
-
19
- **这一步在"复现"之前**,因为拿不到报错时,"先复现"会逼你造假。
20
-
21
- ### 自造证据 —— 最毒的一类错误
22
-
23
- **真实案例**:只有一个假设时,它要么被证实要么思路断掉 —— 于是自己造一个输入去测:
24
-
25
- ```
26
- 编了个接口名 app.init → curl 测出 404 → "我测出来的,是事实"
27
- → 推出"nginx 没配 PATH_INFO"
28
- → 用户纠正后,重跑同一实验、得到同一 404 → 坚信自己没错
29
- ```
30
-
31
- **为什么最毒**:下游全程都是真的(404 真、命令真跑过)。唯一假的那环在**最上游**,
32
- 且已消失在过程里 → 所以"再验证一次"救不了,重跑同假输入得同真输出。
33
-
34
- ### 所以
35
-
36
- 1. **开局列 ≥2 个假设**(代码 / 配置 / 构建产物 / 缓存 / 第三方)。
37
- 只列一个 = 逼自己去证实它 = 造证据的动力。
38
- 2. **每个假设配反证条件**:什么现象出现就认它错。
39
- 反证条件里要写明**能从代码里读到的真实名字**,编造的自动不能用。
40
- 3. **卡住两轮就换假设**,不是在同一假设上加细节。
41
- 4. **领域外的先声明无取证能力**(构建产物在不在 / 缓存 / 第三方后台行为),
42
- 要用户提供事实,**不要脑补**。
43
-
44
- **结论所依据的每个具体名字(接口/字段/报错文本/路径)必须指得出处(文件:行)。**
45
- 指不出的只能标"推测",**不得当证据用**。
46
-
47
- > **为什么多假设能防造假**:单假设必须被证实,否则思路断 → 有动机造。
48
- > 多假设下,"支持了 A" ≠ "就是 A"(B/C/D 还没排除)→ 假证据推不出结论,造它就没意义。
49
-
50
- ## 第 0 步:复现
51
-
52
- 没有稳定复现,后面全是猜。
53
-
54
- - 复现的必要条件是什么?哪个输入/操作/环境触发?
55
- - 100% 复现还是偶发?偶发先找触发模式(特定数据/时机/顺序)。
56
- - 有现成错误栈吗?**错误信息 + 行号是最便宜的定位入口。**
57
-
58
- **给 Bug 起个名字**(一句话描述 + 触发条件)。卡住时回来核对是否还在同一个 Bug 上。
59
-
60
- ## 第一步:定位范围 —— 改动了什么
61
-
62
- Bug 不会凭空出现。先查最近的改动:
63
-
64
- ```bash
65
- git diff HEAD~3 --stat # 改了哪些文件
66
- git log -p -S '可疑字段/函数名' # 谁改过这段
67
- git blame <file> -L <行号,行号> # 定位到具体某行
68
- ```
69
-
70
- 问自己:这次新增/修改了什么?涉及哪些文件?哪个最可能影响出问题的地方?
71
-
72
- **别排查无关代码。**
73
-
74
- ## 第二步:让数据说话
75
-
76
- 不猜,打印出来看。
77
-
78
- - **打印关键数据,不要只打印"到了这里"** —— 要打印实际的变量值、类型、入参出参。
79
- - **二分法**:在可疑链路上隔几层插日志,先跑一遍看哪段有数据、哪段没有,
80
- 范围砍半再往里加。比一次打满所有层更快收敛。
81
- - **带唯一前缀**(如 `[BUG-01]`):日志里 grep 一次看到全链路顺序。
82
- - 怀疑并发/时序时,打时间戳与调用来源。
83
-
84
- ## 第三步:顺藤摸瓜
85
-
86
- 从用户操作出发,沿调用链一层层往下:入口 → 路由 → 中间件 → 业务层 → 数据层 → 存储。
87
-
88
- **每一层打印关键数据,找到数据从正确变为错误的那一层 —— bug 就在那一层。**
89
-
90
- ## 第四步:找根因,不只修症状
91
-
92
- 修复前回答:
93
-
94
- - 为什么数据在这一层变错了?**逻辑错误**(判断写反/取错字段)、
95
- **类型错误**(null/数组/对象混用)、还是**数据本身脏**(上游写入时就错)?
96
- - 根因若在上游,这里 patch 只是挡一下 —— 应该去修上游,或在入口统一兜底。
97
-
98
- **修一层,不改所有调用点**:如果多个地方调用同一函数,只在出问题的调用点打补丁,
99
- 其他调用点照样坏。**在共享函数里修一次,是所有调用点的最小修复。**
100
-
101
- ## 第五步:验证修复
102
-
103
- 不要凭空验证。**先拿第 0 步的复现条件重跑,必须看到它失败** ——
104
- 没失败就说明你修好了但没复现过,或者复现条件记错了。
105
- 不先看到 fail,就无法区分"修好了"和"根本没坏过"。
106
- 改完再看它变 pass,三层都要过:
107
-
108
- | 层 | 查什么 |
109
- |---|---|
110
- | **修好了** | 复现路径不再报错,输出正确 |
111
- | **没修坏** | 相关正常路径仍正常(回归) |
112
- | **边界还在** | 边缘输入(空值/超大值/并发/重复提交)没引入新洞 |
113
-
114
- 确认无误后再删调试日志。
115
-
116
- ## 排查完:写进 .brain/
117
-
118
- **这一步是 `abs-` 前缀的意义 —— 排查的结论不写下来,下个会话会重踩同一个坑。**
119
-
120
- ```bash
121
- abs note "一句话结论" --when "什么时候该看这条"
122
- ```
123
-
124
- 值得写的(**能从代码 grep 到的不写**):
125
-
126
- | 写什么 | 例 |
127
- |---|---|
128
- | **假象的根因** | "报错位置是 A,真因在 B 的写入侧" |
129
- | **判据** | "统计硬切率 >30% 即确诊,别去调 prompt" |
130
- | **反直觉处** | "慢的不是读写,是 node 启动" |
131
- | **排查路径** | "先查写入侧,别先怪模型" |
132
-
133
- 不值得写的:报错原文(日志里有)、修好的代码(git 里有)、通用常识。
134
-
135
- **如果是反复踩的坑**,排查结束后提成硬规则:
136
-
137
- ```bash
138
- abs rule add "靠提醒才能工作的功能,该删不该补"
139
- ```
140
-
141
- ## 常见陷阱
142
-
143
- | 陷阱 | 正确做法 |
144
- |------|----------|
145
- | 凭经验猜位置 | 先复现 + 看日志/打印 |
146
- | 只看代码不运行 | 打印运行时的实际数据 |
147
- | 一次改很多地方再测 | 改一处、验证一处 |
148
- | 修症状不修根因 | 追问"为什么这层数据变了" |
149
- | 只验证出错路径 | 复现 + 回归 + 边界 |
150
- | 忘记删调试代码 | 确认后清理所有打印 |
151
- | 只在本地验证 | 确认部署的代码/数据一致 |
152
- | 偶发当成必然 | 先找触发模式,别用单样本下结论 |
153
- | 排查完不落盘 | 提一条 `abs note`,反复踩的提 `abs rule` |
154
-
155
- ## 兜底:卡住时
156
-
157
- 排查 >20 分钟没进展,不硬扛,回流程检查:
158
-
159
- 1. **Bug 描述还准吗?** 新观察是否改变了问题定义。
160
- 2. **复现还稳定吗?** 换个触发样本还出现吗。
161
- 3. **漏看日志了吗?** grep 全量(不只最近的),可能早期就报过错。
162
- 4. **改动范围查全了吗?** 只看 HEAD~3 会漏分支合并、配置、部署差异。
163
- 5. **要不要问人?** 这条功能最近谁改的、意图是什么,可能一句话点醒。
164
- 6. **向上游看一层。** 来源方是否也变了,不只是消费方的问题。
165
-
166
- ## 收尾:结论要明确
167
-
168
- **不许**:"可能是 X,也可能是 Y,建议排查一下" —— 这是把判断推回给用户。
169
-
170
- **必须**给出:
171
-
172
- 1. **当前结论**(是什么 / 或"未定位")
173
- 2. **置信度与依据**(哪来的证据 / 还是只是推测)
174
- 3. **下一步具体动作**(跑什么、看什么、要用户提供什么)
175
-
176
- "未定位"是合法输出,但必须配一句"**需要你提供 X**"(具体到要什么),
177
- 不能只说"无法确定"就结束。
178
-
179
- > 与第 -1 步同一根因:**模糊化是逃避判断。** 要么给结论 + 依据,要么明确索取证据。