agentic-flow-kit 0.5.0 → 0.6.0

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.
Files changed (82) hide show
  1. package/README.md +2 -0
  2. package/modules/gates/dotnet-ca/check-architecture.sh +23 -5
  3. package/modules/hosts/omp/agents/implementer.md +2 -0
  4. package/modules/hosts/omp/agents/independent-reviewer.md +2 -0
  5. package/modules/hosts/omp/agents/ui-verifier.md +2 -0
  6. package/modules/hosts/opencode/agents/implementer.md +2 -0
  7. package/modules/hosts/opencode/agents/independent-reviewer.md +2 -0
  8. package/modules/hosts/opencode/agents/ui-verifier.md +2 -0
  9. package/modules/hosts/opencode/commands/build.md +20 -1
  10. package/modules/hosts/opencode/commands/design.md +2 -0
  11. package/modules/hosts/opencode/commands/gate-checklist.md +77 -0
  12. package/modules/hosts/opencode/commands/maintain.md +2 -3
  13. package/modules/hosts/opencode/commands/plan.md +4 -1
  14. package/modules/hosts/opencode/commands/review.md +1 -0
  15. package/modules/hosts/opencode/commands/source-sync-check.md +89 -0
  16. package/modules/hosts/opencode/commands/sync-hosts.md +66 -0
  17. package/modules/hosts/opencode/commands/test.md +4 -2
  18. package/modules/hosts/trae/agents/implementer.md +2 -0
  19. package/modules/hosts/trae/agents/independent-reviewer.md +2 -0
  20. package/modules/hosts/trae/agents/ui-verifier.md +2 -0
  21. package/modules/hosts/trae/commands/wf-build.md +20 -1
  22. package/modules/hosts/trae/commands/wf-design.md +2 -0
  23. package/modules/hosts/trae/commands/wf-gate-checklist.md +78 -0
  24. package/modules/hosts/trae/commands/wf-maintain.md +2 -3
  25. package/modules/hosts/trae/commands/wf-plan.md +4 -1
  26. package/modules/hosts/trae/commands/wf-review.md +1 -0
  27. package/modules/hosts/trae/commands/wf-source-sync-check.md +90 -0
  28. package/modules/hosts/trae/commands/wf-sync-hosts.md +67 -0
  29. package/modules/hosts/trae/commands/wf-test.md +4 -2
  30. package/modules/hosts/trae/hooks/post-edit-check.cjs +3 -2
  31. package/modules/hosts/trae/hooks/pre-shell-check.cjs +40 -9
  32. package/modules/hosts/trae/hooks.json +1 -1
  33. package/modules/hosts/zcode/agents/implementer.md +2 -0
  34. package/modules/hosts/zcode/agents/independent-reviewer.md +2 -0
  35. package/modules/hosts/zcode/agents/ui-verifier.md +2 -0
  36. package/package.json +2 -2
  37. package/src/add-gate.mjs +5 -3
  38. package/src/doctor.mjs +121 -18
  39. package/src/gate-dotnet-ca.test.mjs +124 -0
  40. package/src/init.mjs +79 -17
  41. package/src/init.test.mjs +60 -1
  42. package/src/render.mjs +64 -4
  43. package/src/run-tests.mjs +3 -0
  44. package/src/sync-hosts.mjs +3 -2
  45. package/src/sync.mjs +64 -20
  46. package/src/sync.test.mjs +42 -0
  47. package/src/trae-hooks.test.mjs +115 -0
  48. package/templates/AGENTS.md +1 -0
  49. package/templates/_agents/board/index.html +32 -3
  50. package/templates/_agents/commands/build.md +5 -4
  51. package/templates/_agents/commands/design.md +1 -0
  52. package/templates/_agents/commands/maintain.md +1 -2
  53. package/templates/_agents/commands/plan.md +2 -0
  54. package/templates/_agents/commands/test.md +2 -1
  55. package/templates/_agents/hooks/check-pairing-incremental.sh +20 -7
  56. package/templates/_agents/roles/implementer.md +2 -0
  57. package/templates/_agents/roles/independent-reviewer.md +2 -0
  58. package/templates/_agents/roles/ui-verifier.md +2 -0
  59. package/templates/_agents/scripts/agg-delegations.cjs +39 -30
  60. package/templates/_agents/scripts/agg-delegations.test.mjs +32 -0
  61. package/templates/_agents/scripts/check-loop.mjs +262 -16
  62. package/templates/_agents/scripts/check-loop.test.mjs +621 -0
  63. package/templates/_agents/scripts/confirm-doc.mjs +228 -0
  64. package/templates/_agents/scripts/confirm-doc.test.mjs +321 -0
  65. package/templates/_agents/scripts/doctor.test.mjs +74 -1
  66. package/templates/_agents/scripts/ensure-board.mjs +29 -2
  67. package/templates/_agents/scripts/gate-checklist.mjs +2 -0
  68. package/templates/_agents/scripts/gate-checklist.test.mjs +3 -3
  69. package/templates/_agents/scripts/gen-wiki-board.mjs +38 -24
  70. package/templates/_agents/scripts/gen-wiki-board.test.mjs +18 -0
  71. package/templates/_agents/scripts/gen-workflow-metrics.mjs +12 -3
  72. package/templates/_agents/scripts/kb-search.mjs +22 -8
  73. package/templates/_agents/scripts/kb-search.test.mjs +38 -8
  74. package/templates/_agents/scripts/source-sync-check.mjs +17 -3
  75. package/templates/_agents/scripts/source-sync-check.test.mjs +96 -8
  76. package/templates/_agents/scripts/verify-wiki-consistency.mjs +46 -23
  77. package/templates/_agents/scripts/workflow-board-server.mjs +64 -9
  78. package/templates/_agents/scripts/workflow-board-server.test.mjs +136 -0
  79. package/templates/_agents/scripts/workflow-enums.mjs +4 -2
  80. package/templates/_agents/workflow-enums.txt +3 -3
  81. package/templates/_agents/workflows/_TEMPLATE.md +1 -0
  82. package/templates/_githooks/pre-commit +16 -1
@@ -5,6 +5,8 @@
5
5
  // ② 手改装副本(盘面 sha 变) → drift=1 gone=[] total=1
6
6
  // ③ owned 文件缺失 → drift=0 gone=[rel] total=1
7
7
  // ④ kit.json 无 owned 字段 → skipped=true total=0
8
+ // 另测 checkAdapterDrift(target)(场景 5-14)与 checkLedgerCoverage(target, pkgRoot)(场景 15-18,
9
+ // 2026-09-26 managed-ledger-adopt:补「盘上有、台账无」一侧)。
8
10
  // 用法:node .agents/scripts/doctor.test.mjs(在仓库根执行,需 src/doctor.mjs 存在——dogfooding 模式)
9
11
  import fs from 'node:fs';
10
12
  import os from 'node:os';
