@fanchao8609/agent_brain_sync 1.9.6 → 1.9.8

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
@@ -8,6 +8,11 @@ import { stripStateMark, ensureStateMark, normalizeTodo, addTask, upsertTask, bo
8
8
  import { editFile, SKIP } from './lock.js';
9
9
  import { appendWrapup, strandedFor } from './wrapup.js';
10
10
  import { keywords, pickRelevant, renderRelevant, recentFiles, rankPage, topicStrength } from './relevant.js';
11
+ import { impactOf } from './codegraph.js';
12
+
13
+ // Re-export lint.js symbols so external importers (e.g. bin/mcp.js, bin/abs.js) still work.
14
+ export { cmdLint, listPages, hasTail, PAGE_DIRS } from './lint.js';
15
+ import { SOURCES_MAX, PAGE_DIRS, listPages } from './lint.js';
11
16
 
12
17
  // ---------- init: 建 .brain/ 骨架 ----------
13
18
  const BRAIN_DIRS = ['entities', 'concepts', 'sources', 'syntheses', 'sessions'];
@@ -132,8 +137,7 @@ export function logTemplate() {
132
137
  * 标记不一致 = 直接改成标准(“能自己处理的先处理”)。
133
138
  * 只按**整行精确匹配**改标题,绝不动正文 —— 不做模糊替换,否则正文里提到的旧名会被误改。 */
134
139
  // sources/ 堆积阀值:超过就是「采了没消化」。lint 报 SOURCES-PILED-UP,load 顶部同步提示。
135
- // 两处共用同一常量 —— 阀值只有一个真源。
136
- const SOURCES_MAX = 10;
140
+ // 两处共用同一常量 —— 阀值只有一个真源(SOURCES_MAX 在 lint.js 定义,此处导入使用)。
137
141
 
138
142
  export const BRAIN_SHAPE = {
139
143
  'todo.md': {
@@ -351,20 +355,18 @@ export async function cmdLoad({ dir }) {
351
355
  // 自动带出正文是错的方向:那会让 query 更没人用,且无法按需求变化调整词。
352
356
  // 失败静默:这只是 additive 提示,出任何错都不应弄坏 load。
353
357
  try {
354
- const rel = await queryHint(root, todo);
358
+ const rel = await queryHintSection(root, todo);
355
359
  if (rel) sections.push('', rel);
356
360
  } catch { /* 提示失败不影响 load */ }
357
361
  // sources 堆积提醒:与「滞留」同构 —— 放在每次开工必经的顶部,而不是等人跑 lint。
358
362
  // 只数目录条目(不读文件),零成本。提炼仍手工:这里只负责送达,不替判断。
359
- const nsrc = await countSources(root);
360
- if (nsrc > SOURCES_MAX) {
361
- // 插在滞留之后 / Rules 之前:滞留更紧急(卡住当前工作),消化其次。
362
- const at = sections.findIndex((s) => String(s).startsWith('--- Rules')) ;
363
- sections.splice(at === -1 ? 1 : at, 0,
364
- `♻ 待消化: sources/ 有 ${nsrc} 条 > ${SOURCES_MAX}(采集了没提炼)`,
365
- '→ abs lint 看明细;提炼成 concepts/ 后删 source 并清引用',
366
- '');
367
- }
363
+ try {
364
+ const srcWarn = await sourcesCountSection(root);
365
+ if (srcWarn) {
366
+ const at = sections.findIndex((s) => String(s).startsWith('--- Rules'));
367
+ sections.splice(at === -1 ? 1 : at, 0, ...srcWarn);
368
+ }
369
+ } catch { /* 提醒失败不影响 load */ }
368
370
  return sections.join('\n');
369
371
  }
370
372
 
@@ -454,6 +456,22 @@ async function countSources(root) {
454
456
  } catch { return 0; }
455
457
  }
456
458
 
459
+ /** queryHint 的 load 端封装:返回提示文本(含标题行),无强词则空串。失败静默。 */
460
+ async function queryHintSection(root, todo) {
461
+ return queryHint(root, todo);
462
+ }
463
+
464
+ /** sources 堆积提醒:返回 [标题行, ...内容行] 或 null。失败静默。 */
465
+ async function sourcesCountSection(root) {
466
+ const nsrc = await countSources(root);
467
+ if (nsrc <= SOURCES_MAX) return null;
468
+ return [
469
+ `♻ 待消化: sources/ 有 ${nsrc} 条 > ${SOURCES_MAX}(采集了没提炼)`,
470
+ '→ abs lint 看明细;提炼成 concepts/ 后删 source 并清引用',
471
+ '',
472
+ ];
473
+ }
474
+
457
475
  // ---------- Rules: index.md 里的硬规则区 ----------
458
476
  /** index.md 的 `## Rules` 区名与上限。 */
459
477
  export const RULES_HEADING = '## Rules';
@@ -691,6 +709,71 @@ export async function resolvePage(root, idOrSlug) {
691
709
  return null;
692
710
  }
693
711
 
712
+ // ---------- review: 待确认页队列(draft → active/superseded) ----------
713
+ // 为何需要:abs note 落的是 status: draft(未经核实),但之前没有「确认」这一步 ——
714
+ // draft 只是标签,没人管,经验就永远停在「待核实」状态,从不正式化。
715
+ // 借鉴 TencentDB 的 review/route 治理环节:提取后必经审查,防止脏知识进入正式图谱。
716
+ // 本命令只做「把 draft 显式升为 active 或否决为 superseded」,不替人判断内容好坏。
717
+ // 动作收口在一处(editFile 锁内),并发安全同 supersede。
718
+ export async function cmdReview({ dir, refs, action }) {
719
+ let root;
720
+ try {
721
+ root = await requireBrain(dir || process.cwd());
722
+ } catch {
723
+ return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
724
+ }
725
+ const act = String(action || '').toLowerCase();
726
+ if (act && !['accept', 'reject'].includes(act)) {
727
+ return '用法: abs review [--accept <页名…>] [--reject <页名…>] — 无参数列出全部 draft 页';
728
+ }
729
+ // 无动作 → 列出所有 draft 页(待确认队列)
730
+ if (!act) {
731
+ const pages = await listPages(brainPath(root));
732
+ // 只扫经验/知识目录(concepts/sources)。entities 是人页、sessions 是日志,
733
+ // 它们不是「待核实的经验」,不该进 review 队列(拉进来会把人页/日志当经验误确认)。
734
+ const REVIEW_DIRS = ['concepts', 'sources'];
735
+ const drafts = pages.filter((p) => REVIEW_DIRS.includes(p.dir) && statusOfPage(p.body) === 'draft');
736
+ if (!drafts.length) return '✓ 没有待确认的 draft 页。';
737
+ const lines = drafts.map((p) => {
738
+ const t = p.body.match(/^#\s*(.+)$/m);
739
+ const title = t ? t[1].trim() : p.slug;
740
+ return ` [draft] ${p.slug} — ${title}`;
741
+ });
742
+ return [
743
+ `待确认 draft 页 ${drafts.length} 条:`,
744
+ ...lines,
745
+ '',
746
+ '确认: abs review --accept <页名> [更多…] 否决: abs review --reject <页名> [更多…]',
747
+ ].join('\n');
748
+ }
749
+ // 有动作 → 对每个 ref 改 status
750
+ const list = (refs || []).map((r) => String(r).trim()).filter(Boolean);
751
+ if (!list.length) return `✗ --${act} 需要至少一个页名。用法: abs review --${act} <页名…>`;
752
+ const target = act === 'accept' ? 'active' : 'superseded';
753
+ const out = [];
754
+ for (const r of list) {
755
+ const hit = await resolvePage(root, r);
756
+ if (!hit) { out.push(`✗ ${r}: 未找到(试 abs review 看清单)`); continue; }
757
+ const res = await editFile(hit.full, (cur) => {
758
+ if (!cur || !cur.startsWith('---\n')) return SKIP;
759
+ const end = cur.indexOf('\n---', 3);
760
+ if (end === -1) return SKIP;
761
+ let fm = cur.slice(0, end);
762
+ const curSt = statusOfPage('', fm);
763
+ // 幂等:已是目标状态 → 不写盘
764
+ if (curSt === target) return SKIP;
765
+ fm = STATUS_RE.test(fm)
766
+ ? fm.replace(STATUS_RE, `status: ${target}`)
767
+ : `${fm}\nstatus: ${target}`;
768
+ return { text: fm + cur.slice(end) };
769
+ });
770
+ out.push(res === SKIP
771
+ ? `= ${hit.slug}: 已是 ${target}(无变化)`
772
+ : `✓ ${hit.slug} → ${target}`);
773
+ }
774
+ return out.join('\n');
775
+ }
776
+
694
777
  // ---------- resolve: id/slug → 页面路径(引用的反查端) ----------
695
778
  // 配合 frontmatter 的 id: 使用。页改名后 id 不变,靠本命令仍能找回来。
696
779
  export async function cmdResolve({ dir, refs }) {
@@ -1095,143 +1178,14 @@ export async function cmdShow({ dir, view, full }) {
1095
1178
  if (!text) return `(${v}.md 为空)`;
1096
1179
  return v === 'todo' ? boardText(root, text, { full: !!full }) : text;
1097
1180
  }
1098
- // ---------- query: 检索知识图谱(多词 OR,扫全 .md 页) ----------
1099
- const KNOWN_SLUG_HINT = /模板残留|\[\[slug\]\]/;
1100
-
1101
- export async function cmdQuery({ dir, terms, includeSuperseded }) {
1102
- // 每个 term 内部再按空白拆 —— 让 `abs query "发布 流程"` 与 `abs query 发布 流程` 等价。
1103
- // 坑: 曾经引号包起来的 "发布 流程" 被当成一个完整短语 → 全图无命中。
1104
- // 用户看到「无命中」会以为图谱里没这条经验,实际只是词没被拆开(静默失效)。
1105
- const words = [...new Set(
1106
- (terms || []).flatMap((w) => String(w).trim().split(/\s+/)).filter(Boolean)
1107
- )];
1108
- if (!words.length) {
1109
- return '用法: abs query <词1> [词2 …] — 多词检索 .brain/ 全部知识页';
1110
- }
1111
- let root;
1112
- try {
1113
- root = await requireBrain(dir || process.cwd());
1114
- } catch {
1115
- return `未找到 .brain/ 图谱(无记忆可查)。先在项目根运行: abs init`;
1116
- }
1117
- const hits = [];
1118
- const dirs = ['concepts', 'entities', 'sources', 'syntheses', 'sessions'];
1119
- for (const d of dirs) {
1120
- const p = brainPath(root, d);
1121
- let files;
1122
- try {
1123
- files = await fs.readdir(p);
1124
- } catch {
1125
- continue;
1126
- }
1127
- for (const f of files) {
1128
- if (!f.endsWith('.md') || f.startsWith('_')) continue;
1129
- const full = join(p, f);
1130
- const body = await fs.readFile(full, 'utf8').catch(() => '');
1131
- const slug = f.replace(/\.md$/, '');
1132
- const rank = rankPage(body, slug, words);
1133
- if (rank) {
1134
- // 作者从本页 frontmatter 读(权威来源)。不在 index 行里重复 ——
1135
- // index 行是覆盖式更新的,作者会从"创建者"漂成"最后改的人"。
1136
- const au = body.match(/^author:\s*(.+)$/m);
1137
- const st = statusOfPage(body);
1138
- // 被推翻的经验默认不出现在检索结果里 —— 它的存在意义是"别再用它",
1139
- // 而不是回答"我上次怎么解決 X"(那会拿到一个已知错误的答案)。
1140
- // 但它不是静默消失:计数里告知还有几条被隐藏(带 --all 能看)。
1141
- if (st === 'superseded' && !includeSuperseded) {
1142
- hits.push({ hidden: true, slug });
1143
- continue;
1144
- }
1145
- hits.push({
1146
- full, slug, matched: rank.matched, kind: rank.kind, fuzzyScore: rank.score, via: rank.via,
1147
- author: au ? au[1].trim() : '',
1148
- id: idOfPage(body, slug),
1149
- status: st,
1150
- supersededBy: supersededByOf(body),
1151
- snippet: firstHitLine(body, rank.matched.length ? rank.matched : words),
1152
- });
1153
- }
1154
- }
1155
- }
1156
- const hidden = hits.filter((h) => h.hidden).length;
1157
- // 分两区:精确命中优先,模糊只作补充。
1158
- // 坑(2026-09-16 实测): 不加区分时查 file-write-locking 返回 26 页(几乎全图)——
1159
- // 连字符词的 2-gram 太通用,模糊命中把无关页也拉进来。
1160
- // 规则:只要有任何精确命中,模糊命中的就不排在前面(但也不丢弃,单列一段)。
1161
- const exactHits = hits.filter((h) => !h.hidden && h.kind === 'exact')
1162
- .map((h) => ({ ...h, score: h.fuzzyScore }))
1163
- .sort((a, b) => b.score - a.score);
1164
- const fuzzyHits = hits.filter((h) => !h.hidden && h.kind === 'fuzzy')
1165
- .map((h) => ({ ...h, score: h.fuzzyScore }))
1166
- .sort((a, b) => b.score - a.score);
1167
- // 有精确命中 → 模糊全藏起来(但不静默:告知数量 + 怎么看)
1168
- const fuzzySuppressed = exactHits.length > 0 && fuzzyHits.length > 0;
1169
- const shown = exactHits.length ? exactHits : fuzzyHits;
1170
- if (!shown.length) {
1171
- const extra = hidden ? `(另外 ${hidden} 页已标记 superseded,用 abs query ${words.join(' ')} --all 查看)` : '';
1172
- return `query [${words.join(', ')}]: 无命中。${extra}用 abs lint 看图谱健康;首次使用先 abs init。`;
1173
- }
1174
- const lines = shown.map((h) => {
1175
- const by = h.author ? ` @${h.author}` : '';
1176
- // id 只在≠slug 时显示 —— 相同时再印一遗就是纯噪音(绝大多数页)。
1177
- // 目的:让 AI 拿到一个改名也不漂的引用句柄(abs resolve <id> 能反查回来)。
1178
- const id = h.id && h.id !== h.slug ? ` [id: ${h.id}]` : '';
1179
- // draft = 未经核实。不拦使用,但必须让 AI 知道这是它自己没验证过的。
1180
- const st = h.status === 'draft' ? ' [draft 未核实]' : '';
1181
- const full = h.kind === 'exact' && words.length > 1 && h.matched.length === words.length ? ' ★全命中' : '';
1182
- // 模糊命中必须显式标出 —— 否则用户会把"语义相近"当成"真的是这条"。
1183
- const fuzzy = h.kind === 'fuzzy' ? ' [模糊命中: 字面未出现,仅字形相近]' : '';
1184
- const hitTxt = h.matched.length ? h.matched.join(', ') : '(无字面命中)';
1185
- // 命中渠道:tag 最有价值(人工提炼的关键词),显式标出便于判断可信度
1186
- const viaTag = h.via && h.via.tag.length ? ` [tag: ${h.via.tag.join(', ')}]` : '';
1187
- return `📄 ${h.slug}${by}${id}${st}${fuzzy} (命中: ${hitTxt}${full})${viaTag}\n ${h.snippet}`;
1188
- });
1189
- // 多词且无全命中时告知降级了 —— 不静默给一堆弱相关结果。
1190
- const anyFull = shown.some((h) => h.kind === 'exact' && h.matched.length === words.length);
1191
- const tail = [];
1192
- if (words.length > 1 && !anyFull) tail.push('', `(无页同时命中全部 ${words.length} 个词,以下按命中数排序)`);
1193
- if (fuzzySuppressed) tail.push('', `(另有 ${fuzzyHits.length} 页字形相近但字面未命中,已隐藏 —— 它们通常不相关)`);
1194
- if (hidden) tail.push(``, `(${hidden} 页 superseded 已隐藏;--all 可看)`);
1195
- return [`query [${words.join(', ')}] → ${shown.length} 页:`, '', ...lines, ...tail].join('\n');
1196
- }
1197
1181
 
1198
- /** 从页面正文取一段「像答案」的片段。
1199
- * 基线(2026-09-15 实测 296 条片段): 25% 是非内容行(tags:/H1/段落标题)。
1200
- * 典型症状: 查「发布」时 npm-publish-flow 返回 `tags: [concept, npm, publish, 发布]`
1201
- * —— frontmatter 在第 3 行,跑在正文前,于是「含关键词的第一行」永远先命中它。
1202
- * 所以必须:(1) 跳过 frontmatter/标题这类非内容行;(2) 优先从「答案段」里找。
1203
- * 只做机械判断:行首标记 + 所属小节标题,不猜语义。 */
1204
- const ANSWER_SECTION_RE = /解法|根因|修复|验证|流程|标准|判据|处置|怎么办|🛠/;
1205
-
1206
- function firstHitLine(body, words) {
1207
- const lines = body.split('\n');
1208
- // 逐行扫描,记录当前所属小节标题,供「答案段优先」用。
1209
- let section = '';
1210
- const candidates = []; // {line, inAnswer}
1211
- for (const line of lines) {
1212
- const t = line.trim();
1213
- if (t.startsWith('#')) {
1214
- section = t.replace(/^#+\s*/, '');
1215
- continue; // 标题本身不是内容
1216
- }
1217
- if (!t || t === '---') continue;
1218
- const l = t.toLowerCase();
1219
- if (!words.some((w) => l.includes(w.toLowerCase()))) continue;
1220
- if (KNOWN_SLUG_HINT.test(t)) continue;
1221
- // 非内容行:frontmatter 的键值对(tags:/id:/status:/updated:/author:/superseded-by:)
1222
- if (/^(tags|id|status|updated|author|superseded-by|superseded|aliases)\s*:/.test(t)) continue;
1223
- candidates.push({ line: t, inAnswer: ANSWER_SECTION_RE.test(section) });
1224
- }
1225
- if (!candidates.length) return '';
1226
- // 答案段里的行优先;否则回退到第一条命中的正文行
1227
- const best = candidates.find((c) => c.inAnswer) || candidates[0];
1228
- return clip(best.line, 160);
1229
- }
1182
+ // Re-export query.js symbols so external importers still work.
1183
+ export { cmdQuery } from './query.js';
1230
1184
 
1231
1185
  // ---------- note: 经验实时暂存(source 页,一念一落,防流失) ----------
1232
1186
  const NOTE_DEDUP_MS = 60 * 1000;
1233
1187
 
1234
- export async function cmdNote({ dir, text, tags, when }) {
1188
+ export async function cmdNote({ dir, text, tags, when, impact, type }) {
1235
1189
  const clean = String(text || '').trim();
1236
1190
  if (!clean) return '用法: abs note "经验/坑/技巧一句话" [--when "何时该读它"](落 sources/ 暂存页,实时不流失)';
1237
1191
  let root;
@@ -1252,6 +1206,15 @@ export async function cmdNote({ dir, text, tags, when }) {
1252
1206
  return `• 60s 内已落同文本 → ${f} (跳过重复)`;
1253
1207
  }
1254
1208
  }
1209
+ // 影响面(可选):显式传 --impact <符号> 时,借本机 CodeGraph 拿「改它波及谁」。
1210
+ // 失败/未装 codegraph 静默降级为无,绝不阻断 note 落盘。
1211
+ const impactText = impact ? await impactOf(impact, root) : null;
1212
+ // 类型(可选):借鉴 TencentDB 的 L1 四分类,把自由文本经验分成可分类的资产。
1213
+ // 默认不强制(自由文本仍是主体);显式 --type 时才写进 frontmatter,供检索/load 区分。
1214
+ // 合法值对齐 L1 四类:fact 事实 / pref 偏好 / constraint 约束 / event 事件。
1215
+ const NOTE_TYPES = ['fact', 'pref', 'constraint', 'event'];
1216
+ const noteType = NOTE_TYPES.includes(String(type || '').trim().toLowerCase())
1217
+ ? String(type).trim().toLowerCase() : '';
1255
1218
  const tagList = String(tags || '').split(',').map((t) => t.trim()).filter(Boolean);
1256
1219
  const fmTags = ['source', ...tagList].join(', ');
1257
1220
  const slugSrc = slugOf(clean);
@@ -1267,12 +1230,14 @@ export async function cmdNote({ dir, text, tags, when }) {
1267
1230
  `author: ${who}`,
1268
1231
  `updated: ${today()}`,
1269
1232
  'status: draft',
1233
+ ...(noteType ? [`type: ${noteType}`] : []),
1270
1234
  '---',
1271
1235
  '',
1272
1236
  `# 来源:${heading}`,
1273
1237
  '',
1274
1238
  `TITLE: ${clean}`,
1275
1239
  ...(whenText ? ['', `WHEN: ${whenText}`] : []),
1240
+ ...(impactText ? ['', '## 影响面(本机 CodeGraph 自动带出)', '```', impactText, '```'] : []),
1276
1241
  '',
1277
1242
  `## 记录(实时暂存,Teardown 时提炼进 concepts/ 后本页可删)`,
1278
1243
  `- ${clean}`,
@@ -1437,364 +1402,8 @@ export async function registerInIndex(root, section, slug, desc) {
1437
1402
  const next = after === -1
1438
1403
  ? `${index.replace(/\s*$/, '')}\n${line}\n`
1439
1404
  : index.slice(0, after) + `\n${line}` + index.slice(after);
1440
- return { text: next };
1405
+ // 归一空行:历史手工编辑会留 3+ 空行(load 时 collapseIndex 会压掉,但文件本身没清)。
1406
+ // 追加新条目的同时顺手压一次,既清旧债又不改内容(与 collapseIndex 同一判据)。
1407
+ return { text: next.replace(/\n{3,}/g, '\n\n') };
1441
1408
  });
1442
1409
  }
1443
-
1444
- // ---------- lint: 体检(与 scripts/lint.sh 同规则的 Node 版,供 CLI/MCP 直调) ----------
1445
- export async function cmdLint({ dir }) {
1446
- let root;
1447
- try {
1448
- root = await requireBrain(dir || process.cwd());
1449
- } catch {
1450
- return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
1451
- }
1452
- const vault = brainPath(root);
1453
- const pages = await listPages(vault);
1454
- const names = new Set(pages.map((p) => p.slug));
1455
- // `.brain` 顶层文件(index / log / todo)也是真实页:从图谱看 [[todo]] 就是 todo.md。
1456
- // 坑: 以前只把子目录当页 → [[todo]] 被当成死链(误报),反而逼用户去删掉正确引用。
1457
- // 只用于「链接目标是否存在」判定;不参与 ORPHAN / INDEX-MISSING(它们只针对子目录页)。
1458
- for (const f of await fs.readdir(vault).catch(() => [])) {
1459
- if (f.endsWith('.md')) names.add(f.replace(/\.md$/, ''));
1460
- }
1461
- const linkedNames = new Set(pages.flatMap((p) => p.links));
1462
- // 入度统计(不含 index.md):图上"有人引用它"才算被接上。
1463
- // index 是入口清单(每页都会被登记),算进去就永远不会有 NO-INBOUND —— 失去意义。
1464
- const inbound = new Map();
1465
- for (const p of pages) for (const ln of p.links) inbound.set(ln, (inbound.get(ln) || 0) + 1);
1466
- // index.md 里列的 [[x]] —— 用于反向查死引用(列了但页不存在)
1467
- let indexLinks = [];
1468
- try {
1469
- const idx = await fs.readFile(join(vault, 'index.md'), 'utf8');
1470
- indexLinks = [...idx.matchAll(/\[\[([^\]|#]+)/g)].map((m) => m[1].trim());
1471
- } catch { /* 无 index 则不查 */ }
1472
- const issues = [];
1473
-
1474
- for (const pg of pages) {
1475
- if (!pg.hasFrontmatter) issues.push(`NO-FRONTMATTER: ${pg.rel}`);
1476
- // ID-DRIFT: 页面写了 id 但已跟文件名(slug)不一致 = 改过名或改过 id。
1477
- // 不是错误(id 就是用来固定身份的),但必须提示:[[slug]] 形式的引用指向的是**文件名**,
1478
- // 改名后旧引用全变 DEAD-LINK;本检查让「静默断链」变成「一条可执行的提示」。
1479
- // 优先看「有没有别的页 id 指向旧名」→ 那才是真正的改名现场。
1480
- if (pg.hasFrontmatter) {
1481
- const id = idOfPage(pg.body, pg.slug);
1482
- // 反向查:有别的页声明 id = 本页 slug,说明本页是从那个 id 改名过来的。
1483
- // 这种才是「改名没同步引用」的真信号;单纯 id≠slug 也可能只是手写的 id。
1484
- if (id !== pg.slug) {
1485
- issues.push(`ID-DRIFT: ${pg.rel} (frontmatter id=${id} ≠ 文件名 ${pg.slug};` +
1486
- `引用请用 [[${id}]] 或改回文件名)`);
1487
- }
1488
- }
1489
- for (const ln of pg.links) {
1490
- // 两类都不是真链接,只报 TEMPLATE-LINK(且不短路就会再报一次 DEAD-LINK,同一条报两遍):
1491
- // ① 模板占位: `[[页面名]]` / `[[slug]]` / `[[Name]]` —— 模板没填
1492
- // ② 描述语法时引用的字面量: `[[<slug>]]` —— 尖括号不是合法 wikilink 字符,
1493
- // 而是任务描述在解释格式(如 “在 ### 归档 段留下 [[<slug>]] 完成任务 N 条”)。
1494
- // 单靠关键字(slug/name)分辨不了两者,故额外认尖括号形态。
1495
- if (/^<.+>$/.test(ln) || /slug|Name|name|Date|页面名$/.test(ln)) {
1496
- issues.push(`TEMPLATE-LINK: ${pg.rel} -> [[${ln}]]`);
1497
- } else if (!names.has(ln)) {
1498
- issues.push(`DEAD-LINK: ${pg.rel} -> [[${ln}]]`);
1499
- }
1500
- }
1501
- // ORPHAN: sources/ 暂存页与会话/归档页豁免。前者是暂存线索(提炼成 concept 前天然孤立),
1502
- // 后者是历史记录(已登记在 index.md,就是图谱入口,无需再制造双链)。
1503
- // 豁免名单含两种归档命名:旧 `*-todo归档`(存量页仍在)+ 新 `log-*`
1504
- //(2026-09-15 起归档并入当天快照,见 todo.js 的 upsertArchiveSection)。
1505
- const isTerminal = pg.dir === 'sources'
1506
- || /todo归档$/.test(pg.slug)
1507
- || (pg.dir === 'sessions' && /^log-/.test(pg.slug));
1508
- if (!isTerminal && !pg.links.length && !linkedNames.has(pg.slug)) {
1509
- issues.push(`ORPHAN-PAGE: ${pg.rel} (no links out, no links in)`);
1510
- }
1511
- // NO-INBOUND: 有出边但无人指向 = 挂在图上没人接。ORPHAN-PAGE 只抓"零出零入",
1512
- // 抓不到"连了 5 条出去却没人连它"的悬挂页(实测 concepts/file-shape-check-on-load 即是)。
1513
- // 只查知识页(concepts/entities/syntheses)——sources/sessions 的孤立是设计使然。
1514
- if (['concepts', 'entities', 'syntheses'].includes(pg.dir) && !(inbound.get(pg.slug) || 0)) {
1515
- issues.push(`NO-INBOUND: ${pg.rel} (无人链接到本页;在相关页的 ## 关联连接 挂一条 [[${pg.slug}]])`);
1516
- }
1517
- // UNRESOLVED-CONFLICT: 有「## 知识冲突」段但还是 draft = 冲突标了没裁决。
1518
- // 判据必须认【段标题】而非页内出现「知识冲突」字样。
1519
- // 坑(2026-09-15 实测): 原用裸子串 → codebuddy 的会话快照因任务描述里写了
1520
- // 「更新 session-key-fingerprint-flaw(知识冲突裁决)」而误报(它并无该段)。
1521
- // 同 NO-TAIL 的教训: 判据看结构,不看关键词。
1522
- if (/^#{2,6}[^\n]*知识冲突/m.test(pg.body) && /status: draft/.test(pg.frontmatter)) {
1523
- issues.push(`UNRESOLVED-CONFLICT: ${pg.rel}`);
1524
- }
1525
- if (['concepts', 'entities', 'syntheses'].includes(pg.dir)) {
1526
- if (pg.lines > PAGE_MAX_LINES || pg.bytes > PAGE_MAX_BYTES) {
1527
- issues.push(`OVER-SIZE: ${pg.rel} (${pg.lines}L/${pg.bytes}B > ${PAGE_MAX_LINES}L/${PAGE_MAX_BYTES / 1024}KB; 拆或外链)`);
1528
- }
1529
- }
1530
- // NO-TAIL: concept 页只有「头」(触发场景/表现)没有「尾」(可执行的东西)= 只能信,不能验。
1531
- // 尾巴的本质不是「叫验证」,而是【给出可执行的东西】:跑什么 / 怎么查 / 按什么步骤 / 用什么判据。
1532
- //
1533
- // 判据为何要宽(实测):
1534
- // 26 页的段名高度分散 —— `## ✅ 处置` 出现 17 次,比 `## 🛠 解法` 还多;
1535
- // 还有 `## 做法`(11) / `## 判据` / `## ✅ 正确顺序` / `## 测试要点` / `## 分析步骤`。
1536
- // 只认「验证」二字会误报 7/11(64%)→ 噪音 → 规则被忽略。
1537
- // 判据为何要看「段内有没有真内容」:
1538
- // `abs concept` 生成的骨架自带 `## 验证` 占位;若只看标题,骨架刚建就被判有尾(假阴性)。
1539
- // 所以必须排除「只有 <!-- 占位 --> 的空段」。
1540
- // 实测此版: 误报 0 / 漏报 0(报出的 2 页确实都没有「做完怎么确认」)。
1541
- if (pg.dir === 'concepts' && !hasTail(pg.body)) {
1542
- issues.push(`NO-TAIL: ${pg.rel}(无「做完怎么确认」;尾巴写清跑什么/看什么/按什么判据,别只有头)`);
1543
- }
1544
- if (pg.dir !== 'sources' && !pg.indexed) {
1545
- issues.push(`INDEX-MISSING: ${pg.rel} not listed as [[${pg.slug}]] in index.md`);
1546
- }
1547
- }
1548
-
1549
- // index.md 反向检查: 列了 [[x]] 但 x 页不存在 —— 删页/归档 source 后忘了清 index 的残留。
1550
- // (page→index 的 INDEX-MISSING 已有, 这里补 index→page, 否则死引用静默留在入口文件里)
1551
- for (const ln of indexLinks) {
1552
- if (/slug|Name|name|Date|页面名$/.test(ln)) continue; // 模板占位行
1553
- if (!names.has(ln)) issues.push(`INDEX-DEAD-LINK: index.md -> [[${ln}]] (该页不存在, 删页后忘清 index?)`);
1554
- }
1555
-
1556
- const nsrc = pages.filter((p) => p.dir === 'sources').length;
1557
- if (nsrc > SOURCES_MAX) issues.push(`SOURCES-PILED-UP: sources/ has ${nsrc} files > ${SOURCES_MAX}; 提炼归档旧 source`);
1558
-
1559
- // SOURCE-UNDISTILLED: source 页超过 SOURCE_STALE_DAYS 天仍没链到任何 concept 页 = 暂存了没归位。
1560
- // 只数总量(SOURCES-PILED-UP)抓不到"4 个 source 里 3 个没提炼"——实测本仓即如此。
1561
- // 判据机械可判:出边里有没有 concepts/ 的页 + mtime 超龄,不猜语义。
1562
- const conceptSlugs = new Set(pages.filter((p) => p.dir === 'concepts').map((p) => p.slug));
1563
- const staleMs = SOURCE_STALE_DAYS * 86400 * 1000;
1564
- for (const pg of pages) {
1565
- if (pg.dir !== 'sources') continue;
1566
- if (pg.links.some((ln) => conceptSlugs.has(ln))) continue;
1567
- let ageMs = 0;
1568
- try { ageMs = Date.now() - (await fs.stat(join(root, pg.rel))).mtimeMs; } catch { continue; }
1569
- if (ageMs > staleMs) {
1570
- issues.push(`SOURCE-UNDISTILLED: ${pg.rel}(${SOURCE_STALE_DAYS} 天未提炼成 concept;提炼后删 source 并清引用)`);
1571
- }
1572
- }
1573
-
1574
- // SESSIONS-NAMING: sessions/ 的一天一文件契约(见 skill 的「sessions/ 命名契约」)。
1575
- // 实测 codebuddy 乱局(2026-09-15):一天最多出现 5 个文件、5 种 tags、同天两个快照。
1576
- // 判据纯机械:按“日期前缀”归组——同天 >1 个文件报 SPLIT;单个但非规范名报 NAMING。
1577
- // 只看文件名,不猜内容。
1578
- const sessByDate = new Map();
1579
- for (const pg of pages) {
1580
- if (pg.dir !== 'sessions') continue;
1581
- // 同时认两种写法:规范名 `log-<日期>` 与旧/杂命名 `<日期>-…`。
1582
- // 坑(写测试时抓到的): 首版只写 `^(\d{4}-…)` → **匹配不上规范名 `log-2026-09-07`**,
1583
- // 于是「一个 log- + 一个旧杂文件」被数成 1 个而非 2 个,漏报。
1584
- const m = pg.slug.match(/^(?:log-)?(\d{4}-\d{2}-\d{2})/);
1585
- if (!m) continue;
1586
- // 长期存续的归档页豁免:**只认独立的 `archive` 标签**(如 `tags: [session-log, archive]`),
1587
- // 不认 `todo-archive`(那是旧归档页的标签,它正是要迁移的对象)。
1588
- // 坑(2026-09-15 在 ~/Docker 实测抓到): 首版用 `\barchive\b` —— 而 `todo-archive`
1589
- // 里 `-` 与 `a` 之间也是词边界 → **老式归档页全被豁免**,一个都不报(漏报四天)。
1590
- // 豁免是为「外部产物全文归档」(如仓库 todo.md 全文)设的,不是为旧命名归档页。
1591
- const tags = String(pg.frontmatter).match(/^tags:\s*(.+)$/m)?.[1] || '';
1592
- const tagList = tags.replace(/^\[|\]$/g, '').split(',').map((t) => t.trim());
1593
- if (tagList.includes('archive')) continue;
1594
- if (!sessByDate.has(m[1])) sessByDate.set(m[1], []);
1595
- sessByDate.get(m[1]).push(pg.slug);
1596
- }
1597
- for (const [date, slugs] of sessByDate) {
1598
- if (slugs.length > 1) {
1599
- issues.push(`SESSIONS-SPLIT: ${date} 在 sessions/ 有 ${slugs.length} 个文件(${slugs.join('、')});` +
1600
- `一天只应有一个 \`log-${date}.md\`:归档写进其「## 📦 任务归档」段,多主题写成多个 ## 子段`);
1601
- } else if (!slugs[0].startsWith('log-')) {
1602
- // 单个文件但**不是规范名** —— 旧命名(`<日期>-todo归档.md` 等)单独存留。
1603
- // 坑(2026-09-15 在 ~/Docker 实测抓到): 首版只看“同天 >1 个” → 4 个日期
1604
- // 各只有一份 `<日期>-todo归档.md` → 一个都不报(漏报)。
1605
- // 旧命名的页无论是否孤单都该改:跑 `abs todo archive` 后并入 `log-<日期>.md`。
1606
- issues.push(`SESSIONS-NAMING: ${date} 的文件 \`${slugs[0]}.md\` 不是规范名;` +
1607
- `应为 \`log-${date}.md\`(跑 abs todo archive 会并入;旧归档页可删)`);
1608
- }
1609
- }
1610
- // SESSIONS-MISPLACED: sessions/ 里放了 tags 既非 session-log / todo-archive / archive 的页。
1611
- // 实例:codebuddy 的 `2026-09-07-ui-fixes.md`(tags: [source,session-log])等 5 页 ——
1612
- // 当时做的一组工作不是「暂存线索」,而就是当天的快照正文。
1613
- for (const pg of pages) {
1614
- if (pg.dir !== 'sessions') continue;
1615
- const tags = String(pg.frontmatter).match(/^tags:\s*(.+)$/m)?.[1] || '';
1616
- if (!/session-log|todo-archive|archive/.test(tags)) {
1617
- issues.push(`SESSIONS-MISPLACED: ${pg.rel}(tags: ${tags.trim()} 不属 sessions/;` +
1618
- `当天工作写进 \`log-<日期>.md\` 正文,暂存线索用 \`abs note\` 落 sources/)`);
1619
- }
1620
- }
1621
-
1622
- // SUPERSEDED-DANGLING: superseded 页声明的取代者也必须存在。
1623
- // 它跟 DEAD-LINK 同性质(指向不存在的页),但后果更重:
1624
- // 读者被引导去找一个不存在的"新版本",比单纯断链更容易让人以为"没新页就是没替代"。
1625
- for (const pg of pages) {
1626
- if (pg.status !== 'superseded') continue;
1627
- if (!pg.supersededBy) continue; // 无取代者也是合法状态(就是弃用,没替代)
1628
- const by = pg.supersededBy.replace(/^\[\[|\]\]$/g, '').trim();
1629
- if (!names.has(by)) {
1630
- issues.push(`SUPERSEDED-DANGLING: ${pg.rel} (superseded-by: ${by} —— 该页不存在,删页后未同步)`);
1631
- }
1632
- }
1633
-
1634
- // DRAFT-STALE: draft 停太久 = 既没核实也没被推翻,实质是写了没人看的堆积。
1635
- // 不报 sources(它们由 SOURCE-UNDISTILLED 管),只报 concepts/entities/syntheses ——
1636
- // 那些页是"应该已经被确认过"的长期资产,长期 draft 说明核实环节缺位。
1637
- for (const pg of pages) {
1638
- if (pg.status !== 'draft') continue;
1639
- if (pg.dir === 'sources') continue;
1640
- let ageMs = 0;
1641
- try { ageMs = Date.now() - (await fs.stat(join(root, pg.rel))).mtimeMs; } catch { continue; }
1642
- if (ageMs > DRAFT_STALE_DAYS * 86400 * 1000) {
1643
- const days = Math.floor(ageMs / 86400000);
1644
- issues.push(`DRAFT-STALE: ${pg.rel}(已 ${days} 天停在 draft;核实后改 status: active,推翻则 abs supersede)`);
1645
- }
1646
- }
1647
-
1648
- // Rules 区:它的价值在“少而重”,且不被折叠(load 每次都全量读)。
1649
- // 无上限增长 = 把 load 又撑回去(同 Done / index 清单的膨胀根因)。
1650
- {
1651
- const idxTxt = await readFileOrNull(join(vault, 'index.md'));
1652
- const { items, found } = readRules(idxTxt);
1653
- if (found && items.length > RULES_MAX) {
1654
- issues.push(`RULES-PILED-UP: Rules 区 ${items.length} 条 > ${RULES_MAX};把长条目提炼成概念页,这里只留一句话`);
1655
- }
1656
- // 该区是 load 必读的硬规则清单,条目却写得像段落 → 提醒改短句。
1657
- const longOnes = items.filter((l) => l.trim().length > 160);
1658
- if (longOnes.length) {
1659
- issues.push(`RULES-TOO-LONG: Rules 区 ${longOnes.length} 条超 160 字符(如 "${clip(longOnes[0].trim(), 40)}");展开写进概念页,这里只留一句话(不带链接)`);
1660
- }
1661
- }
1662
-
1663
- // Done 区堆积:它无上限增长,且 `abs todo`/`abs load` 每次全量打印 → 越积越难用。
1664
- // (与 hooks.log/wrapup.log 同类问题;那两处有轮转,这里靠 `abs todo archive`。)
1665
- // 坑: 曾经写成 brainPath(vault, 'todo.md'),而 vault 已经是 .brain 目录
1666
- // → 拼出 .brain/.brain/todo.md(ENOENT),又被外层 try/catch 吞掉
1667
- // → 检查静默失效(lint 永远 0 problem)。故这里不用 try/catch 吞错,
1668
- // 只对 ENOENT 做缺省,写错路径这类编程错会直接暴露。
1669
- const todoTxt = await fs.readFile(join(vault, 'todo.md'), 'utf8').catch(() => '');
1670
- const di = todoTxt.split('\n').findIndex((l) => l.startsWith('## Done'));
1671
- if (di !== -1) {
1672
- const doneLines = todoTxt.split('\n').slice(di + 1).filter((l) => l.trim()).length;
1673
- const DONE_MAX = 60;
1674
- if (doneLines > DONE_MAX) {
1675
- issues.push(`DONE-PILED-UP: Done 区 ${doneLines} 行 > ${DONE_MAX}; 跑 \`abs todo archive\` 迁出旧日期组`);
1676
- }
1677
-
1678
- // 结语契约:Done 的 [x] 必须带【落地/否决/仅方案】。
1679
- // 为什么钉死: 无结语的 [x] 同时意味着"真做完了"和"只想过",读的人无法区分。
1680
- // 实测翻车: 把"跑通后又被撤销"的 daemon 条当成已落地 → 得出错误结论。
1681
- // 只查 Done 区(含历史归档前的旧条目也算),不做自动改写(改记录属内容决策,不该由 lint 代劳)。
1682
- const doneBody = todoTxt.split('\n').slice(di + 1);
1683
- const noKind = doneBody.filter((l) => /^\s*- \[x\]/.test(l) && !doneKindOf(l));
1684
- if (noKind.length) {
1685
- const sample = (noKind[0].match(/- \[x\] (\S+)/) || [, '?'])[1];
1686
- issues.push(
1687
- `DONE-NO-KIND: Done 区 ${noKind.length} 条缺结语(如 ${sample})。` +
1688
- `逐条补 \`--as 落地|否决|仅方案\`(新条目:abs todo done <id> --as …)`,
1689
- );
1690
- }
1691
- // 完成日期同理必须由工具盖:手写 [x] 时人会抄语义部分(结语)而漏掉机械部分(日期)。
1692
- // 后果不只是排版不齐 —— 无日期行归入 `### (未标日期)` 尾组,而归档靠日期判天数,
1693
- // 故这些行**永远无法被 abs todo archive 迁出**(todo.js:300 保守跳过)。
1694
- // 即:漏一个日期 = 一条永久钉住 Done 区、拖大 load 输出的行。
1695
- // 两处在同一处校验(同一份契约的两半),别只查一半给假信心。
1696
- const noDate = doneBody.filter((l) => /^\s*- \[x\]/.test(l) && !doneDateOf(l));
1697
- if (noDate.length) {
1698
- const sample = (noDate[0].match(/- \[x\] (\S+)/) || [, '?'])[1];
1699
- issues.push(
1700
- `DONE-NO-DATE: Done 区 ${noDate.length} 条缺 \`(完成 YYYY-MM-DD)\`(如 ${sample})。` +
1701
- `手写的 [x] 不会自动盖日期 —— 补上后重跑;新条目一律走 \`abs todo done <id>\`。`,
1702
- );
1703
- }
1704
- }
1705
-
1706
- const n = issues.length;
1707
- return [
1708
- ...(issues.length ? issues : []),
1709
- '',
1710
- `lint: ${n} problem(s).`,
1711
- n === 0 ? '✓ 图谱健康' : '',
1712
- ].filter(Boolean).join('\n');
1713
- }
1714
-
1715
- const PAGE_DIRS = ['entities', 'concepts', 'sources', 'syntheses', 'sessions'];
1716
-
1717
- // concept/entity/synthesis 页的容量上限,超出提示"拆或外链"。
1718
- // 曾为 150L/5120B —— 实测偏紧:跨 4 项目 68 页里仅 2 页超限,且都只超一点
1719
- // (5463B / 5440B);为满足它还把一页从 5319B 压到 4972B(内容受损、收益为零)。
1720
- // 放宽到 8KB:当前最大页 5463B,留约 50% 余量,但不至于失去"该拆了"的信号。
1721
- // 提成常量避免检查条件与提示文本各写一份而漂移。
1722
- const PAGE_MAX_LINES = 150;
1723
-
1724
- /** 尾巴关键词:只要段名里带这些「动作词」,就认为作者在给「做完怎么确认」。
1725
- * 为何不限定叫「验证」:实测 26 页段名高度分散(`## ✅ 处置` 17 次 > `## 🛠 解法`),
1726
- * 只认「验证」会误报 7/11(64%)—— 噪音会让规则失去意义。 */
1727
- const TAIL_WORDS = '验证|检查|清单|测试|处置|做法|步骤|顺序|判据|信号|怎么';
1728
-
1729
- /** concept 页是否有「尾」(可执行的东西)。
1730
- * 两种真实形态都算:
1731
- * 1. 有带动作词的段标题,且**段内有真内容** —— 排除 `abs concept` 骨架的
1732
- * `## 验证` + `<!-- 占位 -->`(只看标题会把未填的骨架误判为有尾)。
1733
- * 2. 列表项形式,如 `3. 验证命令:\`cmd\``(hook-sh-not-bash / todo-rewrite-not-map 的写法)。
1734
- * 逐行扫描而非复杂正则:需要「段边界」与「占位识别」,正则会难读且难改。 */
1735
- export function hasTail(body) {
1736
- const lines = String(body || '').split('\n');
1737
- const headRe = new RegExp(`^#{2,6}[^\\n]*(${TAIL_WORDS})`);
1738
- for (let i = 0; i < lines.length; i++) {
1739
- if (!headRe.test(lines[i])) continue;
1740
- const lvl = lines[i].match(/^#+/)[0].length;
1741
- for (let j = i + 1; j < lines.length; j++) {
1742
- const t = lines[j].trim();
1743
- const h = lines[j].match(/^(#+)\s/);
1744
- if (h && h[1].length <= lvl) break; // 本段结束,换下一段找
1745
- if (!t) continue;
1746
- if (t.startsWith('<!--') || t === '-->') continue; // 占位注释不算内容
1747
- return true;
1748
- }
1749
- }
1750
- return new RegExp(`^\\s*(?:\\d+\\.|[-*])\\s*\\**[^\\n]{0,20}(${TAIL_WORDS})`, 'm').test(String(body || ''));
1751
- }
1752
- const PAGE_MAX_BYTES = 8 * 1024;
1753
-
1754
- // source 页超龄未提炼的天数阈值(SOURCE-UNDISTILLED)。
1755
- // 7 天 = 跨过至少一个完整工作周还没人提炼,基本等于被遗忘。
1756
- const SOURCE_STALE_DAYS = 7;
1757
- // draft 超龄阈值:新落的经验(abs note)默认 draft,指的是"还没核实过"。
1758
- // 长期停在 draft = 既没被核实也没被推翻,属"写了没人看"的堆积 —— 不自动改,只报。
1759
- const DRAFT_STALE_DAYS = 14;
1760
-
1761
- async function listPages(vault) {
1762
- let indexText = '';
1763
- try {
1764
- indexText = await fs.readFile(join(vault, 'index.md'), 'utf8');
1765
- } catch { /* no index yet */ }
1766
- const pages = [];
1767
- for (const d of PAGE_DIRS) {
1768
- const dp = join(vault, d);
1769
- let files;
1770
- try {
1771
- files = await fs.readdir(dp);
1772
- } catch {
1773
- continue;
1774
- }
1775
- for (const f of files) {
1776
- if (!f.endsWith('.md') || f.startsWith('_')) continue;
1777
- const full = join(dp, f);
1778
- const body = await fs.readFile(full, 'utf8').catch(() => '');
1779
- const fm = body.match(/^---\n([\s\S]*?)\n---/);
1780
- const links = [...new Set([...body.matchAll(/\[\[([^\]]+)\]\]/g)].map((m) => m[1].split('|')[0]))];
1781
- pages.push({
1782
- dir: d,
1783
- // 带上 .brain/ 前缀:这串会原样出现在 lint 提示里,用户会拿它去找文件。
1784
- // 坑: 曾经只给 vault 相对路径(concepts/x.md),用户到项目根找 concepts/ 找不到。
1785
- rel: `${BRAIN_DIR}/${d}/${f}`,
1786
- slug: f.replace(/\.md$/, ''),
1787
- body,
1788
- frontmatter: fm ? fm[1] : '',
1789
- hasFrontmatter: body.startsWith('---\n'),
1790
- links,
1791
- lines: body.split('\n').length,
1792
- bytes: Buffer.byteLength(body, 'utf8'),
1793
- indexed: indexText.includes(`[[${f.replace(/\.md$/, '')}]]`),
1794
- status: statusOfPage(body, fm ? fm[1] : ''),
1795
- supersededBy: supersededByOf(body),
1796
- });
1797
- }
1798
- }
1799
- return pages;
1800
- }