@fanchao8609/agent_brain_sync 1.8.3 → 1.8.5

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, cmdShow, cmdRepair, cmdWrapup, cmdRule, cmdTeardownCheck, cmdTodoArchive, cmdResolve, cmdSupersede } from '../src/store.js';
5
+ import { cmdInit, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdConcept, cmdShow, cmdRepair, cmdWrapup, cmdRule, cmdTeardownCheck, cmdTodoArchive, cmdResolve, cmdSupersede } 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';
@@ -73,6 +73,10 @@ const FLAG_SPEC = {
73
73
  'as': { type: 'string' },
74
74
  'payload': { type: 'string' },
75
75
  'tags': { type: 'string' },
76
+ // concept 骨架用: 不在 FLAG_SPEC 里的 `--title` 会被静默当布尔 true(见 parseArgv 注释),
77
+ // 于是 `--title "一句话"` 的正文会进位置参数 → 必须在这声明。
78
+ 'title': { type: 'string' },
79
+ 'desc': { type: 'string' },
76
80
  'state': { type: 'string' },
77
81
  'by': { type: 'string' },
78
82
  'all': { type: 'boolean' },
@@ -150,6 +154,8 @@ function parseArgv(args) {
150
154
  all: !!values.all,
151
155
  payload: values.payload,
152
156
  tags: values.tags,
157
+ title: values.title,
158
+ desc: values.desc,
153
159
  help: !!values.help,
154
160
  yes: !!values.yes,
155
161
  repair: !!values.repair,
@@ -185,6 +191,8 @@ const usage = `abs — agent-brain-sync 记忆工具
185
191
  不加结语或结语失真会让下一个会话把"想过"当成"做完了"。
186
192
  abs log "完成 X:…" 记一行工作成果 (无参=查看)
187
193
  abs note "经验一句话" [--tags 坑,docker] 经验实时暂存 → sources/
194
+ abs concept <slug> --title "标题" [--tags a,b] [--desc "index 描述"]
195
+ 建概念页骨架(头/中/尾四段位置)。只给结构不给内容
188
196
 
189
197
  维护:
190
198
  abs query <词1> [词2 …] [--all]
@@ -381,6 +389,17 @@ async function main() {
381
389
  console.log(await cmdNote({ dir: opts.dir, text: opts._.join(' '), tags: opts.tags }));
382
390
  break;
383
391
  }
392
+ case 'concept': {
393
+ // 建概念页骨架(只给结构,不给内容 —— 判断不自动化)。
394
+ console.log(await cmdConcept({
395
+ dir: opts.dir,
396
+ slug: opts._.join('-'),
397
+ title: opts.title,
398
+ tags: opts.tags,
399
+ desc: opts.desc,
400
+ }));
401
+ break;
402
+ }
384
403
  case 'install':
385
404
  case 'uninstall': {
386
405
  // 子命令级 --help: 打印用法后直接返回。
package/bin/mcp.js CHANGED
@@ -12,7 +12,7 @@ import { readFileSync } from 'node:fs';
12
12
  import { join, dirname } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { findBrainRoot, absLogDir } from '../src/index.js';
15
- import { cmdBoard, cmdLoad, cmdStatus, cmdTask, cmdQuery, cmdLint, cmdNote, cmdWrapup, cmdRule, cmdResolve, cmdSupersede } from '../src/store.js';
15
+ import { cmdBoard, cmdLoad, cmdStatus, cmdTask, cmdQuery, cmdLint, cmdNote, cmdConcept, cmdWrapup, cmdRule, cmdResolve, cmdSupersede } from '../src/store.js';
16
16
 
17
17
  // ---------- 技术日志: MCP 请求跟踪(调试用, 与图谱 log.md 完全分开) ----------
18
18
  // 落 ~/.abs/log/mcp.log: 每次工具调用一行 [时间] tool cwd 参数摘要 → 耗时/结果摘要。
@@ -245,6 +245,23 @@ tool(
245
245
  }
246
246
  );
247
247
 
248
+ tool(
249
+ 'abs_concept',
250
+ '建概念页骨架(给写入定结构:触发场景/表现/解法/验证)。只给结构不给内容 —— 值不值得留、归哪页仍靠人判断。',
251
+ {
252
+ cwd: z.string().describe('项目根目录(.brain/ 所在处)'),
253
+ slug: z.string().min(1).describe('文件名/slug,如 "docker-prisma-429"(命名即链接)'),
254
+ title: z.string().optional().describe('页面标题(省略则用 slug)'),
255
+ tags: z.string().optional().describe('逗号分隔标签,如 "docker,坑"'),
256
+ desc: z.string().optional().describe('index.md 里那一行的一句话描述'),
257
+ },
258
+ async ({ cwd, slug, title, tags, desc }) => {
259
+ const root = await findBrainRoot(cwd || process.cwd());
260
+ if (!root) return errNoBrain(cwd || process.cwd());
261
+ return { content: [{ type: 'text', text: await cmdConcept({ dir: root, slug, title, tags, desc }) }] };
262
+ }
263
+ );
264
+
248
265
  tool(
249
266
  'abs_wrapup',
250
267
  '收尾保险:把当前 todo 未完成任务快照到 ~/.abs/log/wrapup.log(下会话 load 时展示滞留对账)。',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.8.3",
3
+ "version": "1.8.5",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -232,13 +232,21 @@ Done 区由 `abs wrapup` 在会话结束时自动把「超 3 天且整天都已
232
232
  `abs todo note <id> --note 断点`;卡住的 `abs todo state <id> --note 滞留中`。别让干完的事还留在 Todo。
233
233
  3. **沉淀经验(该沉淀才沉淀)** → 踩了值得记的坑/有可复用技巧/跨会话判断 → `abs note`
234
234
  暂存;值得深提炼的(规律/坑/决策)按 Teardown 走完整流程。
235
- 4. **更新 index/log/todo** → 新页同步进 index;`log.md` 倒序记一行**工作成果**摘要
235
+ 4. **判本次教训够不够格进 Rules(别跳过)** → 过一遍:这条是否**「违反会丢数据/静默失效/白干活」**级?
236
+ - **够格 → 提议,不直写**:输出一行 `[Rules 提议] <一句话>` 并问用户要不要加,得到确认才
237
+ `abs rule add "<一句话>"`(短句,不带链接/解释;链接去概念页挂)。
238
+ - 不够格 → 不提。普通经验留在概念页,别进 Rules(否则长成第二份概念库)。
239
+ > **为什么单列一步**:Rules 是全系统**唯一每次 load 全量送达**的通道(代码里明确不折),
240
+ > 而概念页只在关键词命中时出现。**够格却不加 = 这条教训下次不会送达。**
241
+ > 实测坑(2026-09-15):原先把这动作塞在第 4 步里当附属从句,结果 14 条 Rule 中 13 条
242
+ > 来自两次人工注入,机制本身长期 0 新增 —— 同类坑反复发作(静默失效 5 次)。
243
+ > 详见 concepts/learning-loop-collect-distill-deliver.md。
244
+ > **别改成 lint 报警**:那是「靠提醒才能工作的功能」,已被本仓硬规则否定。
245
+ 5. **更新 index/log/todo** → 新页同步进 index;`log.md` 倒序记一行**工作成果**摘要
236
246
  (`abs log "完成 X:..."`,不是工具动作);todo 对账。
237
- **新规律是「违反会丢数据/静默失效/白干活」级别 → 往 index 的 `## Rules` 加一行**
238
- (短句,不带链接;链接去概念页挂)。普通经验不进 Rules —— 否则会长成第二份概念库。
239
247
  跑 `abs lint` 确认自洽。
240
248
 
241
- **完成标准**:看板反映真实状态(Done 无滞留半成品)、该沉淀已落、index/log/todo 与事实一致。
249
+ **完成标准**:看板反映真实状态(Done 无滞留半成品)、该沉淀已落、**够格的教训已提议进 Rules**、index/log/todo 与事实一致。
242
250
 
243
251
  ## 收尾:Teardown Sync(深提炼,工具不替判断)
244
252
 
package/src/install.js CHANGED
@@ -39,6 +39,20 @@ const ALL_SKILLS = (() => {
39
39
  if (!fsSync.existsSync(src)) continue; // 只认含 SKILL.md 的子目录
40
40
  out.push({ name: e.name, src });
41
41
  }
42
+ if (!out.length) {
43
+ // 包内一个 skill 都没找到 = 安装树与代码版本不一致(半升级 / 旧布局 / 打包漏文件)。
44
+ // 不静默装 0 个: 曾实测新版代码遇扁平旧布局 → out=[] → 一句不说就什么都不装,
45
+ // 而旧版代码遇新布局只报一个当前版本根本不存在的路径(E​NOENT skill/SKILL.md), 把人引去查源码。
46
+ const v = (() => { try { return JSON.parse(fsSync.readFileSync(join(ABS_DIR, 'package.json'), 'utf8')).version; } catch { return 'unknown'; } })();
47
+ const flat = fsSync.existsSync(join(SKILL_ROOT, 'SKILL.md'));
48
+ throw new Error(
49
+ `本包内没有可安装的 skill(${SKILL_ROOT}),安装树与代码版本不一致(当前 ${v})。\n` +
50
+ (flat
51
+ ? ' 检测到旧版扁平布局 skill/SKILL.md,本版要求 skill/<名称>/SKILL.md。\n'
52
+ : ' skill/ 目录缺失或不含任何 <名称>/SKILL.md。\n') +
53
+ ' 修复: npm i -g @fanchao8609/agent_brain_sync@latest && abs install(幂等,可安全重跑)',
54
+ );
55
+ }
42
56
  return out;
43
57
  })();
44
58
  // 主 skill(会话开场加载的那个)。它也只是 ALL_SKILLS 的一员,此处仅用于告警比对。
package/src/store.js CHANGED
@@ -130,6 +130,10 @@ export function logTemplate() {
130
130
  /** 标准形状表(与 indexTemplate/logTemplate/todoTemplate 同源)。
131
131
  * 标记不一致 = 直接改成标准(“能自己处理的先处理”)。
132
132
  * 只按**整行精确匹配**改标题,绝不动正文 —— 不做模糊替换,否则正文里提到的旧名会被误改。 */
133
+ // sources/ 堆积阀值:超过就是「采了没消化」。lint 报 SOURCES-PILED-UP,load 顶部同步提示。
134
+ // 两处共用同一常量 —— 阀值只有一个真源。
135
+ const SOURCES_MAX = 10;
136
+
133
137
  export const BRAIN_SHAPE = {
134
138
  'todo.md': {
135
139
  h1: '# 📋 Todo Board',
@@ -328,14 +332,36 @@ export async function cmdLoad({ dir }) {
328
332
  ''
329
333
  );
330
334
  }
335
+ // sources 堆积提醒:与「滞留」同构 —— 放在每次开工必经的顶部,而不是等人跑 lint。
336
+ // 只数目录条目(不读文件),零成本。提炼仍手工:这里只负责送达,不替判断。
337
+ const nsrc = await countSources(root);
338
+ if (nsrc > SOURCES_MAX) {
339
+ // 插在滞留之后 / Rules 之前:滞留更紧急(卡住当前工作),消化其次。
340
+ const at = sections.findIndex((s) => String(s).startsWith('--- Rules')) ;
341
+ sections.splice(at === -1 ? 1 : at, 0,
342
+ `♻ 待消化: sources/ 有 ${nsrc} 条 > ${SOURCES_MAX}(采集了没提炼)`,
343
+ '→ abs lint 看明细;提炼成 concepts/ 后删 source 并清引用',
344
+ '');
345
+ }
331
346
  return sections.join('\n');
332
347
  }
333
348
 
349
+ /** 数 sources/ 下的 .md 文件数(只读目录,不解析内容)—— load 顶部提醒用。
350
+ * 坑: root 是【项目根】,图谱在 root/.brain/ 下 —— 必须走 brainPath,
351
+ * 直接用 join(root,'sources') 会 ENOENT 被 catch 吞成 0,成为又一个静默失效。 */
352
+ async function countSources(root) {
353
+ try {
354
+ const files = await fs.readdir(brainPath(root, 'sources'));
355
+ return files.filter((f) => f.endsWith('.md') && !f.startsWith('_')).length;
356
+ } catch { return 0; }
357
+ }
358
+
334
359
  // ---------- Rules: index.md 里的硬规则区 ----------
335
360
  /** index.md 的 `## Rules` 区名与上限。 */
336
361
  export const RULES_HEADING = '## Rules';
337
362
  export const RULES_MAX = 30; // 超过就 lint 报:它属于“被读到才有价值”的区,不能无界增长
338
363
 
364
+
339
365
  /** 提取 index.md 里的 Rules 区条目(不含标题)。返回 { items:[行], body, found }。 */
340
366
  export function readRules(indexText) {
341
367
  const lines = String(indexText || '').split('\n');
@@ -948,9 +974,14 @@ export async function cmdShow({ dir, view, full }) {
948
974
  const KNOWN_SLUG_HINT = /模板残留|\[\[slug\]\]/;
949
975
 
950
976
  export async function cmdQuery({ dir, terms, includeSuperseded }) {
951
- const words = (terms || []).map((w) => String(w).trim()).filter(Boolean);
977
+ // 每个 term 内部再按空白拆 —— 让 `abs query "发布 流程"` 与 `abs query 发布 流程` 等价。
978
+ // 坑: 曾经引号包起来的 "发布 流程" 被当成一个完整短语 → 全图无命中。
979
+ // 用户看到「无命中」会以为图谱里没这条经验,实际只是词没被拆开(静默失效)。
980
+ const words = [...new Set(
981
+ (terms || []).flatMap((w) => String(w).trim().split(/\s+/)).filter(Boolean)
982
+ )];
952
983
  if (!words.length) {
953
- return '用法: abs query <词1> [词2 …] — 多词 OR 检索 .brain/ 全部知识页';
984
+ return '用法: abs query <词1> [词2 …] — 多词检索 .brain/ 全部知识页';
954
985
  }
955
986
  let root;
956
987
  try {
@@ -997,7 +1028,13 @@ export async function cmdQuery({ dir, terms, includeSuperseded }) {
997
1028
  }
998
1029
  }
999
1030
  const hidden = hits.filter((h) => h.hidden).length;
1000
- const shown = hits.filter((h) => !h.hidden);
1031
+ // 相关性排序:命中词多的排前面。
1032
+ // 为什么不是严格 AND:26 页的量级下,「全词必须命中」经常直接归零(静默变成"查不到")。
1033
+ // 故先用命中数排序,让「全命中」自然浮顶;命中太稀(只 1 页)时才提示可加词。
1034
+ // 实测痛点(2026-09-15): 多词 OR 下查「安装 skill 报错」出 24/26 页(几乎全图)→ 相关性被稀释。
1035
+ const shown = hits.filter((h) => !h.hidden)
1036
+ .map((h) => ({ ...h, score: h.matched.length }))
1037
+ .sort((a, b) => b.score - a.score);
1001
1038
  if (!shown.length) {
1002
1039
  const extra = hidden ? `(另外 ${hidden} 页已标记 superseded,用 abs query ${words.join(' ')} --all 查看)` : '';
1003
1040
  return `query [${words.join(', ')}]: 无命中。${extra}用 abs lint 看图谱健康;首次使用先 abs init。`;
@@ -1009,21 +1046,48 @@ export async function cmdQuery({ dir, terms, includeSuperseded }) {
1009
1046
  const id = h.id && h.id !== h.slug ? ` [id: ${h.id}]` : '';
1010
1047
  // draft = 未经核实。不拦使用,但必须让 AI 知道这是它自己没验证过的。
1011
1048
  const st = h.status === 'draft' ? ' [draft 未核实]' : '';
1012
- return `📄 ${h.slug}${by}${id}${st} (命中: ${h.matched.join(', ')})\n ${h.snippet}`;
1049
+ const full = words.length > 1 && h.score === words.length ? ' ★全命中' : '';
1050
+ return `📄 ${h.slug}${by}${id}${st} (命中: ${h.matched.join(', ')}${full})\n ${h.snippet}`;
1013
1051
  });
1014
- const tail = hidden ? [``, `(${hidden} 页 superseded 已隐藏;--all 可看)`] : [];
1052
+ // 多词且无全命中时告知降级了 —— 不静默给一堆弱相关结果。
1053
+ const anyFull = shown.some((h) => h.score === words.length);
1054
+ const tail = [];
1055
+ if (words.length > 1 && !anyFull) tail.push('', `(无页同时命中全部 ${words.length} 个词,以下按命中数排序)`);
1056
+ if (hidden) tail.push(``, `(${hidden} 页 superseded 已隐藏;--all 可看)`);
1015
1057
  return [`query [${words.join(', ')}] → ${shown.length} 页:`, '', ...lines, ...tail].join('\n');
1016
1058
  }
1017
1059
 
1060
+ /** 从页面正文取一段「像答案」的片段。
1061
+ * 基线(2026-09-15 实测 296 条片段): 25% 是非内容行(tags:/H1/段落标题)。
1062
+ * 典型症状: 查「发布」时 npm-publish-flow 返回 `tags: [concept, npm, publish, 发布]`
1063
+ * —— frontmatter 在第 3 行,跑在正文前,于是「含关键词的第一行」永远先命中它。
1064
+ * 所以必须:(1) 跳过 frontmatter/标题这类非内容行;(2) 优先从「答案段」里找。
1065
+ * 只做机械判断:行首标记 + 所属小节标题,不猜语义。 */
1066
+ const ANSWER_SECTION_RE = /解法|根因|修复|验证|流程|标准|判据|处置|怎么办|🛠/;
1067
+
1018
1068
  function firstHitLine(body, words) {
1019
- const lower = body.toLowerCase();
1020
- for (const line of body.split('\n')) {
1021
- const l = line.toLowerCase();
1022
- if (words.some((w) => l.includes(w.toLowerCase())) && line.trim() && !KNOWN_SLUG_HINT.test(line)) {
1023
- return clip(line.trim(), 160);
1069
+ const lines = body.split('\n');
1070
+ // 逐行扫描,记录当前所属小节标题,供「答案段优先」用。
1071
+ let section = '';
1072
+ const candidates = []; // {line, inAnswer}
1073
+ for (const line of lines) {
1074
+ const t = line.trim();
1075
+ if (t.startsWith('#')) {
1076
+ section = t.replace(/^#+\s*/, '');
1077
+ continue; // 标题本身不是内容
1024
1078
  }
1079
+ if (!t || t === '---') continue;
1080
+ const l = t.toLowerCase();
1081
+ if (!words.some((w) => l.includes(w.toLowerCase()))) continue;
1082
+ if (KNOWN_SLUG_HINT.test(t)) continue;
1083
+ // 非内容行:frontmatter 的键值对(tags:/id:/status:/updated:/author:/superseded-by:)
1084
+ if (/^(tags|id|status|updated|author|superseded-by|superseded|aliases)\s*:/.test(t)) continue;
1085
+ candidates.push({ line: t, inAnswer: ANSWER_SECTION_RE.test(section) });
1025
1086
  }
1026
- return '';
1087
+ if (!candidates.length) return '';
1088
+ // 答案段里的行优先;否则回退到第一条命中的正文行
1089
+ const best = candidates.find((c) => c.inAnswer) || candidates[0];
1090
+ return clip(best.line, 160);
1027
1091
  }
1028
1092
 
1029
1093
  // ---------- note: 经验实时暂存(source 页,一念一落,防流失) ----------
@@ -1087,6 +1151,93 @@ export async function cmdNote({ dir, text, tags }) {
1087
1151
  await cmdLog({ dir: root, title: clean, kind: 'note' });
1088
1152
  return `✓ 经验暂存 → sources/${file}\n ${clean} ${atTag(who)}`;
1089
1153
  }
1154
+
1155
+ // ---------- concept: 概念页脚手架(给「写入」定结构,不替人做判断) ----------
1156
+ /** 建一张带骨架的概念页。
1157
+ *
1158
+ * 为何需要它(实测 2026-09-15): concepts/ 页原来**没有任何代码写入路径** ——
1159
+ * 全靠人/AI 手写 markdown,结果 26 页里 2 页完全没有「做完怎么确认」。
1160
+ * 而骨架只写在 skill 的**文字里**("触发场景/表现/解法/验证命令"),没有执行点 → 看运气。
1161
+ * 对照: `abs note` 落的 source 页结构整齐,因为模板在**代码里**。
1162
+ *
1163
+ * 边界(关键): 它只给**结构**,不给**内容**。
1164
+ * 「这条值不值得留 / 归哪一页」仍靠人判断 —— 那是 skill 明写的分工(深提炼不自动化)。
1165
+ * 所以本命令不猜语义、不自动提炼,只在你要新建页时把该有的位置摆好。
1166
+ *
1167
+ * 尾巴用「占位符」而非真实值: 这样 lint 的 NO-TAIL 判据在占位未填时仍会报
1168
+ * (骨架≠完成)。填完删掉占位行即可。 */
1169
+ export async function cmdConcept({ dir, slug, title, tags, desc }) {
1170
+ let root;
1171
+ try {
1172
+ root = await requireBrain(dir || process.cwd());
1173
+ } catch {
1174
+ return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
1175
+ }
1176
+ const raw = String(slug || '').trim();
1177
+ if (!raw) {
1178
+ return [
1179
+ '用法: abs concept <slug> --title "一句话标题" [--tags a,b] [--desc "index 里的一句话"]',
1180
+ ' 例: abs concept docker-prisma-429 --title "Docker 内存超限导致 Prisma 429"',
1181
+ ' 说明: 只给骨架(头/中/尾位置),内容仍由你写 —— 判断不自动化。',
1182
+ ].join('\n');
1183
+ }
1184
+ // slug 即文件名(命名即链接)。收口掉路径分隔符与空白,防逃出 concepts/。
1185
+ const name = raw.replace(/[\s/\\]+/g, '-').replace(/[^\w\u4e00-\u9fff.-]/g, '').replace(/^-+|-+$/g, '');
1186
+ if (!name) return `✗ slug 无效(清洗后为空): ${raw}`;
1187
+ const who = await requireUser();
1188
+ await ensurePersonPage(root, who);
1189
+ const dirP = brainPath(root, 'concepts');
1190
+ await fs.mkdir(dirP, { recursive: true });
1191
+ const file = join(dirP, `${name}.md`);
1192
+ const head = String(title || '').trim() || name;
1193
+ const tagList = ['concept', ...String(tags || '').split(',').map((t) => t.trim()).filter(Boolean)];
1194
+ const body = [
1195
+ '---',
1196
+ `tags: [${tagList.join(', ')}]`,
1197
+ `id: ${name}`,
1198
+ `author: ${who}`,
1199
+ `updated: ${today()}`,
1200
+ 'status: draft',
1201
+ '---',
1202
+ '',
1203
+ `# 概念:${head}`,
1204
+ '',
1205
+ '## 触发场景',
1206
+ '<!-- 什么情况下该想起这条?(写可检索的词,别只写“遇到问题”) -->',
1207
+ '',
1208
+ '## ❌ 表现',
1209
+ '<!-- 具体症状 / 贴报错 / 复现条件 -->',
1210
+ '',
1211
+ '## 🛠 解法',
1212
+ '<!-- 根因 + 修复 -->',
1213
+ '',
1214
+ '## 验证',
1215
+ '<!-- 做完怎么确认?跑什么命令 / 看什么信号 / 用什么判据。必须填 —— 没尾巴的经验只能被“相信”,不能被“验证” -->',
1216
+ '',
1217
+ '## 关联连接',
1218
+ `- ${atTag(who)} — 本页沉淀者`,
1219
+ '(在这挂相关页双链,别留孤岛)',
1220
+ '',
1221
+ ].join('\n');
1222
+ // 独占写(wx):已存在则 EEXIST —— 与 ensurePersonPage 同路数。
1223
+ // 不用「先查后写」:那有 TOCTOU 竞态,且已有人工内容一律不覆盖是本仓硬规则。
1224
+ // 也不走 tmp+rename:rename 会默默覆盖已存在文件,而这里必须「存在就拒绝」。
1225
+ try {
1226
+ await fs.writeFile(file, body, { encoding: 'utf8', flag: 'wx' });
1227
+ } catch (e) {
1228
+ if (e.code === 'EEXIST') {
1229
+ return `• 已存在,不覆盖 → .brain/concepts/${name}.md\n 要改请直接编辑(或先删页);新建请换个 slug。`;
1230
+ }
1231
+ throw e;
1232
+ }
1233
+ const oneLine = String(desc || '').trim() || clip(head, 60);
1234
+ await registerInIndex(root, 'Concepts', name, oneLine);
1235
+ await cmdLog({ dir: root, title: `新建概念页 ${name}`, kind: 'concept' });
1236
+ return `✓ 概念页骨架 → .brain/concepts/${name}.md ${atTag(who)}\n` +
1237
+ ' 已给好四段位置;填完内容后:删掉 <!-- --> 占位、按需改 status: active、挂双链。\n' +
1238
+ ' 尾部「## 验证」必须填(留空会被 abs lint 报 NO-TAIL)。';
1239
+ }
1240
+
1090
1241
  // ---------- person: 使用者实体页(首次需要时创建,已存在则不动) ----------
1091
1242
  /** 确保 entities/<name>.md 存在。已存在一律不动(里面的技术栈/特点是人工沉淀的)。
1092
1243
  * 用 `wx` 独占写:并发下后到者拿到 EEXIST 就静默跳过,不覆盖。
@@ -1224,6 +1375,20 @@ export async function cmdLint({ dir }) {
1224
1375
  issues.push(`OVER-SIZE: ${pg.rel} (${pg.lines}L/${pg.bytes}B > ${PAGE_MAX_LINES}L/${PAGE_MAX_BYTES / 1024}KB; 拆或外链)`);
1225
1376
  }
1226
1377
  }
1378
+ // NO-TAIL: concept 页只有「头」(触发场景/表现)没有「尾」(可执行的东西)= 只能信,不能验。
1379
+ // 尾巴的本质不是「叫验证」,而是【给出可执行的东西】:跑什么 / 怎么查 / 按什么步骤 / 用什么判据。
1380
+ //
1381
+ // 判据为何要宽(实测):
1382
+ // 26 页的段名高度分散 —— `## ✅ 处置` 出现 17 次,比 `## 🛠 解法` 还多;
1383
+ // 还有 `## 做法`(11) / `## 判据` / `## ✅ 正确顺序` / `## 测试要点` / `## 分析步骤`。
1384
+ // 只认「验证」二字会误报 7/11(64%)→ 噪音 → 规则被忽略。
1385
+ // 判据为何要看「段内有没有真内容」:
1386
+ // `abs concept` 生成的骨架自带 `## 验证` 占位;若只看标题,骨架刚建就被判有尾(假阴性)。
1387
+ // 所以必须排除「只有 <!-- 占位 --> 的空段」。
1388
+ // 实测此版: 误报 0 / 漏报 0(报出的 2 页确实都没有「做完怎么确认」)。
1389
+ if (pg.dir === 'concepts' && !hasTail(pg.body)) {
1390
+ issues.push(`NO-TAIL: ${pg.rel}(无「做完怎么确认」;尾巴写清跑什么/看什么/按什么判据,别只有头)`);
1391
+ }
1227
1392
  if (pg.dir !== 'sources' && !pg.indexed) {
1228
1393
  issues.push(`INDEX-MISSING: ${pg.rel} not listed as [[${pg.slug}]] in index.md`);
1229
1394
  }
@@ -1237,7 +1402,7 @@ export async function cmdLint({ dir }) {
1237
1402
  }
1238
1403
 
1239
1404
  const nsrc = pages.filter((p) => p.dir === 'sources').length;
1240
- if (nsrc > 10) issues.push(`SOURCES-PILED-UP: sources/ has ${nsrc} files > 10; 提炼归档旧 source`);
1405
+ if (nsrc > SOURCES_MAX) issues.push(`SOURCES-PILED-UP: sources/ has ${nsrc} files > ${SOURCES_MAX}; 提炼归档旧 source`);
1241
1406
 
1242
1407
  // SOURCE-UNDISTILLED: source 页超过 SOURCE_STALE_DAYS 天仍没链到任何 concept 页 = 暂存了没归位。
1243
1408
  // 只数总量(SOURCES-PILED-UP)抓不到"4 个 source 里 3 个没提炼"——实测本仓即如此。
@@ -1355,6 +1520,35 @@ const PAGE_DIRS = ['entities', 'concepts', 'sources', 'syntheses', 'sessions'];
1355
1520
  // 放宽到 8KB:当前最大页 5463B,留约 50% 余量,但不至于失去"该拆了"的信号。
1356
1521
  // 提成常量避免检查条件与提示文本各写一份而漂移。
1357
1522
  const PAGE_MAX_LINES = 150;
1523
+
1524
+ /** 尾巴关键词:只要段名里带这些「动作词」,就认为作者在给「做完怎么确认」。
1525
+ * 为何不限定叫「验证」:实测 26 页段名高度分散(`## ✅ 处置` 17 次 > `## 🛠 解法`),
1526
+ * 只认「验证」会误报 7/11(64%)—— 噪音会让规则失去意义。 */
1527
+ const TAIL_WORDS = '验证|检查|清单|测试|处置|做法|步骤|顺序|判据|信号|怎么';
1528
+
1529
+ /** concept 页是否有「尾」(可执行的东西)。
1530
+ * 两种真实形态都算:
1531
+ * 1. 有带动作词的段标题,且**段内有真内容** —— 排除 `abs concept` 骨架的
1532
+ * `## 验证` + `<!-- 占位 -->`(只看标题会把未填的骨架误判为有尾)。
1533
+ * 2. 列表项形式,如 `3. 验证命令:\`cmd\``(hook-sh-not-bash / todo-rewrite-not-map 的写法)。
1534
+ * 逐行扫描而非复杂正则:需要「段边界」与「占位识别」,正则会难读且难改。 */
1535
+ export function hasTail(body) {
1536
+ const lines = String(body || '').split('\n');
1537
+ const headRe = new RegExp(`^#{2,6}[^\\n]*(${TAIL_WORDS})`);
1538
+ for (let i = 0; i < lines.length; i++) {
1539
+ if (!headRe.test(lines[i])) continue;
1540
+ const lvl = lines[i].match(/^#+/)[0].length;
1541
+ for (let j = i + 1; j < lines.length; j++) {
1542
+ const t = lines[j].trim();
1543
+ const h = lines[j].match(/^(#+)\s/);
1544
+ if (h && h[1].length <= lvl) break; // 本段结束,换下一段找
1545
+ if (!t) continue;
1546
+ if (t.startsWith('<!--') || t === '-->') continue; // 占位注释不算内容
1547
+ return true;
1548
+ }
1549
+ }
1550
+ return new RegExp(`^\\s*(?:\\d+\\.|[-*])\\s*\\**[^\\n]{0,20}(${TAIL_WORDS})`, 'm').test(String(body || ''));
1551
+ }
1358
1552
  const PAGE_MAX_BYTES = 8 * 1024;
1359
1553
 
1360
1554
  // source 页超龄未提炼的天数阈值(SOURCE-UNDISTILLED)。