@@ -20,7 +22,7 @@ if (!fs.existsSync(SRC)) {
20
22
  console.error(`doctor.test.mjs:未找到 ${SRC}——仅在包源仓库(dogfooding)跑 npm test 时调用`);
21
23
  process.exit(1);
22
24
  }
23
- const { checkOwnedDrift, checkAdapterDrift } = await import(pathToFileURL(SRC).href);
25
+ const { checkOwnedDrift, checkAdapterDrift, checkLedgerCoverage } = await import(pathToFileURL(SRC).href);
24
26
 
25
27
  let pass = 0;
26
28
  let fail = 0;
@@ -294,5 +296,76 @@ const CMD_BODY = '# Build\n\n正文 build v1\n';
294
296
  fs.rmSync(root, { recursive: true, force: true });
295
297
  }
296
298
 
299
+ // ---- 场景 15:盘上(包源)有 managed 类文件但台账无 → 差集非空(本 incident 的目标形态)----
300
+ {
301
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'doctor-cov-'));
302
+ const pkg = fs.mkdtempSync(path.join(os.tmpdir(), 'doctor-cov-pkg-'));
303
+ // 包源模板树:一份 managed 类(.agents/scripts/a.mjs)+ 一份 owned 类(workflow/README.md,须被排除)
304
+ const w = (p, c) => { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, c); };
305
+ w(path.join(pkg, 'templates/_agents/scripts/a.mjs'), '// a\n');
306
+ w(path.join(pkg, 'templates/_agents/commands/b.md'), '// b\n');
307
+ w(path.join(pkg, 'templates/workflow/README.md'), '// owned\n');
308
+ fs.mkdirSync(path.join(root, '.agents'), { recursive: true });
309
+ fs.writeFileSync(path.join(root, '.agents', 'kit.json'), JSON.stringify({
310
+ kit: 'agentic-flow-kit', version: '0.5.0', options: { hosts: [], stack: 'none', boardPort: '8933' },
311
+ managed: [], owned: [],
312
+ }, null, 2));
313
+ const r = checkLedgerCoverage(root, pkg);
314
+ check('场景 15:盘上有 managed 类文件台账无 → 差集含两者',
315
+ r.skipped === false && r.missing.includes('.agents/scripts/a.mjs') && r.missing.includes('.agents/commands/b.md') && r.missing.length === 2,
316
+ JSON.stringify(r));
317
+ check('场景 15:owned 类(workflow/)不入差集',
318
+ !r.missing.some((m) => m.startsWith('workflow/')), JSON.stringify(r.missing));
319
+ fs.rmSync(root, { recursive: true, force: true });
320
+ fs.rmSync(pkg, { recursive: true, force: true });
321
+ }
322
+
323
+ // ---- 场景 16:台账登记齐全 → 差集空 ----
324
+ {
325
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'doctor-cov-'));
326
+ const pkg = fs.mkdtempSync(path.join(os.tmpdir(), 'doctor-cov-pkg-'));
327
+ const w = (p, c) => { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, c); };
328
+ w(path.join(pkg, 'templates/_agents/scripts/a.mjs'), '// a\n');
329
+ fs.mkdirSync(path.join(root, '.agents'), { recursive: true });
330
+ fs.writeFileSync(path.join(root, '.agents', 'kit.json'), JSON.stringify({
331
+ kit: 'agentic-flow-kit', version: '0.5.0', options: { hosts: [], stack: 'none', boardPort: '8933' },
332
+ managed: [{ rel: '.agents/scripts/a.mjs', sha256: 'x' }], owned: [],
333
+ }, null, 2));
334
+ const r = checkLedgerCoverage(root, pkg);
335
+ check('场景 16:登记齐全 → missing=[] skipped=false',
336
+ r.skipped === false && r.missing.length === 0, JSON.stringify(r));
337
+ fs.rmSync(root, { recursive: true, force: true });
338
+ fs.rmSync(pkg, { recursive: true, force: true });
339
+ }
340
+
341
+ // ---- 场景 17:无包源 templates/(装户环境)→ skipped ----
342
+ {
343
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'doctor-cov-'));
344
+ const pkg = fs.mkdtempSync(path.join(os.tmpdir(), 'doctor-cov-pkg-')); // 空包根,无 templates/
345
+ fs.mkdirSync(path.join(root, '.agents'), { recursive: true });
346
+ fs.writeFileSync(path.join(root, '.agents', 'kit.json'), JSON.stringify({
347
+ kit: 'agentic-flow-kit', version: '0.5.0', options: { hosts: [], stack: 'none', boardPort: '8933' },
348
+ managed: [], owned: [],
349
+ }, null, 2));
350
+ const r = checkLedgerCoverage(root, pkg);
351
+ check('场景 17:无包源模板 → skipped=true missing=[]',
352
+ r.skipped === true && r.missing.length === 0, JSON.stringify(r));
353
+ fs.rmSync(root, { recursive: true, force: true });
354
+ fs.rmSync(pkg, { recursive: true, force: true });
355
+ }
356
+
357
+ // ---- 场景 18:无 kit.json → skipped ----
358
+ {
359
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'doctor-cov-'));
360
+ const pkg = fs.mkdtempSync(path.join(os.tmpdir(), 'doctor-cov-pkg-'));
361
+ fs.mkdirSync(path.join(pkg, 'templates/_agents/scripts'), { recursive: true });
362
+ fs.writeFileSync(path.join(pkg, 'templates/_agents/scripts/a.mjs'), '// a\n');
363
+ const r = checkLedgerCoverage(root, pkg);
364
+ check('场景 18:无 kit.json → skipped=true missing=[]',
365
+ r.skipped === true && r.missing.length === 0, JSON.stringify(r));
366
+ fs.rmSync(root, { recursive: true, force: true });
367
+ fs.rmSync(pkg, { recursive: true, force: true });
368
+ }
369
+
297
370
  console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
298
371
  process.exit(fail ? 1 : 0);
@@ -16,7 +16,12 @@ import { spawn } from 'node:child_process';
16
16
  const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
17
17
  const ROOT = path.resolve(SCRIPT_DIR, '../..'); // 与 server 的 ROOT 同口径
18
18
  const SERVER = path.join(SCRIPT_DIR, 'workflow-board-server.mjs');
19
- const CODE_FILES = [path.join(ROOT, '.agents', 'board', 'index.html'), SERVER]; // 前端 + 服务端:任一更新都算「旧代码」
19
+ const CODE_FILES = [
20
+ path.join(ROOT, '.agents', 'board', 'index.html'),
21
+ SERVER,
22
+ path.join(SCRIPT_DIR, 'workflow-enums.mjs'), // server import 即加载(p2-batch2:枚举更新不触发重启的盲区)
23
+ path.join(ROOT, '.agents', 'board', 'marked.min.js'), // 前端运行时依赖(同盲区)
24
+ ]; // 任一更新都算「旧代码」
20
25
  const WINDOW = 10;
