@fanchao8609/agent_brain_sync 1.8.8 → 1.8.9

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/src/store.js CHANGED
@@ -7,6 +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
11
 
11
12
  // ---------- init: 建 .brain/ 骨架 ----------
12
13
  const BRAIN_DIRS = ['entities', 'concepts', 'sources', 'syntheses', 'sessions'];
@@ -332,6 +333,27 @@ export async function cmdLoad({ dir }) {
332
333
  ''
333
334
  );
334
335
  }
336
+ // 卡点提取(2026-09-16,定位 = 看板可见性):
337
+ // 从 todo 里抽出带 `阻塞:` / `等待:` 的断点行,集中展示。
338
+ // 为何需要:断点行在任务下方,一屏扫过去看不出"哪些事在等人/等外部"。
339
+ // 只提取不求全 —— 只有明确写了前缀的才被抽出,没写的不猜。
340
+ const blocks = extractBlocks(todo);
341
+ if (blocks.length) {
342
+ const at = sections.findIndex((s) => String(s).startsWith('--- Rules'));
343
+ sections.splice(at === -1 ? 1 : at, 0,
344
+ `⛔ 卡点 ${blocks.length} 条(在等外部条件)`,
345
+ ...blocks.map((b) => ` - ${b.id}: ${b.why}`),
346
+ '');
347
+ }
348
+ // 相关页提示(2026-09-16):实测 abs_query 几乎不被调用(MCP 日志 task 234 / note 54 / load 53 vs query 2)。
349
+ // 根因:load 只列目录 → 模型觉得"我记过了" → 真需要时靠印象。模型不知道自己不知道什么。
350
+ // 解法(用户定):不替它查 —— 而是【把该查的词直接给出来】,降低发起查询的成本。
351
+ // 自动带出正文是错的方向:那会让 query 更没人用,且无法按需求变化调整词。
352
+ // 失败静默:这只是 additive 提示,出任何错都不应弄坏 load。
353
+ try {
354
+ const rel = await queryHint(root, todo);
355
+ if (rel) sections.push('', rel);
356
+ } catch { /* 提示失败不影响 load */ }
335
357
  // sources 堆积提醒:与「滞留」同构 —— 放在每次开工必经的顶部,而不是等人跑 lint。
336
358
  // 只数目录条目(不读文件),零成本。提炼仍手工:这里只负责送达,不替判断。
337
359
  const nsrc = await countSources(root);
@@ -346,6 +368,61 @@ export async function cmdLoad({ dir }) {
346
368
  return sections.join('\n');
347
369
  }
348
370
 
371
+ /**
372
+ * 从 todo 正文抽出"卡点"行,供 load 集中展示。
373
+ *
374
+ * 识别的前缀:`阻塞:` / `等待:` / `等 `(在断点/附属行里)。
375
+ * 为何只认前缀(不靠语义猜):没写就是没写,猜出来的卡点比不报更坑。
376
+ *
377
+ * @returns {{id:string, why:string}[]}
378
+ */
379
+ export function extractBlocks(todoText) {
380
+ const out = [];
381
+ let curId = null;
382
+ for (const line of String(todoText || '').split('\n')) {
383
+ // 任务行(未完成):`- [ ] [状态] ID [[who]] — 描述`
384
+ const t = line.match(/^\s*-\s*\[\s*\]\s*(?:\[[^\]]*\]\s*)?(\S+)/);
385
+ if (t) { curId = t[1]; continue; }
386
+ // 完成行:不再跟踪
387
+ if (/^\s*-\s*\[x\]/.test(line)) { curId = null; continue; }
388
+ if (!curId) continue;
389
+ // 附属行(↳ 开头或缩进行)里找卡点前缀
390
+ const m = line.match(/^\s*(?:↳\s*断点:\s*)?(?:阻塞|等待|等)[::]\s*(.+)$/);
391
+ if (m && m[1].trim()) out.push({ id: curId, why: m[1].trim() });
392
+ }
393
+ return out;
394
+ }
395
+
396
+ /**
397
+ * 开工提示:直接给出该查的几个词,降低发起 query 的成本。
398
+ *
399
+ * 为何不是"替它查"(2026-09-16 用户定):自动带出正文会让 query 更没人用,
400
+ * 且无法按需求变化调整词。正确做法是把词准备好,查询仍由调用方发。
401
+ *
402
+ * 词从两个环境事实推:活跃 todo 的关键词 + 最近改过的文件名。
403
+ * 输出保持极短(两行)—— 它只是提示,不是报告。
404
+ */
405
+ async function queryHint(root, todoText) {
406
+ const active = String(todoText || '')
407
+ .split('\n')
408
+ .filter((l) => /^\s*-\s*\[\s*\]/.test(l))
409
+ .join(' ');
410
+ const files = await recentFiles(root, { n: 5 }).catch(() => []);
411
+ const src = [active, files.map((f) => f.name).join(' ')].filter(Boolean).join(' ');
412
+ 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));
416
+ const pick = [...en.slice(0, 2), ...zh.slice(0, 1)].slice(0, 3);
417
+ if (!pick.length) return '';
418
+ const rows = [
419
+ '--- 开工前建议查一次(图谱里有相关经验,别重踩)---',
420
+ ` abs query ${pick.join(' ')}`,
421
+ ];
422
+ if (files.length) rows.push(` (据最近改动: ${files.slice(0, 3).map((f) => f.name).join(', ')})`);
423
+ return rows.join('\n');
424
+ }
425
+
349
426
  /** 数 sources/ 下的 .md 文件数(只读目录,不解析内容)—— load 顶部提醒用。
350
427
  * 坑: root 是【项目根】,图谱在 root/.brain/ 下 —— 必须走 brainPath,
351
428
  * 直接用 join(root,'sources') 会 ENOENT 被 catch 吞成 0,成为又一个静默失效。 */