21
26
 
22
27
  // ---- 参数与基端口 ----
@@ -83,6 +88,7 @@ function staleCode(startedAt) {
83
88
  function startServer(port) {
84
89
  const c = spawn(process.execPath, [SERVER, '--port', String(port)], { detached: true, stdio: 'ignore', windowsHide: true });
85
90
  c.unref();
91
+ return c; // 返回 child 供竞态复验比对 pid(p2-batch2)
86
92
  }
87
93
 
88
94
  async function waitListen(port) {
@@ -145,11 +151,32 @@ async function main() {
145
151
  }
146
152
  if (skipped.length) console.log(`board: 基端口段他人进程已跳过:${skipped.join(';')}`);
147
153
 
148
- startServer(port);
154
+ const child = startServer(port);
149
155
  if (!(await waitListen(port))) {
150
156
  console.log(`board: FAILED to start on ${port}`);
151
157
  return 1;
152
158
  }
159
+ // 竞态复验(2026-09-27 p2-batch2;同日复核 P1-1 收窄):waitListen 只见「端口有人听」——spawn 与
160
+ // listen 之间他人进程抢先绑定同端口时自家子进程已 EADDRINUSE 退出。复验 /api/board 自报 pid 与
161
+ // 自家子进程一致。判定顺序(复核实测教训:冷启动首包可达 12s,2s 硬超时会误杀 fresh start 主路径):
162
+ // ① 子进程已退出(EADDRINUSE 自杀)→ 端口确被抢占,如实报告;
163
+ // ② 15s 内轮询 probeBoard 直到自报 pid === 自家 pid(长超时容忍冷启动);
164
+ // ③ 轮询超时仍不一致 → 如实报告未确认为本项目(不误称他人抢占)。
165
+ const deadline = Date.now() + 15000;
166
+ let mine = null;
167
+ while (Date.now() < deadline) {
168
+ if (child.exitCode !== null) {
169
+ console.log(`board: 端口 ${port} 在启动窗口被他人进程抢占(本项目进程已退出 exitCode=${child.exitCode})——本轮未拉起本项目看板,可重跑 ensure-board 自动上探下一端口`);
170
+ return 1;
171
+ }
172
+ mine = await probeBoard(port);
173
+ if (mine.ours && mine.pid === child.pid) break;
174
+ await sleep(400);
175
+ }
176
+ if (!mine || !mine.ours || mine.pid !== child.pid) {
177
+ console.log(`board: 端口 ${port} 上的服务未能确认为本项目(pid ${child.pid},自报 ${mine?.pid ?? '未知'})——冷启动超时或被抢占;可重跑 ensure-board`);
178
+ return 1;
179
+ }
153
180
  console.log(`board: ${action} -> ${url}`);
154
181
  if (!noOpen && action === 'fresh start') openBrowser(url);
155
182
  return 0;
@@ -60,6 +60,7 @@ const PAIRS = [
60
60
  { doctor: '2', cl: null, note: '目录布局——装户体检独有' },
61
61
  { doctor: '3', cl: null, note: 'git 仓库与钩子——装户体检独有' },
62
62
  { doctor: '4', cl: null, note: 'kit.json managed 台账——装户体检独有' },
63
+ { doctor: '4.5', cl: null, note: '台账覆盖率——装户体检独有(盘上有 managed 类文件未登记,2026-09-26 managed-ledger-adopt)' },
63
64
  { doctor: '6.5', cl: null, note: 'delegations 台账结构——装户体检独有' },
64
65
  { doctor: '6.6', cl: null, note: 'owned 漂移——装户体检独有' },
65
66
  { doctor: '6.7', cl: null, note: '跨宿主薄适配——装户体检独有' },
@@ -78,6 +79,7 @@ const PAIRS = [
78
79
  { doctor: '7', cl: '12', note: '经 §7 整体运行覆盖' },
79
80
  { doctor: '7', cl: '13', note: '经 §7 整体运行覆盖' },
80
81
  { doctor: '7', cl: '14', note: '经 §7 整体运行覆盖' },
82
+ { doctor: '7', cl: '15', note: '经 §7 整体运行覆盖' },
81
83
  ];
82
84
 
83
85
  // gateChecklist:按登记表对照两侧检查项(pure function;pairs 可注入供测试)
@@ -167,11 +167,11 @@ if (hasReal) {
167
167
  JSON.stringify({ broken: r.broken, unregistered: r.unregistered }));
168
168
  }
169
169
 
170
- // ---- S11 真实仓库计数:doctor ≥ 12 节 / check-loop ≥ 14 项 ----
170
+ // ---- S11 真实仓库计数:doctor ≥ 12 节 / check-loop ≥ 15 项 ----
171
171
  if (hasReal) {
172
172
  const r = gateChecklist({ doctorSrc: realDoctor(), checkLoopSrc: realCl() });
173
- check('S11 真实仓库 doctor ≥ 12 节且 check-loop ≥ 14 项(baseline)',
174
- r.doctorCount >= 12 && r.checkLoopCount >= 14,
173
+ check('S11 真实仓库 doctor ≥ 12 节且 check-loop ≥ 15 项(baseline)',
174
+ r.doctorCount >= 12 && r.checkLoopCount >= 15,
175
175
  `doctor ${r.doctorCount} / check-loop ${r.checkLoopCount}`);
176
176
  }
177
177
 
@@ -44,20 +44,40 @@ const BOARD_P = path.join(WIKI, '知识沉淀总览.html');
44
44
  // 主题目录顶层:全量文件登记(现状不变);
45
45
  // 直属子目录:仅登记 md/html 知识文档(排除 *.visual-check.* 工具产物;.json/.png 不计入);
46
46
  // 数据维护主题例外——其子目录(schema变更/<批次>/ 等)走 README.md 台账,不参与登记。
47
+ // gitignore 豁免(F4,incident 2026-09-23-code-review-f3-f4-closeout;p2-batch2 扩面到全 wiki/——
48
+ // 活跃主题目录内的本机文件(如 *.xlsx)同样会造成生成物计数在 fresh clone 漂移):
49
+ // 排除 .gitignore 忽略的本机文件,git 不可用时退化为全量计数
50
+ const gitIgnoredArchive = (() => {
51
+ try {
52
+ const out = execSync('git -c core.quotepath=false status --ignored=matching --porcelain -uall -- wiki',
53
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
54
+ const prefix = 'wiki/';
55
+ return new Set(out.split(/\r?\n/)
56
+ .filter((l) => l.startsWith('!! '))
57
+ .map((l) => l.slice(3).trim().replace(/\/$/, ''))
58
+ .filter((p) => p.startsWith(prefix))
59
+ .map((p) => p.slice(prefix.length)));
60
+ } catch {
61
+ return new Set();
62
+ }
63
+ })();
47
64
  const isKnowledgeDoc = (f) => /\.(md|html)$/i.test(f) && !/\.visual-check\./i.test(f);
48
65
  const diskTopics = new Map(); // name -> [{ name: '文件名或 子目录/文件名', dir: 'wiki/主题[/子目录]/' }]
49
66
  for (const d of fs.readdirSync(WIKI)) {
50
67
  const p = path.join(WIKI, d);
51
68
  if (!fs.statSync(p).isDirectory() || d === 'drafts-archive') continue;
69
+ // gitignore 豁免消费(p2-batch2 扩面落点):gitIgnoredArchive 集合现为全 wiki/ 的忽略件相对路径——
70
+ // 活跃区 entries 构建处按「主题/文件」与「主题/子目录/文件」过滤(复核 P1-2:扩面集合此前无消费点=死代码)
71
+ const isIgnored = (rel) => { const k = rel.endsWith('/') ? rel.slice(0, -1) : rel; return gitIgnoredArchive.has(`${d}/${k}`) || gitIgnoredArchive.has(`${d}/${k}/`); }; // 尾斜杠归一(复核 P2-a;endsWith 替代正则——此文件转义层过于嘈杂)
52
72
  const entries = fs.readdirSync(p)
53
- .filter((f) => fs.statSync(path.join(p, f)).isFile())
73
+ .filter((f) => fs.statSync(path.join(p, f)).isFile() && !isIgnored(f))
54
74
  .map((f) => ({ name: f, dir: `wiki/${d}/` }));
55
75
  if (d !== '数据维护') {
56
76
  for (const sub of fs.readdirSync(p)) {
57
77
  const sp = path.join(p, sub);
58
- if (!fs.statSync(sp).isDirectory()) continue;
78
+ if (!fs.statSync(sp).isDirectory() || isIgnored(`${sub}/`)) continue;
59
79
  for (const f of fs.readdirSync(sp)) {
60
- if (fs.statSync(path.join(sp, f)).isFile() && isKnowledgeDoc(f)) {
80
+ if (fs.statSync(path.join(sp, f)).isFile() && isKnowledgeDoc(f) && !isIgnored(`${sub}/${f}`)) {
61
81
  entries.push({ name: `${sub}/${f}`, dir: `wiki/${d}/${sub}/` });
62
82
  }
63
83
  }
@@ -66,29 +86,15 @@ for (const d of fs.readdirSync(WIKI)) {
66
86
  entries.sort((a, b) => a.name.localeCompare(b.name, 'zh'));
67
87
  diskTopics.set(d, entries);
68
88
  }
69
- // gitignore 豁免(F4,incident 2026-09-23-code-review-f3-f4-closeout):与 verify-wiki-consistency.mjs 同口径——
70
- // 排除 .gitignore 忽略的本机文件(如 *.xlsx),防止生成物计数在 fresh clone 漂移;git 不可用时退化为全量计数
71
- const gitIgnoredArchive = (() => {
72
- try {
73
- const out = execSync('git -c core.quotepath=false status --ignored=matching --porcelain -uall -- wiki/drafts-archive',
74
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
75
- const prefix = 'wiki/drafts-archive/';
76
- return new Set(out.split(/\r?\n/)
77
- .filter((l) => l.startsWith('!! '))
78
- .map((l) => l.slice(3).trim().replace(/\/$/, ''))
79
- .filter((p) => p.startsWith(prefix))
80
- .map((p) => p.slice(prefix.length)));
81
- } catch {
82
- return new Set();
83
- }
84
- })();
89
+
85
90
  const archiveCount = (() => {
86
91
  let n = 0;
87
92
  const walk = (dir, rel = '') => {
88
93
  for (const e of fs.readdirSync(dir)) {
89
94
  const p = path.join(dir, e);
90
- const r = rel ? `${rel}/${e}` : e;
91
- if (fs.statSync(p).isDirectory()) walk(p, r);
95
+ // 集合键带 drafts-archive/ 前缀(扫描面扩全 wiki/ 后),walk rel 须拼前缀比对(复核 P2-b)
96
+ const r = rel ? `drafts-archive/${rel}/${e}` : `drafts-archive/${e}`;
97
+ if (fs.statSync(p).isDirectory()) walk(p, rel ? `${rel}/${e}` : e);
92
98
  else if (!gitIgnoredArchive.has(r)) n++;
93
99
  }
94
100
  };
@@ -100,11 +106,19 @@ const archiveCount = (() => {
100
106
  // ---- 读 INDEX 现速览表:主题顺序 + 用途列(人工,不覆盖)----
101
107
  // 切行须容忍 CRLF:按 '\n' 切会在行尾留 '\r',速览表行锚定正则(\|$)整表失配 →
102
108
  // 「用途」列(唯一人工维护位)被静默重置为 <待补>(papercut 2026-09-17;fixture 见 gen-wiki-board.test.mjs 场景 1)
109
+ // 用途列管道转义(p2-batch2):生成侧对单元格 `|` → `\|`(行内码 \`|\`),解析侧容忍转义——
110
+ // 否则含管道的用途描述会切坏表格行、下次生成该行解析失配走「新主题追加」把人工文本静默重置
111
+ const BS = String.fromCharCode(92); // 反斜杠字符(转义写法在此文件里反复出错,用 charCode 表达)
112
+ const PIPE_CH = '|';
113
+ const escCell = (s) => String(s).split(PIPE_CH).join(BS + PIPE_CH);
114
+ const unescCell = (s) => String(s).split(BS + PIPE_CH).join(PIPE_CH);
103
115
  const indexText = fs.readFileSync(INDEX_P, 'utf8');
104
116
  const overview = []; // {name, usage}
117
+ // 解析正则(p2-batch2):用途列允许转义管道 \|--`(?:\\.|[^|])*` 吃掉「转义字符+任一字符」或普通字符,
118
+ // 裸管道才是列分隔。unescCell 还原人工原文。
105
119
  for (const line of indexText.split(/\r?\n/)) {
106
- const m = line.match(/^\| ([^|]+) \| (\d+) \|([^|]*)\|$/);
107
- if (m && m[1].trim() !== '主题') overview.push({ name: m[1].trim(), usage: m[3].trim() });
120
+ const m = line.match(/^\| ([^|]+) \| (\d+) \|((?:\\.|[^|])*)\|$/);
121
+ if (m && m[1].trim() !== '主题') overview.push({ name: m[1].trim(), usage: unescCell(m[3].trim()) });
108
122
  }
109
123
  // 主题顺序 = 速览表现顺序在前,磁盘新主题按中文排序追加
110
124
  const ordered = [...overview.filter((o) => diskTopics.has(o.name))];
@@ -116,7 +130,7 @@ const totalFiles = [...diskTopics.values()].reduce((n, f) => n + f.length, 0);
116
130
 
117
131
  // ---- 生成 INDEX 三段 ----
118
132
  const eol = indexText.includes('\r\n') ? '\r\n' : '\n';
119
- const overviewRows = ordered.map((o) => `| ${o.name} | ${diskTopics.get(o.name).length} | ${o.usage} |`);
133
+ const overviewRows = ordered.map((o) => `| ${o.name} | ${diskTopics.get(o.name).length} | ${escCell(o.usage)} |`);
120
134
  const mappingSection = ordered.map((o) => {
121
135
  const rows = diskTopics.get(o.name).map((e) => `| ${e.name} | ${o.name} | ${e.dir} |`);
122
136
  return [`### ${o.name}(${diskTopics.get(o.name).length})`, '', '| 文件名 | 主题 | 归属目录 |', '|---|---|---|', ...rows].join(eol);
@@ -150,6 +150,24 @@ const runGen = (root) => spawnSync(process.execPath, [GEN], { cwd: root, encodin
150
150
  fs.rmSync(root, { recursive: true, force: true });
151
151
  }
152
152
 
153
+ // ---- 场景 6:用途列含管道符 → 转义保 round-trip(p2-batch2:含管道的用途描述曾切坏表格行、
154
+ // 下次生成把人工文本静默重置为 <待补>)----
155
+ {
156
+ const root = mkfix('\n');
157
+ const BS = String.fromCharCode(92); // 反斜杠字符(JS 字符串 '\|' 中 \| 就是裸管道无转义语义——须显式拼)
158
+ const USAGE_WITH_PIPE = '用于 A' + BS + '|B 两类(含竖线)'; // 转义形态输入(反斜杠+管道)
159
+ const idx = path.join(root, 'wiki', 'INDEX.md');
160
+ fs.writeFileSync(idx, fs.readFileSync(idx, 'utf8').replace(USAGE_COL, USAGE_WITH_PIPE), 'utf8');
161
+ const r1 = runGen(root);
162
+ const out1 = fs.readFileSync(idx, 'utf8');
163
+ check('场景 6:含转义管道用途生成 exit 0 且原样保留', r1.status === 0 && out1.includes(USAGE_WITH_PIPE), `exit=${r1.status}\n${out1.split('\n').slice(0, 8).join('\n')}`);
164
+ const r2 = runGen(root);
165
+ const out2 = fs.readFileSync(idx, 'utf8');
166
+ check('场景 6:二次生成用途文本保持(round-trip 不重置为 <待补>)',
167
+ r2.status === 0 && out2.includes(USAGE_WITH_PIPE) && !out2.includes('<待补'), out2.split('\n').slice(0, 8).join('\n'));
168
+ fs.rmSync(root, { recursive: true, force: true });
169
+ }
170
+
153
171
  console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
154
172
  if (fail) {
155
173
  console.log(`\n${USAGE}`);
@@ -4,11 +4,15 @@
4
4
  // 目的:把「语料膨胀 + 常驻面体积」变成可观测趋势——每月(或按需)跑一次,往 workflow/metrics.md 追加/更新一行。
5
5
  // 口径:文档 = 磁盘(对照 gen-workflow-index / 看板,排除 _TEMPLATE.md);常驻面 = `.agents/rule-budgets.txt`
6
6
  // 各条实测(单源,含预算上限),括号内给「预算占用峰值项」。
7
+ // 字节口径声明(p2-batch2):本脚本用工作区 statSync 字节(CRLF 检出态会偏高),rule-budget.sh 用
8
+ // 索引 LF 归一字节——趋势观测可对比,但与门禁判定数值存在系统性差异,勿互相替用。
7
9
  // 不做 --check / 不挂门禁:快照是历史记录——任何文档改动都会让「当前值」漂移,拿它做漂移校验只会常红;
8
10
  // 规则面的机器约束由 pre-commit 的 rule-budget.sh 负责,本脚本只负责「看见趋势」。
9
11
  // 测试:node .agents/scripts/gen-workflow-metrics.test.mjs(fixture 回归,须全绿)
10
12
  import fs from 'node:fs';
11
13
  import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { loadEnums } from './workflow-enums.mjs';
12
16
 
13
17
  const USAGE = `用法:node .agents/scripts/gen-workflow-metrics.mjs [--month YYYY-MM] [--dry-run] [--help]
14
18
 
@@ -39,7 +43,10 @@ const WORKFLOW = 'workflow';
39
43
  const METRICS_P = path.join(WORKFLOW, 'metrics.md');
40
44
  const BUDGETS = path.join('.agents', 'rule-budgets.txt');
41
45
  const DOC_TYPES = ['intents', 'specs', 'plans', 'incidents'];
42
- const ACTIVE_STATUS = ['draft', 'approved', 'open'];
46
+ // 活跃口径单源(board-kb-p1:枚举字面量硬编码退役——workflow-enums.txt 是唯一源);
47
+ // 枚举路径锚定脚本位置(fixture 测试 cwd 在临时目录,按 cwd 解析会 ENOENT)
48
+ const ENUMS = loadEnums(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '.agents', 'workflow-enums.txt'));
49
+ const ACTIVE_STATUS = [...ENUMS['doc.status.active'], ...ENUMS['incident.status.active']];
43
50
  const BEGIN = '<!-- GENERATED:BEGIN — gen-workflow-metrics.mjs 整段重写,手工说明写在本行之前 -->';
44
51
  const END = '<!-- GENERATED:END -->';
45
52
 
@@ -51,6 +58,7 @@ function scanDocs() {
51
58
  let files = 0;
52
59
  let bytes = 0;
53
60
  let active = 0;
61
+ let invalid = 0;
54
62
  let withModule = 0;
55
63
  for (const type of DOC_TYPES) {
56
64
  let n = 0;
@@ -72,6 +80,7 @@ function scanDocs() {
72
80
  }
73
81
  }
74
82
  if (ACTIVE_STATUS.includes(status)) active++;
83
+ else if (!status || ![...ENUMS['doc.status.all'], ...ENUMS['incident.status.all']].includes(status)) invalid++; // 状态缺失/非法单列(board-kb-p1:不再吞进终态桶)
75
84
  if (mod) withModule++;
76
85
  n++;
77
86
  b += fs.statSync(p).size;
@@ -80,7 +89,7 @@ function scanDocs() {
80
89
  files += n;
81
90
  bytes += b;
82
91
  }
83
- return { perType, files, bytes, active, terminal: files - active, withModule };
92
+ return { perType, files, bytes, active, terminal: files - active - invalid, invalid, withModule };
84
93
  }
85
94
 
86
95
  // ---- 常驻面:按预算表逐条实测(<glob|dir/> <上限>)----
@@ -146,7 +155,7 @@ const row = `| ${month} | ${docs.files}(活跃 ${docs.active} / 终态 ${docs.
146
155
 
147
156
  // ---- 明细(stdout,不落表):类型分布 + 逐条预算占用 ----
148
157
  console.log(`📅 ${month} 快照`);
149
- console.log(` 文档:${docs.files} 篇 / ${kb(docs.bytes)}(活跃 ${docs.active} / 终态 ${docs.terminal});模块已填 ${docs.withModule}/${docs.files}`);
158
+ console.log(` 文档:${docs.files} 篇 / ${kb(docs.bytes)}(活跃 ${docs.active} / 终态 ${docs.terminal}${docs.invalid ? ` / 状态缺失或非法 ${docs.invalid}` : ''});模块已填 ${docs.withModule}/${docs.files}`);
150
159
  for (const [t, v] of Object.entries(docs.perType)) console.log(` ${t.padEnd(10)} ${String(v.n).padStart(3)} 篇 / ${kb(v.b)}`);
151
160
  console.log(` 索引:workflow/INDEX.md ${kb(idxBytes)}(活跃 ${Number.isNaN(idxActive) ? '—' : idxActive} 行)`);
152
161
  for (const e of surf.entries) console.log(` ${e.label.padEnd(44)} ${String(e.max).padStart(6)} / ${String(e.lim).padStart(6)} B(${e.pct.toFixed(1)}%)`);
@@ -31,6 +31,7 @@ const USAGE = `用法: node .agents/scripts/kb-search.mjs [选项] <词1> [词2
31
31
  -n <N> 每语料展示文件数上限,默认 10;0 = 不限
32
32
  --include-generated wiki:纳入默认跳过的 json/csv、*.visual-check.* 与 >180KB bulk 文件(走纯扫,不吃缓存)
33
33
  --no-cache 禁用结构缓存,全量扫(与缓存热路径输出逐字节一致)
34
+ --cache <文件> 结构缓存文件路径覆盖(默认 .agents/cache/kb-index.json;测试注入用)
34
35
  --rebuild 忽略现有缓存重建后回写
35
36
  --help 打印本说明
36
37
 
@@ -47,6 +48,7 @@ let limit = 10;
47
48
  let limitRaw = '10';
48
49
  let includeGenerated = false;
49
50
  let noCache = false;
51
+ let cacheFileOverride = ''; // --cache <文件>(board-kb-p1):测试注入式缓存路径,不再直跑清真实开发缓存
50
52
  let rebuild = false;
51
53
  const words = [];
52
54
  for (let i = 0; i < args.length; i++) {
@@ -59,11 +61,16 @@ for (let i = 0; i < args.length; i++) {
59
61
  else if (a === '-n') { limitRaw = args[++i] ?? ''; limit = Number(limitRaw); }
60
62
  else if (a === '--include-generated') includeGenerated = true;
61
63
  else if (a === '--no-cache') noCache = true;
64
+ else if (a === '--cache') cacheFileOverride = args[++i] ?? '';
62
65
  else if (a === '--rebuild') rebuild = true;
63
66
  else if (a === '--help' || a === '-h') { console.log(USAGE); process.exit(0); }
64
67
  else words.push(a);
65
68
  }
66
69
  if (!words.length) { console.error(USAGE); process.exit(1); }
70
+ // 空 query 校验(board-kb-p1):空字符串词 includes('') 恒真 → 全量命中噪声(实测 109 文件命中)——参数错误
71
+ const qWords = words.map((w) => w.trim()).filter(Boolean);
72
+ if (!qWords.length) { console.error(`检索词不能为空(纯空白/空串)\n\n${USAGE}`); process.exit(1); }
73
+ words.length = 0; words.push(...qWords); // 原位替换,下游 searchWorkflow/searchWiki 传参不变
67
74
  if (!Number.isFinite(limit) || limit < 0) { console.error(`-n 须为非负整数(现 ${limitRaw})\n\n${USAGE}`); process.exit(1); }
68
75
  if (!scope) scope = topicFilter ? 'wiki' : 'all';
69
76
  if (!['workflow', 'wiki', 'all'].includes(scope)) { console.error(`--scope 须为 workflow|wiki|all(现 ${scope})\n\n${USAGE}`); process.exit(1); }
@@ -137,12 +144,23 @@ function matchWords(lines, words) {
137
144
  // ---- 结构缓存:可检索子集预提取,[mtimeMs,size] 两元组逐文件失效,指纹变即整份作废 ----
138
145
  // 键 = <检索根 cwd>::<相对路径>(多语料根共存一文件,fixture 临时目录天然隔离);读写任何异常静默回退全扫。
139
146
  const CACHE_DIR = path.join(SCRIPT_DIR, '..', 'cache');
140
- const CACHE_FILE = path.join(CACHE_DIR, 'kb-index.json');
147
+ const CACHE_FILE = cacheFileOverride || path.join(CACHE_DIR, 'kb-index.json');
141
148
  const ROOT_NS = `${path.resolve('.')}::`;
149
+ // WIKI_EXTS:wiki 语料扩展面(含 --include-generated 分支)——上移至 CFG_FP 之前(board-kb-p1 起纳入指纹派生)
150
+ const WIKI_EXTS = includeGenerated ? /\.(md|sql|html|csv|json|txt)$/i : /\.(md|sql|html|txt)$/i;
151
+
152
+ // 行序列化/复活(缓存 schema 的定义者——board-kb-p1 起纳入 CFG_FP 指纹:schema 演进即整份缓存作废)
153
+ const serializeLines = (lines, kind) => lines.map((x) => (kind === 'wf' ? [x.ln, x.section, x.line] : [x.ln, x.line]));
154
+ const reviveLines = (lines, kind) => lines.map((a) => (kind === 'wf' ? { ln: a[0], section: a[1], line: a[2] } : { ln: a[0], line: a[1] }));
155
+
156
+ // linesShapeOk:缓存条目 lines 形状校验(board-kb-p1)——JSON 合法但 schema 坏(lines 非数组 / 元素非数组)
157
+ // 一律视同 miss 走 build 重建(头注释「任何读写异常静默回退」契约恢复;此前直接 .map 抛 TypeError exit 1)
158
+ const linesShapeOk = (lines) => Array.isArray(lines) && (lines.length === 0 || lines.every(Array.isArray));
159
+
142
160
  const CFG_FP = createHash('sha256').update(JSON.stringify({
143
161
  v: 1, WF_SECTIONS, ACTIVE_STATUS, WIKI_MAX_KB, excl: [...WIKI_EXCLUDE],
144
- exts: ['md', 'sql', 'html', 'txt', 'gen:csv|json'],
145
- fns: [parseDoc.toString(), workflowLines.toString(), matchWords.toString()],
162
+ exts: WIKI_EXTS.source, // schema 派生(board-kb-p1:手抄摘要曾致扩展面演进不触发指纹失效;RegExp.source 跨 includeGenerated 分支稳定)
163
+ fns: [parseDoc.toString(), workflowLines.toString(), matchWords.toString(), serializeLines.toString(), reviveLines.toString()],
146
164
  })).digest('hex').slice(0, 16);
147
165
  const cacheUsable = !noCache && !includeGenerated;
148
166
  let cache = null;
@@ -155,9 +173,6 @@ if (cacheUsable) {
155
173
  if (rebuild) cache = {};
156
174
  }
157
175
 
158
- const serializeLines = (lines, kind) => lines.map((x) => (kind === 'wf' ? [x.ln, x.section, x.line] : [x.ln, x.line]));
159
- const reviveLines = (lines, kind) => lines.map((a) => (kind === 'wf' ? { ln: a[0], section: a[1], line: a[2] } : { ln: a[0], line: a[1] }));
160
-
161
176
  // 取一个源文件的缓存条目;miss 时 build()(读全文提取可检索子集)并登记回写。返回 {st, meta?, title?, lines}
162
177
  function cachedEntry(relPath, kind, build) {
163
178
  const abs = path.resolve(relPath);
@@ -170,7 +185,7 @@ function cachedEntry(relPath, kind, build) {
170
185
  const st = fs.statSync(abs);
171
186
  const sig = [st.mtimeMs, st.size];
172
187
  const e = cache[key];
173
- if (e && e.kind === kind && Array.isArray(e.st) && e.st[0] === sig[0] && e.st[1] === sig[1]) {
188
+ if (e && e.kind === kind && Array.isArray(e.st) && e.st[0] === sig[0] && e.st[1] === sig[1] && linesShapeOk(e.lines)) {
174
189
  touched.add(key);
175
190
  return { st: e.st, meta: e.meta, title: e.title, lines: reviveLines(e.lines, kind) };
176
191
  }
@@ -236,7 +251,6 @@ function searchWorkflow(words) {
236
251
  }
237
252
 
238
253
  // ---- wiki 检索(口径同 2026-09-21 降噪版 wiki-search)----
239
- const WIKI_EXTS = includeGenerated ? /\.(md|sql|html|csv|json|txt)$/i : /\.(md|sql|html|txt)$/i;
240
254
  const walkWiki = (dir, rel) => {
241
255
  const out = [];
242
256
  for (const e of fs.readdirSync(dir)) {
@@ -14,8 +14,10 @@ import { fileURLToPath } from 'node:url';
14
14
 
15
15
  const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
16
16
  const KB = path.join(SCRIPT_DIR, 'kb-search.mjs');
17
- const CACHE_FILE = path.join(SCRIPT_DIR, '..', 'cache', 'kb-index.json');
17
+ // 注入式缓存(board-kb-p1):--cache 指向 fixture 内路径——测试不再触碰真实 .agents/cache/kb-index.json
18
+ // (旧 CACHE_FILE 直跑会清空真实开发缓存并遗留孤儿条目,审查 P2-2)
18
19
  const USAGE = '用法:node .agents/scripts/kb-search.test.mjs';
20
+ const rootCache = (root) => path.join(root, 'kb-cache.json');
19
21
 
20
22
  let pass = 0;
21
23
  let fail = 0;
@@ -46,7 +48,7 @@ const mkfix = () => {
46
48
  return root;
47
49
  };
48
50
 
49
- const run = (root, args) => spawnSync(process.execPath, [KB, ...args], { cwd: root, encoding: 'utf8' });
51
+ const run = (root, args) => spawnSync(process.execPath, [KB, '--cache', rootCache(root), ...args], { cwd: root, encoding: 'utf8' });
50
52
  const files = (out) => (out.match(/^📄 (workflow\/\S+)/gm) || []).map((l) => l.replace('📄 ', ''));
51
53
 
52
54
  // ---- 场景 1:主路径——AND 语义 / 节限定 / 输出字段 / 活跃优先 ----
@@ -97,7 +99,7 @@ const files = (out) => (out.match(/^📄 (workflow\/\S+)/gm) || []).map((l) => l
97
99
  const cold = run(root, ['编码模板']); // 该临时根 ns 无条目 → cold
98
100
  const warm = run(root, ['编码模板']); // 全命中缓存
99
101
  const nocache = run(root, ['--no-cache', '编码模板']);
100
- check('场景 4:cold/warm exit 0 且 warm 确有缓存回写', cold.status === 0 && warm.status === 0 && fs.existsSync(CACHE_FILE), `cold=${cold.status} warm=${warm.status}`);
102
+ check('场景 4:cold/warm exit 0 且 warm 确有缓存回写', cold.status === 0 && warm.status === 0 && fs.existsSync(rootCache(root)), `cold=${cold.status} warm=${warm.status}`);
101
103
  check('场景 4:cold ≡ warm(逐字节)', cold.stdout === warm.stdout, `cold:\n${cold.stdout}\nwarm:\n${warm.stdout}`);
102
104
  check('场景 4:cold ≡ --no-cache(逐字节)', cold.stdout === nocache.stdout, `cold:\n${cold.stdout}\nnocache:\n${nocache.stdout}`);
103
105
  check('场景 4:双侧均有命中(workflow 3 + wiki 1)', files(cold.stdout).length === 3 && /wiki\/测试主题/.test(cold.stdout), cold.stdout);
@@ -121,14 +123,14 @@ const files = (out) => (out.match(/^📄 (workflow\/\S+)/gm) || []).map((l) => l
121
123
  {
122
124
  const root = mkfix();
123
125
  run(root, ['编码模板']); // 建缓存
124
- const j = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
126
+ const j = JSON.parse(fs.readFileSync(rootCache(root), 'utf8'));
125
127
  const fpBefore = j.fp;
126
128
  j.fp = 'ffffffffffffffff';
127
- fs.writeFileSync(CACHE_FILE, JSON.stringify(j), 'utf8');
129
+ fs.writeFileSync(rootCache(root), JSON.stringify(j), 'utf8');
128
130
  const after = run(root, ['编码模板']);
129
131
  const scan = run(root, ['--no-cache', '编码模板']);
130
132
  check('场景 6:指纹不符 → 输出仍与纯扫逐字节一致', after.status === 0 && after.stdout === scan.stdout, `after:\n${after.stdout}\nscan:\n${scan.stdout}`);
131
- const j2 = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
133
+ const j2 = JSON.parse(fs.readFileSync(rootCache(root), 'utf8'));
132
134
  check('场景 6:回写恢复正确指纹', j2.fp === fpBefore && j2.fp !== 'ffffffffffffffff', `fp=${j2.fp}`);
133
135
  fs.rmSync(root, { recursive: true, force: true });
134
136
  }
@@ -136,8 +138,8 @@ const files = (out) => (out.match(/^📄 (workflow\/\S+)/gm) || []).map((l) => l
136
138
  // ---- 场景 7:缓存损坏 → 静默回退全扫(不报错、输出与 --no-cache 一致) ----
137
139
  {
138
140
  const root = mkfix();
139
- fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
140
- fs.writeFileSync(CACHE_FILE, 'not-json{{{', 'utf8');
141
+ fs.mkdirSync(path.dirname(rootCache(root)), { recursive: true });
142
+ fs.writeFileSync(rootCache(root), 'not-json{{{', 'utf8');
141
143
  const r = run(root, ['编码模板']);
142
144
  const scan = run(root, ['--no-cache', '编码模板']);
143
145
  check('场景 7:损坏缓存 exit 0(stderr 无泄漏)', r.status === 0 && !r.stderr, `exit=${r.status}\n${r.stderr}`);
@@ -145,6 +147,34 @@ const files = (out) => (out.match(/^📄 (workflow\/\S+)/gm) || []).map((l) => l
145
147
  fs.rmSync(root, { recursive: true, force: true });
146
148
  }
147
149
 
150
+ // ---- 场景 8:结构损坏(JSON 合法但 lines 非数组 / 元素非数组)→ 静默回退(board-kb-p1 P1-1)----
151
+ // 此前 reviveLines 直接 .map 抛 TypeError exit 1,违背「任何读写异常静默回退」声明契约
152
+ {
153
+ for (const [tag, lines] of [['lines-字符串', 'corrupted'], ['lines-元素非数组', ['not-array']]]) {
154
+ const root = mkfix();
155
+ run(root, ['编码模板']); // 建缓存
156
+ const j = JSON.parse(fs.readFileSync(rootCache(root), 'utf8'));
157
+ const key = Object.keys(j.files).find((k) => k.endsWith('2026-09-01-aaa.md'));
158
+ j.files[key].lines = lines;
159
+ fs.writeFileSync(rootCache(root), JSON.stringify(j), 'utf8');
160
+ const r = run(root, ['编码模板']);
161
+ const scan = run(root, ['--no-cache', '编码模板']);
162
+ check(`场景 8:结构损坏(${tag})exit 0 静默回退`, r.status === 0 && !r.stderr, `exit=${r.status}\n${r.stderr}`);
163
+ check(`场景 8:结构损坏(${tag})输出与纯扫一致`, r.stdout === scan.stdout, `r:\n${r.stdout}\nscan:\n${scan.stdout}`);
164
+ fs.rmSync(root, { recursive: true, force: true });
165
+ }
166
+ }
167
+
168
+ // ---- 场景 9:空 query(空串 / 纯空白)→ 参数错误 exit 1(board-kb-p1:includes('') 恒真曾全量命中)----
169
+ {
170
+ const root = mkfix();
171
+ const r1 = run(root, ['']);
172
+ const r2 = run(root, [' ', ' ']);
173
+ check('场景 9:空串 query exit 1', r1.status === 1 && /检索词不能为空/.test(r1.stderr), `exit=${r1.status}\n${r1.stderr}`);
174
+ check('场景 9:纯空白 query exit 1', r2.status === 1 && /检索词不能为空/.test(r2.stderr), `exit=${r2.status}\n${r2.stderr}`);
175
+ fs.rmSync(root, { recursive: true, force: true });
176
+ }
177
+
148
178
  console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
149
179
  if (fail) {
150
180
  console.log(`\n${USAGE}`);
@@ -5,8 +5,10 @@
5
5
  // 排除装副本独有文件:kit.json / settings.json / hooks/commit-check.config.json(init 渲染产物,不属双源)
6
6
  // 用法:node .agents/scripts/source-sync-check.mjs --diff
7
7
  // node .agents/scripts/source-sync-check.mjs --json
8
+ // node .agents/scripts/source-sync-check.mjs --gate(门禁模式:缺失/漂移 exit 1,孤儿只报告不计失败
9
+ // ——pre-commit 双源一致性门禁用,2026-09-27 audit-gate-hardening;--diff/--json 仍恒 exit 0)
8
10
  // node .agents/scripts/source-sync-check.mjs --pkg-root <path> --target <path>
9
- // 零依赖;B-b 决策「只报告不修复」
11
+ // 零依赖;B-b 决策「只报告不修复」(--gate 只加 exit 码裁决,不改默认行为)
10
12
  import fs from 'node:fs';
11
13
  import path from 'node:path';
12
14
  import { createHash } from 'node:crypto';
@@ -20,7 +22,8 @@ const TARGET_EXCLUDE = RENDER_OUTPUT_FILES; // 向后兼容别名
20
22
 
21
23
  function sha256(p) {
22
24
  try {
23
- return createHash('sha256').update(fs.readFileSync(p)).digest('hex');
25
+ // LF 归一(2026-09-27 closing-coverage):CRLF 盘面与 LF 克隆同值,行尾不一致不误报缺失/漂移/孤儿外差异
26
+ return createHash('sha256').update(fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n'), 'utf8').digest('hex');
24
27
  } catch {
25
28
  return null;
26
29
  }
@@ -98,12 +101,13 @@ function printDiff(result) {
98
101
 
99
102
  const isMain = process.argv[1] && process.argv[1].endsWith('source-sync-check.mjs');
100
103
  if (isMain) {
101
- let jsonMode = false, diffMode = false;
104
+ let jsonMode = false, diffMode = false, gateMode = false;
102
105
  let pkgRoot = null, target = null;
103
106
  for (let i = 0; i < process.argv.length - 2; i++) {
104
107
  const a = process.argv[2 + i];
105
108
  if (a === '--json') jsonMode = true;
106
109
  else if (a === '--diff') diffMode = true;
110
+ else if (a === '--gate') gateMode = true;
107
111
  else if (a === '--pkg-root') pkgRoot = process.argv[2 + i + 1];
108
112
  else if (a === '--target') target = process.argv[2 + i + 1];
109
113
  }
@@ -125,6 +129,16 @@ if (isMain) {
125
129
  } else {
126
130
  printDiff(result);
127
131
  }
132
+ if (gateMode) {
133
+ // 门禁模式(pre-commit 双源一致性用):缺失/漂移非零阻断——正是「包源改了装副本没跟」的故障模式
134
+ // (doctor §4 台账 sha 对此静默:盘面=台账即 PASS;§4.5 只抓未登记)。孤儿只报告不计失败:
135
+ // 孤儿 = 装副本独有(装户自持内容如本地 skills),删留由人拍板,不入机器门。
136
+ if (result.missing.length || result.drift.length) {
137
+ console.error('❌ 双源不一致(缺失 ' + result.missing.length + ' / 漂移 ' + result.drift.length + ')——包源与装副本须同提交成对(双源纪律);跑 node bin/flow-kit.mjs sync 后原路重试(孤儿只报告不计失败)');
138
+ process.exit(1);
139
+ }
140
+ process.exit(0);
141
+ }
128
142
  // diff/json 模式总 exit 0;差异存在不视作错误(人类阅读用;B-b 决策「只报告不修复」)
129
143
  process.exit(0);
130
144
  }