@@ -768,7 +845,12 @@ export async function cmdTeardownCheck({ dir, payload }) {
768
845
  // ② 己方节流: 以 session_id 落 mark。缺 id 时退化为按项目+日期节流,
769
846
  // 绝不"无节流"——否则一旦宿主张不到 id, decision:block 就会无限自激。
770
847
  const sid = String(ev.session_id || ev.sessionId || '').replace(/[^\w-]/g, '');
771
- const key = sid || 'nosession-' + day + '-' + root.replace(/[^\w]/g, '_');
848
+ // 兼底 key 只用【项目名】而非 full path。
849
+ // 坑(2026-09-16 实测): 原用 root.replace(/[^\w]/g,'_') 会把 cwd 的 tmp 路径
850
+ // (/var/folders/..._tmpXXXX) 也编进去 —— 而每个会话的 tmp 目录都不同,
851
+ // 于是"按项目+日期节流"根本没生效, 每次都新 key → ~/.abs/log/ 堆了 361 个 mark。
852
+ const projKey = root.split('/').filter(Boolean).pop() || 'unknown';
853
+ const key = sid || 'nosession-' + day + '-' + projKey;
772
854
  const mark = join(absLogDir(), `teardown-${key}.mark`);
773
855
  try {
774
856
  await fs.access(mark);
@@ -776,6 +858,15 @@ export async function cmdTeardownCheck({ dir, payload }) {
776
858
  } catch { /* 未推过 */ }
777
859
  await fs.mkdir(dirname(mark), { recursive: true });
778
860
  await fs.writeFile(mark, stamp).catch(() => {});
861
+ // mark 只增不减会堆成垃圾(实测 361 个)。只清【带旧日期】的 mark ——
862
+ // 节流只在当天有意义(判据是 day), 旧日期 mark 不可能再命中;
863
+ // 无日期的会话 mark(teardown-<sid>.mark)保留, 它靠自身存在与否判重。
864
+ try {
865
+ for (const f of await fs.readdir(absLogDir())) {
866
+ const m = f.match(/^teardown-.*-(\d{4}-\d{2}-\d{2})-.*\.mark$/);
867
+ if (m && m[1] !== day) await fs.unlink(join(absLogDir(), f)).catch(() => {});
868
+ }
869
+ } catch { /* 清理失败不影响主流程 */ }
779
870
 
780
871
  const msg = [
781
872
  // 未设姓名时把设置指令插到第0条 —— 否则后续 todo add/log/note 全会被守卫拦下。
@@ -899,10 +990,10 @@ export async function cmdTask({ dir, action, id, section, note, as }) {
899
990
  cc = r.changed;
900
991
  return r.changed.length ? { text: r.text } : SKIP;
901
992
  });
902
- return cc.length ? `✓ ${id} 状态 → [${note}]` : `(未找到含 "${id}" 的未完成任务行,或状态未变)`;
993
+ return cc.length ? `✓ ${id} 状态 → [${note}]` : `[NO_MATCH] 未找到含 "${id}" 的未完成任务行,或状态未变`;
903
994
  }
904
995
  if (action === 'note') {
905
- if (!note) return '用法: abs todo note <id> --note "断点/进度"(实时落 ↳ 断点 行)';
996
+ if (!note) return '用法: abs todo note <id> --note "进度"\n 建议前缀(load 会把它们单独抽出来,让人一眼看到):\n 验证: <跑过的命令/结果>\n 边界: <这方案治不了什么>\n 阻塞: <在等什么,卡在哪>';
906
997
  const r = await setBreakpoint(root, { id, text: note });
907
998
  return r.msg;
908
999
  }
@@ -945,7 +1036,7 @@ async function markDone(file, id, kind = '落地') {
945
1036
  return { text: insertDoneGrouped(kept.join('\n'), moved) };
946
1037
  });
947
1038
  return res === SKIP
948
- ? `(未找到含 "${id}" 的未完成任务行)`
1039
+ ? `[NO_MATCH] 未找到含 "${id}" 的未完成任务行`
949
1040
  : `✓ 已完成并归位 Done: ${id} 【${kind}】`;
950
1041
  }
951
1042
 
@@ -1002,8 +1093,9 @@ export async function cmdQuery({ dir, terms, includeSuperseded }) {
1002
1093
  if (!f.endsWith('.md') || f.startsWith('_')) continue;
1003
1094
  const full = join(p, f);
1004
1095
  const body = await fs.readFile(full, 'utf8').catch(() => '');
1005
- const matched = words.filter((w) => body.toLowerCase().includes(w.toLowerCase()));
1006
- if (matched.length) {
1096
+ const slug = f.replace(/\.md$/, '');
1097
+ const rank = rankPage(body, slug, words);
1098
+ if (rank) {
1007
1099
  // 作者从本页 frontmatter 读(权威来源)。不在 index 行里重复 ——
1008
1100
  // index 行是覆盖式更新的,作者会从"创建者"漂成"最后改的人"。
1009
1101
  const au = body.match(/^author:\s*(.+)$/m);
@@ -1012,28 +1104,34 @@ export async function cmdQuery({ dir, terms, includeSuperseded }) {
1012
1104
  // 而不是回答"我上次怎么解決 X"(那会拿到一个已知错误的答案)。
1013
1105
  // 但它不是静默消失:计数里告知还有几条被隐藏(带 --all 能看)。
1014
1106
  if (st === 'superseded' && !includeSuperseded) {
1015
- hits.push({ hidden: true, slug: f.replace(/\.md$/, '') });
1107
+ hits.push({ hidden: true, slug });
1016
1108
  continue;
1017
1109
  }
1018
1110
  hits.push({
1019
- full, slug: f.replace(/\.md$/, ''), matched,
1111
+ full, slug, matched: rank.matched, kind: rank.kind, fuzzyScore: rank.score, via: rank.via,
1020
1112
  author: au ? au[1].trim() : '',
1021
- id: idOfPage(body, f.replace(/\.md$/, '')),
1113
+ id: idOfPage(body, slug),
1022
1114
  status: st,
1023
1115
  supersededBy: supersededByOf(body),
1024
- snippet: firstHitLine(body, words),
1116
+ snippet: firstHitLine(body, rank.matched.length ? rank.matched : words),
1025
1117
  });
1026
1118
  }
1027
1119
  }
1028
1120
  }
1029
1121
  const hidden = hits.filter((h) => h.hidden).length;
1030
- // 相关性排序:命中词多的排前面。
1031
- // 为什么不是严格 AND:26 页的量级下,「全词必须命中」经常直接归零(静默变成"查不到")。
1032
- // 故先用命中数排序,让「全命中」自然浮顶;命中太稀(只 1 页)时才提示可加词。
1033
- // 实测痛点(2026-09-15): 多词 OR 下查「安装 skill 报错」出 24/26 页(几乎全图)→ 相关性被稀释。
1034
- const shown = hits.filter((h) => !h.hidden)
1035
- .map((h) => ({ ...h, score: h.matched.length }))
1122
+ // 分两区:精确命中优先,模糊只作补充。
1123
+ // 坑(2026-09-16 实测): 不加区分时查 file-write-locking 返回 26 页(几乎全图)——
1124
+ // 连字符词的 2-gram 太通用,模糊命中把无关页也拉进来。
1125
+ // 规则:只要有任何精确命中,模糊命中的就不排在前面(但也不丢弃,单列一段)。
1126
+ const exactHits = hits.filter((h) => !h.hidden && h.kind === 'exact')
1127
+ .map((h) => ({ ...h, score: h.fuzzyScore }))
1128
+ .sort((a, b) => b.score - a.score);
1129
+ const fuzzyHits = hits.filter((h) => !h.hidden && h.kind === 'fuzzy')
1130
+ .map((h) => ({ ...h, score: h.fuzzyScore }))
1036
1131
  .sort((a, b) => b.score - a.score);
1132
+ // 有精确命中 → 模糊全藏起来(但不静默:告知数量 + 怎么看)
1133
+ const fuzzySuppressed = exactHits.length > 0 && fuzzyHits.length > 0;
1134
+ const shown = exactHits.length ? exactHits : fuzzyHits;
1037
1135
  if (!shown.length) {
1038
1136
  const extra = hidden ? `(另外 ${hidden} 页已标记 superseded,用 abs query ${words.join(' ')} --all 查看)` : '';
1039
1137
  return `query [${words.join(', ')}]: 无命中。${extra}用 abs lint 看图谱健康;首次使用先 abs init。`;
@@ -1045,13 +1143,19 @@ export async function cmdQuery({ dir, terms, includeSuperseded }) {
1045
1143
  const id = h.id && h.id !== h.slug ? ` [id: ${h.id}]` : '';
1046
1144
  // draft = 未经核实。不拦使用,但必须让 AI 知道这是它自己没验证过的。
1047
1145
  const st = h.status === 'draft' ? ' [draft 未核实]' : '';
1048
- const full = words.length > 1 && h.score === words.length ? ' ★全命中' : '';
1049
- return `📄 ${h.slug}${by}${id}${st} (命中: ${h.matched.join(', ')}${full})\n ${h.snippet}`;
1146
+ const full = h.kind === 'exact' && words.length > 1 && h.matched.length === words.length ? ' ★全命中' : '';
1147
+ // 模糊命中必须显式标出 —— 否则用户会把"语义相近"当成"真的是这条"。
1148
+ const fuzzy = h.kind === 'fuzzy' ? ' [模糊命中: 字面未出现,仅字形相近]' : '';
1149
+ const hitTxt = h.matched.length ? h.matched.join(', ') : '(无字面命中)';
1150
+ // 命中渠道:tag 最有价值(人工提炼的关键词),显式标出便于判断可信度
1151
+ const viaTag = h.via && h.via.tag.length ? ` [tag: ${h.via.tag.join(', ')}]` : '';
1152
+ return `📄 ${h.slug}${by}${id}${st}${fuzzy} (命中: ${hitTxt}${full})${viaTag}\n ${h.snippet}`;
1050
1153
  });
1051
1154
  // 多词且无全命中时告知降级了 —— 不静默给一堆弱相关结果。
1052
- const anyFull = shown.some((h) => h.score === words.length);
1155
+ const anyFull = shown.some((h) => h.kind === 'exact' && h.matched.length === words.length);
1053
1156
  const tail = [];
1054
1157
  if (words.length > 1 && !anyFull) tail.push('', `(无页同时命中全部 ${words.length} 个词,以下按命中数排序)`);
1158
+ if (fuzzySuppressed) tail.push('', `(另有 ${fuzzyHits.length} 页字形相近但字面未命中,已隐藏 —— 它们通常不相关)`);
1055
1159
  if (hidden) tail.push(``, `(${hidden} 页 superseded 已隐藏;--all 可看)`);
1056
1160
  return [`query [${words.join(', ')}] → ${shown.length} 页:`, '', ...lines, ...tail].join('\n');
1057
1161
  }
@@ -1092,9 +1196,9 @@ function firstHitLine(body, words) {
1092
1196
  // ---------- note: 经验实时暂存(source 页,一念一落,防流失) ----------
1093
1197
  const NOTE_DEDUP_MS = 60 * 1000;
1094
1198
 
1095
- export async function cmdNote({ dir, text, tags }) {
1199
+ export async function cmdNote({ dir, text, tags, when }) {
1096
1200
  const clean = String(text || '').trim();
1097
- if (!clean) return '用法: abs note "经验/坑/技巧一句话"(落 sources/ 暂存页,实时不流失)';
1201
+ if (!clean) return '用法: abs note "经验/坑/技巧一句话" [--when "何时该读它"](落 sources/ 暂存页,实时不流失)';
1098
1202
  let root;
1099
1203
  try {
1100
1204
  root = await requireBrain(dir || process.cwd());
@@ -1118,6 +1222,9 @@ export async function cmdNote({ dir, text, tags }) {
1118
1222
  const slugSrc = slugOf(clean);
1119
1223
  const file = `${today()}-${slugSrc || 'note'}.md`;
1120
1224
  const heading = clip(clean, 80); // 页面标题: 完整优先, 超长才收口
1225
+ // 触发条件(2026-09-16):经验"写入多读得少"的根因之一是存的是结论、不是"何时该看"。
1226
+ // 带上 --when 后,load 的相关页推荐能按当前在做的事匹配,而不是按主题词。
1227
+ const whenText = String(when || '').trim();
1121
1228
  const body = [
1122
1229
  '---',
1123
1230
  `tags: [${fmTags}]`,
@@ -1130,9 +1237,11 @@ export async function cmdNote({ dir, text, tags }) {
1130
1237
  `# 来源:${heading}`,
1131
1238
  '',
1132
1239
  `TITLE: ${clean}`,
1240
+ ...(whenText ? ['', `WHEN: ${whenText}`] : []),
1133
1241
  '',
1134
1242
  `## 记录(实时暂存,Teardown 时提炼进 concepts/ 后本页可删)`,
1135
1243
  `- ${clean}`,
1244
+ ...(whenText ? [`- 何时读:${whenText}`] : []),
1136
1245
  '',
1137
1246
  '## 关联连接',
1138
1247
  `- ${atTag(who)} — 本页沉淀者`,
package/src/todo.js CHANGED
@@ -724,7 +724,7 @@ export async function setBreakpoint(brainRoot, { id, text }) {
724
724
  return { text: lines.join('\n'), ok: true };
725
725
  });
726
726
  return res === SKIP
727
- ? { ok: false, msg: `(未找到含 "${id}" 的未完成任务行)` }
727
+ ? { ok: false, msg: `[NO_MATCH] 未找到含 "${id}" 的未完成任务行` }
728
728
  : { ok: true, msg: `✓ 断点已落 → ${id}\n ${bp.trim()}` };
729
729
  }
730
730
 
package/src/userconfig.js CHANGED
@@ -54,12 +54,16 @@ export async function setUser(name) {
54
54
  export async function requireUser() {
55
55
  const u = await getUser();
56
56
  if (u) return u;
57
- throw new Error(
58
- '✗ 尚未设置使用者姓名 —— 图谱需要标记每条记录的作者。\n' +
59
- ' 请任选其一设置后重试:\n' +
60
- ' abs config set user <你的名字> (写入 ~/.abs/config.json, 一次即可)\n' +
61
- ' ABS_USER=<你的名字> abs ... (仅本次生效)'
57
+ const e = new Error(
58
+ '✗ 尚未设置使用者姓名 —— 图谱需要标记每条记录的作者。'
62
59
  );
60
+ // 错误码:hook/脚本靠它区分「需先配置」与真故障(借 Anneal templateRefusal)。
61
+ e.code = 'NO_USER';
62
+ e.fallback =
63
+ '任选其一:\n' +
64
+ ' abs config set user <你的名字> (写入 ~/.abs/config.json, 一次即可)\n' +
65
+ ' ABS_USER=<你的名字> abs ... (仅本次生效)';
66
+ throw e;
63
67
  }
64
68
 
65
69
  /** 标记串:`[[name]]`(wikilink 到人页 entities/<name>.md)。
@@ -1,194 +0,0 @@
1
- ---
2
- name: abs-think-tree
3
- description: 回答前的固定思考骨架 —— 三层下坠式自问, 每层出多个候选互相竞争, 最后一个问题可给多个答案。用于减少"急于表达、只给一个答案、把无答案伪装成有答案"。
4
- ---
5
-
6
- # 三层思考骨架
7
-
8
- ## 为什么需要它
9
-
10
- 默认失败模式有三个,且都不是"不够聪明"造成的:
11
-
12
- | 失败模式 | 表现 |
13
- |---------|------|
14
- | 急于表达 | 还没想清就写答案,答案是第一个想到的 |
15
- | 只给一个答案 | 明明有几种合理解读,只交付一种,另外几种当没想过 |
16
- | 无答案装成有答案 | 问题本身没有确定答案,硬凑一个自圆其说的 |
17
-
18
- **不是让模型变聪明,是让它的偷懒可见。** 骨架填不满 = 露怯,比给个像样的答案更有价值。
19
-
20
- ## 核心:下坠 + 竞争
21
-
22
- ```
23
- 根: 用户原话(外部给定,不可改)
24
- └ L1 他为什么问 出 n 个候选,留最贴合原话的 ← 防答非所问
25
- └ L2 边界在哪 三条轴上定位,留最贴合 L1 的 ← 防答错方向(默认留 2)
26
- └ L3 他要什么 写出可验收产出形态,留最贴合 L2 的 ← 防给不出东西
27
- ↓
28
- 回答:沿存活路径给,有几条活路径就给几个答案
29
- ```
30
-
31
- **三层职能各自独立,不可合并**:L1 定动机 / L2 定边界 / L3 定产出。
32
- 少了 L2,答案容易落在错的轴上(问主观答客观、问当下答长期)。
33
-
34
- **竞争的关键:每层的裁判是父节点,不是答案。** 兄弟候选共享同一个父,所以能横向比较——
35
- "这两条哪个更贴合上面的问题",这是相对判断,不是自评,也不是造证据。
36
-
37
- **父节点始终是外部给定的**(L1 的父是用户原话),所以裁判链是外部的,模型无法自己定标准。
38
-
39
- ## 留几个(关键)
40
-
41
- 留几个**不看你想要几个,看有没有依据**:
42
-
43
- | 同层候选之间的差距 | 留 k | 依据 |
44
- |---|---|---|
45
- | 有明显差距(有候选明显不贴合父节点) | 可以留 1 | 竞争胜出,有裁判 |
46
- | **无差距(都同样贴合)** | **必须留 2** | 竞争无法裁决,砍任何一个都是无依据 |
47
-
48
- **默认留 2。** 因为大多数情况下同时存在几个同样合理的视角,而**同样合理时砍掉一个,是拿单一视角冒充完整回答**——这正是这套骨架要防的病。
49
-
50
- **无差距时强行留 1 的后果**(实测):问"要不要迁移到新框架",两个 L1 候选(评估成本收益 / 遇瓶颈想换)都贴合原话,
51
- 它们的 L2 边界差别只在"当下/长期"一条轴。留 1 就必须砍掉一边——
52
- **但这里没有胜者,砍谁都是丢掉一个合法视角。**
53
-
54
- **竞争结构的边界**:它只能淘汰差的,不能淘汰同样好的。兄弟差距为零时,没有裁判。
55
-
56
- ## 流程
57
-
58
- ### L1 他为什么问 —— 出 3 个,留 2
59
-
60
- 写 3 个**具体的后续动作**,不是心情。
61
-
62
- - ✅ "想快速定位原因"、"想确认是不是自己改错了"、"想让测试通过"
63
- - ❌ "想了解这个技术"、"想学习一下"(空泛,等于没写)
64
-
65
- **基准**:用户原话。写不出的直接标"原话不足以判断"。
66
-
67
- ### L2 他在问什么 —— 每支出 2 个,留 1~2(同上:无差距时留 2)
68
-
69
- **L2 的职能是划定问题边界,不是复述问题。** 复述不产生信息;划边界产生信息。
70
-
71
- 对每个存活的 L1,在三条轴上定位这个问题的边界:
72
-
73
- | 轴 | 两端 | 划错的后果 |
74
- |---|---|---|
75
- | 主观 / 客观 | "好不好吃" vs "营养成分" | 答成客观数据 |
76
- | 当下 / 长期 | "这顿吃啥" vs "长期吃水果好不好" | 答成养生建议 |
77
- | 判断 / 操作 | "要不要买" vs "怎么挑" | 给方法但没给结论 |
78
-
79
- - **每条轴必须落到一端**,不许写"两者都有"。
80
- - 必须能跟 L1 对上。
81
-
82
- **落不下去时,区分两种原因(路径生死相同,输出描述不同):**
83
-
84
- | 原因 | 含义 | 输出里怎么说 |
85
- |---|---|---|
86
- | 落不下去,因为候选本身空泛 | 动机没说清(如"想了解苹果") | "剪掉:动机不具体" |
87
- | 落不下去,因为信息不够 | 动机成立但原话没有线索 | "悬置:需要你补充 X" |
88
-
89
- 两者都使这一支不往下走,**但前者是判断,后者是缺信息**。不要把缺信息写成判断。
90
-
91
- - 不同 L1 可能落到同一个边界 → **汇聚 = 强信号**,记录下来。
92
-
93
- **示例**:"苹果好吃吗"
94
- - L1 = 想选水果 → L2 = 主观 + 当下 + 判断
95
- - 这个边界直接挡住"富含维 C"(客观)和"长期吃水果的好处"(长期)—— 那是在答另一个问题
96
-
97
- **L2 是没它就容易答错方向的一层。** 跳过 L2 直接由 L1 生成答案,最常见的失败是答案落在错的轴上。
98
-
99
- ### L3 他要什么 —— 写出可验收的产出形态
100
-
101
- 这一层是**全流程的地基**。
102
-
103
- - ✅ "一段能跑的代码"、"一个明确结论"、"2-3 个选项让我选"、"一个追问"
104
- - ❌ "一个全面的回答"、"一些建议"(不可验收)
105
-
106
- **写不出可验收形态时,不许硬写。** 直接说明:
107
- > 这个问题没有可验收的标准,我给的是参考,不是答案。
108
-
109
- 这一条是本骨架最重要的产物——它把"无答案"从失败变成一个合法输出。
110
-
111
- ### 回答
112
-
113
- 沿存活路径写。四条规矩:
114
-
115
- 1. **每条不同的 L2/L3 路径对应一个答案,不合并。** 有 3 条活路径就给 3 个答案。
116
- 2. **汇聚优先,分歧附为例外**(不是并列):
117
- - 多条路径汇聚 → 当作**主结论**,可标"多条路径汇聚于此"
118
- - 少数路径分歧 → 当作**例外**附在后面,写明它在哪条轴上不同
119
- - ❌ 不要写成两个平行答案 —— 那会让用户以为两者同等重要
120
-
121
- **示例**(三选一汇聚、一条分歧):
122
- > 主:问的是**主观口味**,苹果甜脆多汁,这几个维度自己判断。
123
- > 例外:**如果你要给小孩吃,问题就变成安全性(客观轴)**,那是另一个问题,得单独说。
124
-
125
- 3. **不选最优。** 骨架禁止输出"综合来看最佳答案是……"这种合并。
126
- 4. **不回头改上层。** L3 写不出可验收形态时,不许回头改 L2 的边界。
127
- **理由:下级不能改上级。** L3 没有比 L2 更高的裁判,让它改 L2 = 自证。
128
- 正确做法:直接在 L3 露怯("这题我给参考不是答案")。
129
-
130
- ## 能力边界(不是 bug,无法靠本骨架解决)
131
-
132
- 竞争结构的裁判链是"父节点",而父节点有它看不到的地方:
133
-
134
- | 边界 | 说明 |
135
- |---|---|
136
- | **只能淘汰差的,不能淘汰同样好的** | 兄弟候选同样贴合父节点时,没有裁判 |
137
- | **只能一致地错,不能发现自己错了** | 父节点错了(如 L1 动机判断错),子节点会沿着它一致错下去;竞争范围只在兄弟之间,淘汰不了父 |
138
- | **无外部信号** | 要发现"父错了",只能靠用户纠正或可运行的验证(编译/测试/算数) |
139
-
140
- **本骨架保证一致性,不保证方向。** 它防的是"同一套逻辑内部自相矛盾";
141
- 不防"整套逻辑从根上就错了"——那需要外部裁判。
142
-
143
- ## 自检(答完前扫一眼)
144
-
145
- | 检查项 | 不合格的样子 |
146
- |-------|-------------|
147
- | L1 三条是否互斥? | 三条其实是同一件事的换说法 |
148
- | L1 是否具体到动作? | "想了解一下" |
149
- | L2 三条轴是否都落到一端? | 写"两者都有"、没划边界就进 L3 |
150
- | L2 能否对回 L1? | 对不上还留着 |
151
- | L3 是否可验收? | "给出好的回答" |
152
- | 是否偷偷合并了多解? | 说了三种可能,最后只答一种 |
153
- | 分歧是否被藏起来了? | 两条路径结论冲突,装作没看见 |
154
- | 无答案时是否露怯? | 硬凑一个"综合"答案 |
155
-
156
- ## 陷阱
157
-
158
- | 陷阱 | 正确做法 |
159
- |------|---------|
160
- | 把 L1 写成用户心情 | 写成具体后续动作 |
161
- | L2 复述问题(废层) | L2 必须划边界:主观/客观 · 当下/长期 · 判断/操作 |
162
- | L2 写"两者都有" | 每条轴必须落到一端,落不下去就作废这一支 |
163
- | 所有层都填满还答错 | 填满是必要不充分;基准是父节点,不是自评 |
164
- | 层层筛选只剩一条 | k 至少留 2 条到终点,否则退回"急于表达" |
165
- | 同层候选无差距却留 1 | 无差距必须留 2;只有有明显差距时才能靠竞争留 1 |
166
- | 为了填满而编候选 | 编不出来时写"原话不足以判断",这本身是结论 |
167
- | 用"综合来看"合并多解 | 禁止。分歧就是分歧,列出来 |
168
- | 无答案时硬凑 | 明确说"这题没有可验收标准" |
169
- | 把骨架当形式走一遍 | 填完还是答第一个想到的 = 没走 |
170
-
171
- ## 边界(这套骨架不适用的时候)
172
-
173
- - **问题本身有唯一正确答案时**(算术、编译):不需要分叉,直接答 + 验证。
174
- - **用户明确要一个答案时**:给一个,别列 3 个让用户挑。
175
- - **纯闲聊**:走这套会显得像官僚流程。
176
-
177
- **本骨架真正的适用面:需求模糊、可能有多种合理解读、或根本没有确定答案的问题。**
178
-
179
- ## 成本
180
-
181
- 一次思考内完成,不是 n 次调用。**n 条线是同一个 forward pass 里的 n 个位置**,
182
- 上下文共享,token 只比直接回答多约 30%。不烧 token 的关键是**不来回**——
183
- 不在生成和外部判定之间反复起调用。
184
-
185
- ## 收尾:结论必须明确
186
-
187
- 答完必须让用户能判断:
188
-
189
- 1. **给了几个答案**(一条路径就一个,多条就列全)
190
- 2. **哪几条汇聚了**(置信较高处)
191
- 3. **哪几条分歧了**(需要用户定夺处)
192
- 4. **是否无答案**(若 L3 写不出,明确说)
193
-
194
- 不许用"综合来看建议……"把分歧抹平。
@@ -1,119 +0,0 @@
1
- /**
2
- * think-tree 自证器 —— 检查一次三层思考的填写质量。
3
- *
4
- * 用法:
5
- * node skill/abs-think-tree/check.js <填好的答案文件.md>
6
- *
7
- * 输入格式(答案文件):
8
- * ## L1
9
- * - [动作] 想快速定位原因
10
- * - [动作] 想确认是不是自己改错了
11
- * ## L2
12
- * - [客观][当下][判断] 在问因果
13
- * ## L3
14
- * - [产出] 一个嫌疑点 + 证据 + 验证方法
15
- *
16
- * 各类标签是硬判据: 不写 = 报错, 不是"提醒"。
17
- * 目的: 把"贴合用户原话"这句软话, 拆成能失败的检查。
18
- */
19
-
20
- const ACTION_VERBS = /(定位|确认|判断|决定|选择|选定|挑选|修改|查找|比较|评估|验证|交付|得到|知道|排除|复现|区分|排查|了解|收尾|继续|停止|给出|输出|回答|说明|解释|分析|检查|拆|推|排|定|学)/;
21
-
22
- /** L2 三轴标签。必须三轴齐全, 缺一轴 = 边界没划全。 */
23
- const AXES = ["主观|客观", "当下|长期", "判断|操作"];
24
-
25
- /** L3 可验收产出类型的白名单。 */
26
- const OUTPUT_KINDS = ["代码", "结论", "选项", "追问", "证据", "复现条件", "命令"];
27
-
28
- function fail(list, msg) {
29
- list.push(msg);
30
- }
31
-
32
- /** L1: 每个候选必须是"动作", 不能是"心情"。 */
33
- function checkL1(lines, errs) {
34
- if (!lines.length) return fail(errs, "L1 为空: 至少写 1 个动机候选");
35
- if (lines.length < 2) fail(errs, "L1 只有 1 个候选: 无兄弟则无法竞争");
36
- lines.forEach((l, i) => {
37
- const m = l.match(/^-\s*\[动作\]\s*(.+)$/);
38
- if (!m) return fail(errs, `L1[${i}] 缺 [动作] 标签: "${l}"`);
39
- const body = m[1].trim();
40
- if (!body) return fail(errs, `L1[${i}] 标签后为空`);
41
- if (!ACTION_VERBS.test(body))
42
- fail(errs, `L1[${i}] 不像动作(无动词): "${body}"`);
43
- // 心情词黑名单
44
- if (/(了解一下|学习一下|感兴趣|好奇|随便看看)/.test(body))
45
- fail(errs, `L1[${i}] 是心情不是动作: "${body}"`);
46
- });
47
- }
48
-
49
- /** L2: 三轴必须齐全, 且每轴只能落一端。 */
50
- function checkL2(lines, errs) {
51
- if (!lines.length) return fail(errs, "L2 为空: 边界没划");
52
- lines.forEach((l, i) => {
53
- const m = l.match(/^-\s*((?:\[[^\]]+\])+)\s*(.+)$/);
54
- if (!m) return fail(errs, `L2[${i}] 缺轴标签: "${l}"`);
55
- const tags = [...m[1].matchAll(/\[([^\]]+)\]/g)].map((x) => x[1]);
56
- AXES.forEach((ax) => {
57
- const ends = ax.split("|");
58
- const hit = tags.filter((t) => ends.includes(t));
59
- if (!hit.length) fail(errs, `L2[${i}] 缺轴 [${ax}]: 边界没划全`);
60
- if (hit.length > 1) fail(errs, `L2[${i}] 轴 [${ax}] 落了两端: 必须只落一端`);
61
- });
62
- if (/(两者都有|都算|不确定|看情况)/.test(m[2]))
63
- fail(errs, `L2[${i}] 边界含糊(写了"两者都有"之类): "${m[2]}"`);
64
- });
65
- }
66
-
67
- /** L3: 必须能指出可验收产出类型, 或明确露怯。 */
68
- function checkL3(lines, errs) {
69
- if (!lines.length) return fail(errs, "L3 为空: 既没给产出也没露怯");
70
- lines.forEach((l, i) => {
71
- // 露怯分支: 明确说没有可验收标准 —— 合法
72
- if (/\[无验收标准\]/.test(l)) {
73
- if (!/给(的)?是参考|不是答案|无法验收/.test(l))
74
- fail(errs, `L3[${i}] 标了无验收标准但没明说给的是参考: "${l}"`);
75
- return;
76
- }
77
- const m = l.match(/^-\s*\[产出\]\s*(.+)$/);
78
- if (!m) return fail(errs, `L3[${i}] 缺 [产出] 或 [无验收标准] 标签: "${l}"`);
79
- if (!OUTPUT_KINDS.some((k) => m[1].includes(k)))
80
- fail(errs, `L3[${i}] 产出不可验收(不含白名单类型 ${OUTPUT_KINDS.join("/")}): "${m[1]}"`);
81
- });
82
- }
83
-
84
- /** L2 是否逐条能对回 L1 的存活支 —— 需要显式写的 [父:N], 且不得越界。 */
85
- function checkTrace(lines, l1count, errs) {
86
- lines.forEach((l, i) => {
87
- const m = l.match(/\[父:(\d+)\]/);
88
- if (!m) return fail(errs, `L2[${i}] 缺 [父:N] 标注: 无法证明它来自哪个 L1 支`);
89
- const n = Number(m[1]);
90
- if (n >= l1count)
91
- fail(errs, `L2[${i}] [父:${n}] 越界: L1 只有 ${l1count} 条, 指向不存在的父`);
92
- });
93
- }
94
-
95
- export function check(text) {
96
- const errs = [];
97
- const sec = { L1: [], L2: [], L3: [] };
98
- let cur = null;
99
- for (const raw of text.split("\n")) {
100
- const h = raw.match(/^##\s*(L[123])\s*$/);
101
- if (h) { cur = h[1]; continue; }
102
- if (cur && raw.trim().startsWith("-")) sec[cur].push(raw.trim());
103
- }
104
- checkL1(sec.L1, errs);
105
- checkL2(sec.L2, errs);
106
- checkTrace(sec.L2, sec.L1.length, errs);
107
- checkL3(sec.L3, errs);
108
- return { ok: errs.length === 0, errors: errs, counts: { L1: sec.L1.length, L2: sec.L2.length, L3: sec.L3.length } };
109
- }
110
-
111
- if (import.meta.url === `file://${process.argv[1]}`) {
112
- const fs = await import("node:fs");
113
- const p = process.argv[2];
114
- if (!p) { console.log("用法: node check.js <答案文件.md>"); process.exit(2); }
115
- const r = check(fs.readFileSync(p, "utf8"));
116
- console.log(`L1=${r.counts.L1} L2=${r.counts.L2} L3=${r.counts.L3}`);
117
- if (r.ok) console.log("PASS");
118
- else { console.log(`FAIL (${r.errors.length})`); r.errors.forEach((e) => console.log(" ✗ " + e)); process.exit(1); }
119
- }