agentic-flow-kit 0.5.0 → 0.7.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 (85) 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/profiles.mjs +19 -2
  43. package/src/render.mjs +64 -4
  44. package/src/run-tests.mjs +3 -0
  45. package/src/sync-hosts.mjs +3 -2
  46. package/src/sync.mjs +64 -20
  47. package/src/sync.test.mjs +42 -0
  48. package/src/trae-hooks.test.mjs +115 -0
  49. package/templates/AGENTS.md +1 -0
  50. package/templates/_agents/board/index.html +32 -3
  51. package/templates/_agents/commands/build.md +5 -4
  52. package/templates/_agents/commands/design.md +1 -0
  53. package/templates/_agents/commands/maintain.md +1 -2
  54. package/templates/_agents/commands/plan.md +2 -0
  55. package/templates/_agents/commands/test.md +2 -1
  56. package/templates/_agents/hooks/check-pairing-incremental.sh +20 -7
  57. package/templates/_agents/metric-claims.txt +81 -0
  58. package/templates/_agents/roles/implementer.md +2 -0
  59. package/templates/_agents/roles/independent-reviewer.md +2 -0
  60. package/templates/_agents/roles/ui-verifier.md +2 -0
  61. package/templates/_agents/scripts/agg-delegations.cjs +39 -30
  62. package/templates/_agents/scripts/agg-delegations.test.mjs +32 -0
  63. package/templates/_agents/scripts/check-loop.mjs +538 -16
  64. package/templates/_agents/scripts/check-loop.test.mjs +1116 -0
  65. package/templates/_agents/scripts/confirm-doc.mjs +228 -0
  66. package/templates/_agents/scripts/confirm-doc.test.mjs +321 -0
  67. package/templates/_agents/scripts/doctor.test.mjs +81 -3
  68. package/templates/_agents/scripts/ensure-board.mjs +29 -2
  69. package/templates/_agents/scripts/gate-checklist.mjs +3 -0
  70. package/templates/_agents/scripts/gate-checklist.test.mjs +3 -3
  71. package/templates/_agents/scripts/gen-wiki-board.mjs +38 -24
  72. package/templates/_agents/scripts/gen-wiki-board.test.mjs +18 -0
  73. package/templates/_agents/scripts/gen-workflow-metrics.mjs +31 -3
  74. package/templates/_agents/scripts/kb-search.mjs +22 -8
  75. package/templates/_agents/scripts/kb-search.test.mjs +38 -8
  76. package/templates/_agents/scripts/source-sync-check.mjs +30 -5
  77. package/templates/_agents/scripts/source-sync-check.test.mjs +133 -8
  78. package/templates/_agents/scripts/verify-wiki-consistency.mjs +46 -23
  79. package/templates/_agents/scripts/workflow-board-server.mjs +64 -9
  80. package/templates/_agents/scripts/workflow-board-server.test.mjs +136 -0
  81. package/templates/_agents/scripts/workflow-enums.mjs +4 -2
  82. package/templates/_agents/scripts/workflows-check.test.mjs +14 -3
  83. package/templates/_agents/workflow-enums.txt +3 -3
  84. package/templates/_agents/workflows/_TEMPLATE.md +1 -0
  85. package/templates/_githooks/pre-commit +16 -1
@@ -14,13 +14,18 @@
14
14
  //
15
15
  // 检查项清单(编号/标题/severity 逐条沿 sh 版头部——gate-checklist 配对登记表按 id 消费,不得增删改号):
16
16
  // 1. 入口文档/spec/plan 同名配对(L1 必须有 plan;L2/L3 必须有 spec+plan) [hard-block]
17
- // 2. 模板字段占位符残留(YYYY-MM-DD / <主题> 等未替换) [warning]
17
+ // 2. 模板字段占位符残留(YYYY-MM-DD / <主题> 等未替换;<主题> 与 .md 同行 = 命名约定描述,豁免) [warning]
18
18
  // 3. incidents 复盘三件套完整性 + 状态严格枚举 + 新 intent 回路(回路断档=hard,其他=warning)
19
- // 4. 引用有效性(文档/指令中引用的 .agents/ 路径必须存在) [warning]
19
+ // 4. 引用有效性(文档/指令中引用的 .agents/ 路径必须存在;支持 fill-{a,b,c}.mjs 花括号展开与 fill-*.mjs 通配;
20
+ // workflow 文档仅扫活跃态——终态件的引用是历史叙述,不扫,2026-09-27 audit-gate-hardening) [warning]
20
21
  // 5. intent/spec/plan 状态字段 + L3 独立复核 [warning]
21
22
  // 6. 子智能体角色契约 + OpenCode/Trae/ZCode Adapter 一致性(含旧委派残留/钉死模型) [warning]
22
23
  // 7. 阶段索引同步(AGENTS.md 与 new-task.md 须双向索引全部阶段指令) [warning]
23
- // 8. intent 验收标准对账(2026-09-12 起新建:done 未勾验/缺节=hard,勾选缺证据=warning;存量聚合 warning,含「存量对账豁免」声明者出账)
24
+ // 8. intent 验收标准对账(新建:done 未勾验/缺节=hard,勾选缺证据=warning;存量聚合 warning,含「存量对账豁免」声明者出账)
25
+ // **生效日锚=git 首次加入日期(2026-09-28 改;此前取文件名前 10 字符——命名规范强制的字段、
26
+ // 写早零成本,一条 hard 门可被平凡绕过;incident 2026-09-28-check8-git-anchor 实证)**:
27
+ // 非 git / 查不到加入记录 → 不可判定 → 走存量口径(不误报 hard)
28
+ // 证据可写在 [x] 行的续行(仓库通写法「(证据:…)」另起一行;全/半角冒号皆认)
24
29
  // 9. 文件名英文 kebab-case(非 ASCII 文件名=warning,2026-09-11 规则)
25
30
  // 10. 级别 vs 迁移文件一致性(L1/L2 入口文档加入提交触及迁移 SQL/Migrations=疑似判低,warning)
26
31
  // 11. workflow/INDEX.md 漂移(活跃层索引与磁盘不一致=warning,2026-09-21 检索层;调生成器 --check,口径单一)
@@ -28,6 +33,46 @@
28
33
  // 13. 常驻面体积预算(超限=warning;判定单源 rule-budget.sh——经 sh 调用,
29
34
  // 无 sh 环境静默跳过:advisory 级且 pre-commit 侧在 git 钩子 sh 环境照常硬拦)
30
35
  // 14. 新 done 的 spec/plan 须在 git 历史里出现过 `状态: approved`(确认环节留痕,2026-09-22;恒 advisory 永不升级 hard)
36
+ // 15. 确认指纹对账(2026-09-27 起:approved/done 须 confirm-doc.mjs 确认指纹+台账配对,缺=hard-block;
37
+ // 两形态——TTY 亲手 / --delegated 对话委托代录,台账 source 如实区分,配对判据与 source 无关)
38
+ // **受管准入 = 两条件取或(2026-09-28 改锚;此前仅「自报日期 ≥ 生效日」单条件——
39
+ // 新档只要把日期写早即整段跳过判定,incident 2026-09-28-confirm-gate-effective-date-anchor 实证)**:
40
+ // ① 该 doc 在台账中有合法跳转行(stage∈approved/done/fixed/closed/superseded/cancelled,append-only
41
+ // 取末次)——「确认确实发生过」的机器事实,作者**不可顺手填**(经 confirm-doc 走确认门才产生);
42
+ // **诚实边界**:台账是本地可写文件、本检查不验签,刻意手改台账仍可伪造——与「伪造 git 时间戳」
43
+ // 同属本地信任边界内不可机器防,事后对质靠 git 历史与台账(2026-09-28-claim-exceeds-fix 口径统一);
44
+ // ② 自报日期(日期/发现/文件名兜底)≥ 生效日——覆盖「新档完全没跑 confirm-doc」的漏网面
45
+ // (此类文档无台账行,仅靠 ① 会误判为存量豁免)。
46
+ // 两条件皆不满足 = 存量豁免(生效日前既有、从未走过确认门,不追溯)。
47
+ // 改锚收益:把「确认过却把日期写早以逃掉对账」这条攻破路径堵死(条件 ① 独立于自报日期成立)。
48
+ // **注意条件②的日期阈值仍分档、且不等价**:docs 2026-09-27 / incidents 2026-09-28——本仓现役
49
+ // incident 的「发现」均 ≤2026-09-27,故该档差异当前不显;新 incident 若把「发现」写成 09-27 且
50
+ // 无台账行,会因 incidents 阈值(09-28)被豁免,而同日期文档在 docs 档则受管。
51
+ // (复核 P2-1 更正:此前本注释误称「incidents 常量退役 / 两档等价」,与代码 :eff 实际保留分档不符;
52
+ // 台账锚两档共用为真,条件②阈值分档亦为真,二者不是一回事——README 遣词为准确口径)
53
+ // + incidents 覆盖(2026-09-27 gate-coverage:fixed/closed 须配对;open=起草态不加门;
54
+ // 不支持 open→closed 单跳——confirm-doc 状态机强制经 fixed)
55
+ // + 放弃态覆盖(2026-09-27 closing-coverage:superseded/cancelled 入配对集,confirm-doc --to 唯一通道)
56
+ // + 四终态内容绑定(2026-09-27 audit-gate-hardening;生效锚=台账行 ts≥2026-09-28——同日 p2-batch
57
+ // 自文档自报日期改锚:旧日期文档晚关单也绑定,3 份失配存量 ts 均 09-27 天然豁免):
58
+ // 按 prev 复原跳转前文本重算 sha256 与台账全量比对,不符=hard「确认内容漂移」;状态行保分隔符换值
59
+ // (非规范格式不误伤);台账行缺 prev 降级 warning;关单编辑顺序新约定:勾验/回填先于关单确认,
60
+ // confirm-doc 是最后一次写入)
61
+ // + 并录批次审计子检查(2026-09-28 batch-ledger-audit 改判据;**读调用事实,不猜时间戳模式**):
62
+ // 判据 = 台账行按 batch 分组、组内 of>1(delegated 行) → warning「确认并录」;batch/seq/of 由
63
+ // confirm-doc 写入时记录(那才是"本次调用落几份"确定已知的时刻)。旧判据「同 quote + 相邻 ts<2s」
64
+ // 整段退役——已实证四类失效:假阳性(合规逐件复用同句必然误报)/假阴性(换 quote 即零告警,
65
+ // 等于引导伪装)/噪声不可消退(不按日期门豁免,退役前本仓 22 条 advisory 中 14 条为该告警)/
66
+ // quote 字段职责冲突(对质凭据 vs 并录指纹,现归还单一职责)。无 batch 的历史行静默跳过
67
+ // (无判定依据的行不产出不可消除噪声——沿 audit-gate-hardening P3 教训)
68
+ // 16. 量化断言指标签名对账 [warning](2026-09-28 起;登记表单源 .agents/metric-claims.txt):
69
+ // 判据 = 活跃态文档(draft/approved/open)中的 `{{指标名}}` 签名须替换为实时值,留签名=未回填=warning。
70
+ // **只查显式签名、不全文扫数字**(据实说明):本仓活跃文档「N 行/N 条/N 份」类表述数十处,绝大多数是
71
+ // 历史叙述(描述某次提交当时的规模,天然不随当前事实变化)——全文扫描对这些正确表述产生大量假阳性,
72
+ // 而本脚本已立红线「无判定依据的行不产出不可消除噪声」(P3:噪声淹没真漏点)。故本检查**假阳性恒 0**,
73
+ // 代价是覆盖面依赖作者登记。签名收窄为小写点分形态——装户模板占位符走全大写 SCREAMING_CASE
74
+ // (由 init 渲染替换,如构建/端口/项目名三类,不属本检查面)。未登记签名 / 取数器缺失 → fail-loud 出账
75
+ // (静默会让「登记了却没查」不可见);登记表不存在 → 静默跳过(未启用该检查的装户不应被噪声打扰)。
31
76
  //
32
77
  // 注:清单条目 5(状态字段+L3 复核)与 1(配对)在同一遍 intents/specs/plans 循环里实现(沿 sh 版代码结构);
33
78
  // 条目 6 的旧委派残留/钉死模型子项在「角色契约与 Adapter」代码段实现。
@@ -44,6 +89,8 @@
44
89
  import fs from 'node:fs';
45
90
  import path from 'node:path';
46
91
  import { spawnSync } from 'node:child_process';
92
+ import { createHash } from 'node:crypto';
93
+ import { createRequire } from 'node:module';
47
94
  import { fileURLToPath } from 'node:url';
48
95
  import { loadEnums } from './workflow-enums.mjs';
49
96
 
@@ -190,12 +237,16 @@ for (const plan of docFiles('plans')) {
190
237
  {
191
238
  const phRe = /YYYY-MM-DD|<主题>|<日期 主题>|L0 \/ L1 \/ L2 \/ L3|draft \/ approved \/ done|open \/ fixed \/ closed/;
192
239
  const boilerRe = /\.\.\/specs\/[A-Za-z0-9-]*\.md|写明如何满足|防复发验证|_YYYY-MM-DD\.|format\('YYYY-MM-DD'\)|value-format="YYYY-MM-DD"/;
240
+ // 命名约定豁免:`<主题>` 与 `.md` 同行 = 在描述文件命名规则(如 `.agents/workflows/<主题>.md`、
241
+ // 「复制本模板为 YYYY-MM-DD-<主题>.md」),非未填占位符;真未填的占位(标题 `# INTENT — <主题>`、
242
+ // `日期: YYYY-MM-DD`)不含 .md,仍照拦(2026-09-25-wf-runtime incident 记录的误报口径)
243
+ const isNamingConv = (line) => line.includes('<主题>') && /\.md/.test(line);
193
244
  const groups = new Map(); // file -> [ "行号:内容" ](首现序)
194
245
  for (const sub of DOC_DIRS) {
195
246
  for (const f of docFiles(sub)) {
196
247
  const hits = [];
197
248
  (linesOf(f) || []).forEach((line, i) => {
198
- if (phRe.test(line) && !boilerRe.test(line)) hits.push(`${i + 1}:${line}`);
249
+ if (phRe.test(line) && !boilerRe.test(line) && !isNamingConv(line)) hits.push(`${i + 1}:${line}`);
199
250
  });
200
251
  if (hits.length) groups.set(f, hits);
201
252
  }
@@ -251,7 +302,23 @@ for (const inc of docFiles('incidents')) {
251
302
 
252
303
  // --- 4. 引用有效性 [warning](.agents/ 路径须存在;skills 特例允许用户级 ~/.agents/skills)---
253
304
  {
254
- const refRe = /\.agents\/(commands|hooks|scripts|skills|roles)\/[A-Za-z0-9_][A-Za-z0-9_./-]*/g;
305
+ // 引用可带 {a,b,c} 花括号展开(仓库通写法 `fill-{intent,spec,plan}.mjs`)或 `*` 通配(`fill-*.mjs`)——
306
+ // 花括号展开逐路查存在,任一路存在即有效;`*` 按目录枚举 glob 匹配,命中即有效;全不中才算断档
307
+ // (2026-09-26 审查修复:正则字符类不含 {} *,曾把 `fill-{intent,spec,plan}.mjs` / `fill-*.mjs`
308
+ // 截断成 `fill-` 误报引用断档)
309
+ const refRe = /\.agents\/(commands|hooks|scripts|skills|roles)\/[A-Za-z0-9_][A-Za-z0-9_./{},,*-]*/g;
310
+ const expandRef = (ref) => {
311
+ const m = ref.match(/^([^{]*)\{([^}]*)\}(.*)$/);
312
+ if (!m) return [ref];
313
+ return m[2].split(/[,,]/).map((v) => m[1] + v.trim() + m[3]);
314
+ };
315
+ // globExists:`*` 通配引用按目录枚举匹配(仅文件名层,`*` 不跨 /;目录不存在 = false)
316
+ const globExists = (ref) => {
317
+ const re = new RegExp('^' + path.basename(ref).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/\\\\]*') + '$');
318
+ try { return fs.readdirSync(path.dirname(path.join(ROOT, ref))).some((f) => re.test(f)); } catch { return false; }
319
+ };
320
+ const refExists = (r) => (r.includes('*') ? globExists(r) : fs.existsSync(path.join(ROOT, r)));
321
+ const HOME_DIR = process.env.HOME || process.env.USERPROFILE || '';
255
322
  const files = [];
256
323
  if (fs.existsSync(path.join(ROOT, 'AGENTS.md'))) files.push('AGENTS.md');
257
324
  for (const e of readdirOrNull(ROOT) || []) {
@@ -263,8 +330,13 @@ for (const inc of docFiles('incidents')) {
263
330
  }
264
331
  for (const sub of DOC_DIRS) {
265
332
  const dir = path.join(ROOT, WF, sub);
333
+ // 仅扫活跃态(draft/approved/open)——终态件的引用是历史叙述(提及已改名/已废文件属常态),
334
+ // 扫了只会产生不可消除的 advisory 噪声,真漏点被淹没(2026-09-27 audit-gate-hardening)
335
+ const activeSet = sub === 'incidents' ? ENUMS['incident.status.active'] : ENUMS['doc.status.active'];
266
336
  for (const f of readdirOrNull(dir) || []) {
267
- if (f.endsWith('.md') && (f === '_TEMPLATE.md' || /^\d/.test(f))) files.push(`${WF}/${sub}/${f}`);
337
+ if (!f.endsWith('.md') || !(f === '_TEMPLATE.md' || /^\d/.test(f))) continue;
338
+ if (f !== '_TEMPLATE.md' && !inSet(fmGet(path.join(dir, f), '状态'), activeSet)) continue;
339
+ files.push(`${WF}/${sub}/${f}`);
268
340
  }
269
341
  }
270
342
  const cmdDir = path.join(ROOT, '.agents', 'commands');
@@ -280,8 +352,9 @@ for (const inc of docFiles('incidents')) {
280
352
  const ref = m[0];
281
353
  if (seen.has(ref)) continue;
282
354
  seen.add(ref);
283
- if (fs.existsSync(path.join(ROOT, ref))) continue;
284
- if (ref.startsWith('.agents/skills/') && fs.existsSync(path.join(process.env.HOME || process.env.USERPROFILE || '', ref))) continue;
355
+ const refs = expandRef(ref);
356
+ if (refs.some(refExists)) continue;
357
+ if (refs.some((r) => r.startsWith('.agents/skills/') && !r.includes('*') && fs.existsSync(path.join(HOME_DIR, r)))) continue;
285
358
  warnings.push(`- [WARN 引用断档] ${rel.split(path.sep).join('/')} 引用不存在的文件:${ref}`);
286
359
  }
287
360
  }
@@ -351,7 +424,41 @@ for (const cmd of ['plan', 'design', 'build', 'test', 'deploy', 'maintain', 'rev
351
424
  }
352
425
  }
353
426
 
354
- // --- 8. intent 验收标准对账(done 须逐条勾验并补证据;2026-09-12 起新建 hard,存量聚合 warning)---
427
+ // --- 共享:文件「首次加入 git」的日期索引(2026-09-28 check8-git-anchor)---
428
+ // 用途:检查 8 的生效日锚从**可手填的文件名前缀**(项目命名规范本身即 `YYYY-MM-DD-<主题>`,作者
429
+ // 日常手写该前缀,「写早」零成本、顺手即发生——一条 hard 门可被平凡绕过)改为**首次加入 git 的日期**。
430
+ // **诚实边界(复核 P1 更正)**:该锚取 `git log --diff-filter=A --format=@%aI`,即 **author date
431
+ // (作者自报时间戳)**,**不是** commit 时刻——`git commit --date=<过去>` 一条参数即可伪造,故
432
+ // 「锚不可手填」的说法**不成立**。本改动的确切收益是:关掉「改文件名(日常必写、零成本)」这条通道,
433
+ // 把伪造成本抬到「主动加参数伪造时间戳」——后者与「伪造台账」同属**本地信任边界内不可机器防**的范畴
434
+ // (事后对质靠 git 历史与台账)。此处如实声明,不宣称「通道已关闭」。
435
+ // 口径:git 按时间新→旧遍历,同一文件首遇即最早那次加入(与检查 10 的 addpath 同款判据)。
436
+ // 非 git / 命令失败 → null,调用方按既有「非 git 跳过」语义退化(不 fail-loud)。
437
+ // 注:检查 10 需要的是「文件→加入 commit」+「commit→文件清单」两份映射(判断同提交是否触及迁移 SQL),
438
+ // 与本索引「文件→日期」不同构,故**不合并**——避免把两处判据耦死。
439
+ const addedDates = (() => {
440
+ if (gitOut(['rev-parse', '--git-dir']) === null) return null;
441
+ const log = gitOut(['log', '--diff-filter=A', '--format=@%aI', '--name-only']);
442
+ if (log === null) return null;
443
+ const m = new Map();
444
+ let cur = null;
445
+ for (const line of log.split('\n')) {
446
+ if (line.startsWith('@')) { cur = line.slice(1); continue; }
447
+ if (line.trim() && cur && !m.has(line.trim())) m.set(line.trim(), cur); // 新→旧序首遇 = 最早
448
+ }
449
+ return m;
450
+ })();
451
+ // 某文档「首次加入日期」(YYYY-MM-DD);不可判定(非 git / 查不到)返回 ''
452
+ const addedDateOf = (absDoc) => {
453
+ if (!addedDates) return '';
454
+ const rel = path.relative(ROOT, absDoc).split(path.sep).join('/');
455
+ const iso = addedDates.get(rel);
456
+ return iso ? iso.slice(0, 10) : '';
457
+ };
458
+
459
+ // --- 8. intent 验收标准对账(done 须逐条勾验并补证据;新建 hard,存量聚合 warning)---
460
+ // 生效日锚 2026-09-28 改「git 首次加入日期」(此前取文件名前 10 字符——命名规范强制的字段、
461
+ // 写早零成本,见 incidents/2026-09-28-check8-git-anchor)。非 git → 不可判定 → 走存量口径(不误报 hard)。
355
462
  {
356
463
  const accCutoff = '2026-09-12';
357
464
  let legacyUnaccounted = 0;
@@ -360,17 +467,29 @@ for (const cmd of ['plan', 'design', 'build', 'test', 'deploy', 'maintain', 'rev
360
467
  const base = path.basename(intent);
361
468
  const lines = linesOf(intent) || [];
362
469
  // 节扫描:hs=有验收节;insec 至下一个二级标题;uc=未勾项;ne=勾选缺证据;ex=存量豁免声明
470
+ // 证据可写在 [x] 行的续行(仓库通写法「(证据:…)」另起一行)——pendX 记「待证据项」,
471
+ // 遇证据行(全角「:」/半角「:」皆认)清零;遇下一条目 / 下一小节 / 节末仍无证据 → ne
363
472
  let hs = false, uc = false, ne = false;
364
473
  let insec = false, ex = false;
474
+ let pendX = false; // 上一 [x] 项尚无证据,证据可能在紧随的续行
475
+ const evRe = /证据[::]/;
476
+ const closeItem = () => { if (pendX) { ne = true; pendX = false; } };
365
477
  for (const line of lines) {
366
478
  if (/存量对账豁免(/.test(line)) ex = true;
367
- if (/^\s*##\s+[^#]*验收标准/.test(line)) { hs = true; insec = true; continue; }
368
- if (insec && /^\s*##\s/.test(line)) insec = false;
369
- if (insec && /^\s*- \[ \]/.test(line)) uc = true;
370
- if (insec && /^\s*- \[x\]/.test(line) && !/证据:/.test(line)) ne = true;
479
+ if (/^\s*##\s+[^#]*验收标准/.test(line)) { hs = true; insec = true; closeItem(); continue; }
480
+ if (insec && /^\s*##\s/.test(line)) { insec = false; closeItem(); continue; }
481
+ if (!insec) continue;
482
+ if (/^\s*- \[ \]/.test(line)) { closeItem(); uc = true; continue; }
483
+ if (/^\s*- \[x\]/.test(line)) { closeItem(); pendX = !evRe.test(line); continue; }
484
+ if (pendX && evRe.test(line)) pendX = false; // 续行补上证据
371
485
  }
372
- const filedate = /^\d{4}-\d{2}-\d{2}$/.test(base.slice(0, 10)) ? base.slice(0, 10) : '';
373
- const isNew = filedate !== '' && filedate >= accCutoff;
486
+ closeItem(); // 节末(或全文末)仍无证据 → 计缺证据
487
+ // 生效日锚 = git 首次加入日期(2026-09-28 改;此前取文件名前 10 字符)。**准确收益**:关掉
488
+ // 「改文件名日期(日常必写、零成本)即整段跳过 hard 门」这条通道;锚取 author date(%aI),
489
+ // 刻意伪造(`git commit --date=`)仍可绕过——与伪造台账同属本地信任边界内,不宣称"通道已关闭"。
490
+ // 非 git / 查不到 → '' → isNew=false → 走存量口径(与既有语义一致:不可判定时不误报 hard)。
491
+ const adddate = addedDateOf(intent);
492
+ const isNew = adddate !== '' && adddate >= accCutoff;
374
493
  if (!hs) {
375
494
  if (isNew) blockers.push(`- [验收未对账] done intent 缺「## 验收标准」节(勾验无从核对):${base}`);
376
495
  else if (!ex) legacyUnaccounted++;
@@ -385,7 +504,7 @@ for (const cmd of ['plan', 'design', 'build', 'test', 'deploy', 'maintain', 'rev
385
504
  }
386
505
  }
387
506
  if (legacyUnaccounted > 0) {
388
- warnings.push(`- [WARN 验收对账存量] ${legacyUnaccounted} 个存量 done intent 验收标准未对账(2026-09-12 前创建,豁免 hard,不回填)`);
507
+ warnings.push(`- [WARN 验收对账存量] ${legacyUnaccounted} 个存量 done intent 验收标准未对账(生效日锚之前加入仓库,豁免 hard,不回填)`);
389
508
  }
390
509
  }
391
510
 
@@ -498,6 +617,386 @@ if (gitOut(['rev-parse', '--git-dir']) !== null && gitOut(['rev-parse', '-q', '-
498
617
  }
499
618
  }
500
619
 
620
+ // --- 15. 确认指纹对账 [hard-block](2026-09-26 confirm-gate-machine;2026-09-27 confirm-gate-delegated 两形态;
621
+ // 生效日锚 2026-09-28 改台账 ts——此前锚取自文档自报「日期/发现」,新档只要把日期写早即整段跳过判定,
622
+ // incident 2026-09-28-confirm-gate-effective-date-anchor 实证)---
623
+ // 确认两形态(confirm-doc.mjs):TTY 亲手键入「可以」/ --delegated 对话委托代录(用户对话内明确放行后
624
+ // AI 代录,台账行如实记 source=chat-delegated + quote 原话,永不伪装 TTY)。intents/specs/plans 凡
625
+ // approved/done 须有 confirm-doc 产生的 frontmatter 确认指纹 + 台账(.agents/confirmations.jsonl)
626
+ // 配对行——配对判据 doc/stage/fingerprint 三键,与 source 无关。
627
+ // **生效日锚 = 台账 ts(2026-09-28 起)**:受管准入 = 该 doc 在台账中有合法跳转行(stage ∈ approved/done/
628
+ // fixed/closed/superseded/cancelled,append-only 取末次);无台账行 = 存量豁免(不追溯)。自报日期
629
+ // (日期/发现/文件名)不再参与门禁判定——它可被作者手填,把「门是否生效」的决定权交给被约束对象本身
630
+ // 是自举漏洞;台账行是「确认确实发生过」的唯一机器事实。docs 与 incidents 共用同一锚(原 incidents
631
+ // 独立生效日 2026-09-28 常量退役:台账起算日 = docs 生效日,两档等价)。
632
+ // 台账坏行容忍跳过(审计件,jsonl 追加式);frontmatter 存 16 位、台账存 64 位,按前 16 位配对。
633
+ {
634
+ const EFFECTIVE = '2026-09-27';
635
+ const ledgerPath = path.join(ROOT, '.agents', 'confirmations.jsonl');
636
+ const ledger = [];
637
+ if (fs.existsSync(ledgerPath)) {
638
+ for (const line of linesOf(ledgerPath) || []) {
639
+ if (!line.trim()) continue;
640
+ try { ledger.push(JSON.parse(line)); } catch { /* 坏行跳过 */ }
641
+ }
642
+ }
643
+ // incidents 侧覆盖(2026-09-27 gate-coverage):fixed/closed 为已确认态(open = 起草态不加门——
644
+ // maintain.md 创建即对话确认的既有口径)
645
+ // 放弃态覆盖(2026-09-27 closing-coverage):superseded/cancelled 入配对集——confirm-doc --to 是唯一
646
+ // 合法产生通道(cancelled 自 draft/approved/open/fixed;superseded 自 approved/done/fixed/closed),
647
+ // 此前手改 frontmatter 即出账的口子收死
648
+ const CONFIRMED_BY_SUB = {
649
+ intents: ['approved', 'done', 'superseded', 'cancelled'],
650
+ specs: ['approved', 'done', 'superseded', 'cancelled'],
651
+ plans: ['approved', 'done', 'superseded', 'cancelled'],
652
+ incidents: ['fixed', 'closed', 'superseded', 'cancelled'],
653
+ };
654
+ // 合法跳转 stage 集(台账行「确认确实发生过」的判据;revert-draft / revert-open 类回退注记行不算)
655
+ const VALID_STAGES = new Set(['approved', 'done', 'fixed', 'closed', 'superseded', 'cancelled']);
656
+ for (const [sub, confirmedSet] of Object.entries(CONFIRMED_BY_SUB)) {
657
+ for (const doc of docFiles(sub)) {
658
+ const st = fmGet(doc, '状态');
659
+ if (!confirmedSet.includes(st)) continue;
660
+ const base = path.basename(doc);
661
+ const rel = path.relative(ROOT, doc).split(path.sep).join('/');
662
+ // 受管准入 = 两条件取或(2026-09-28 改锚):
663
+ // ① 台账中有该 doc 的合法跳转行——「确认确实发生过」的机器事实;它独立于自报日期成立,
664
+ // 故堵死「确认过却把日期写早以逃掉对账」这条攻破路径(本 incident 的核心缺陷);
665
+ // ② 自报日期 ≥ 生效日——覆盖「新档完全没跑 confirm-doc」的漏网面(此类无台账行,
666
+ // 仅靠 ① 会被误判为存量豁免而静默放行)。
667
+ // 两条件皆不满足 = 存量豁免(生效日前既有、从未走确认门,不追溯)。
668
+ const docEntries = ledger.filter((e) => e && e.doc === rel && VALID_STAGES.has(e.stage));
669
+ const eff = sub === 'incidents' ? '2026-09-28' : EFFECTIVE;
670
+ let d = fmGet(doc, '日期') || fmGet(doc, '发现');
671
+ if (!d) d = /^\d{4}-\d{2}-\d{2}$/.test(base.slice(0, 10)) ? base.slice(0, 10) : '';
672
+ const dateManaged = /^\d{4}-\d{2}-\d{2}$/.test(d) && d >= eff;
673
+ if (!docEntries.length && !dateManaged) continue; // 存量豁免
674
+ const fp = fmGet(doc, '确认指纹');
675
+ const ok = !!fp && docEntries.some((e) => e.stage === st
676
+ && typeof e.fingerprint === 'string' && e.fingerprint.startsWith(fp));
677
+ if (!ok) {
678
+ blockers.push(`- [确认未对账] ${base} 状态 ${st} 无用户确认记录——AI 不得代确认,用户在终端跑 node .agents/scripts/confirm-doc.mjs ${rel} 后重试`);
679
+ }
680
+ // 内容绑定(2026-09-27 audit-gate-hardening;生效锚 2026-09-27 gate-hardening-p2-batch 自文档自报日期
681
+ // 改为台账 done 行 ts):done 文档当前内容须与台账 done 行确认时的内容一致——按 prev 复原跳转前文本
682
+ // 重算比对,防「确认后篡改」(改验收标准/正文均触发)。复原口径 = confirm-doc computeFingerprint 的
683
+ // 逆推:CRLF 归一 → frontmatter 首个「状态:」行保分隔符换值为台账 prev(双空格等非规范分隔符不误伤,
684
+ // 与前向按原行字面计算对称)→ 剔「确认指纹:」行。两条降级路径:台账行缺 prev(schema 演进前存量,
685
+ // 无以复原)→ warning 不拦;entry.ts 早于 2026-09-28(UTC 字符串比较——该次确认在旧关单顺序时代完成)→
686
+ // 豁免绑定(配对判定已过,3 份失配存量即此列)。关单编辑顺序新约定不变:confirm-doc 是最后一次写入,
687
+ // 此后修订走 superseded 或新 intent。
688
+ if (ok && ['done', 'closed', 'superseded', 'cancelled'].includes(st)) {
689
+ const doneEntries = ledger.filter((e) => e && e.doc === rel && e.stage === st && typeof e.fingerprint === 'string');
690
+ const entry = doneEntries[doneEntries.length - 1]; // append-only 台账,末次生效(重确认场景)
691
+ if (!entry || !entry.prev) {
692
+ warnings.push(`- [WARN 绑定降级] ${base} 台账 stage=${st} 行缺 prev 字段(schema 演进前行),内容绑定跳过——仅配对判定`);
693
+ } else if (!(typeof entry.ts === 'string' && entry.ts >= '2026-09-28')) {
694
+ // 该 done 确认发生在生效锚前(旧关单顺序时代)——豁免内容绑定,配对判定照常
695
+ } else if (bindingSha256(linesOf(doc) || [], entry.prev) !== entry.fingerprint) {
696
+ blockers.push(`- [确认内容漂移] ${base} ${st} 后内容与确认台账不符——已关单文档不得直接改(关单编辑先于关单确认);确需修订走 superseded 或新 intent 引用`);
697
+ }
698
+ }
699
+ }
700
+ }
701
+ // 并录批次审计(2026-09-28 batch-ledger-audit 改判据;原 2026-09-27 confirm-gate-one-per-call 引入):
702
+ // **读调用事实,不猜时间戳模式**。判据 = 台账行按 `batch` 分组,组内 `of > 1` → warning「确认并录」。
703
+ // batch/seq/of 由 confirm-doc 在**写入时**记录(那才是「本次调用落了几份」确定已知的时刻);
704
+ // 旧判据「同 quote + 相邻 ts 差 < 2s」是拿两个间接信号反推该事实,已实证四类失效并**整段退役**:
705
+ // ① 假阳性:合规逐件调用复用同句(用户两次都说「可以」)→ 必然误报;
706
+ // ② 假阴性:并录时给每份换不同 quote → 零告警(判据的实际效果是引导伪装者改 quote);
707
+ // ③ 噪声不可消退:不按日期门豁免,退役前本仓 22 条 advisory 中 14 条为该告警,且文案自认「不可区分」;
708
+ // ④ 字段职责冲突:`quote` 同时当「对质凭据」与「并录指纹」——现归还单一职责,只记用户原话。
709
+ // 无 `batch` 字段的历史行(schema 演进前,本仓全部 67 行均无该字段)→ **静默跳过**,不降级出账、不回溯:
710
+ // 按 audit-gate-hardening P3 教训(「扫了只产生不可消除的 advisory 噪声,真漏点被淹没」),无判定依据
711
+ // 的行不该产出告警。VALID_STAGES 复用外层声明(复核 P2-3 更正:此前本块内另有一份同名声明遮蔽外层)。
712
+ {
713
+ const byBatch = new Map(); // batch → 该批的台账行(仅计合法跳转 stage)
714
+ for (const e of ledger) {
715
+ if (!e || !VALID_STAGES.has(e.stage)) continue;
716
+ if (typeof e.batch !== 'string' || !e.batch) continue; // 历史行无 batch → 无判定依据,跳过
717
+ if (!byBatch.has(e.batch)) byBatch.set(e.batch, []);
718
+ byBatch.get(e.batch).push(e);
719
+ }
720
+ for (const [b, rows] of byBatch) {
721
+ // 判据取组内**任一行** of>1(复核 P2-3 更正:原取 rows[0].of 使结论依赖行序——同批首行 of=1、
722
+ // 次行 of=2 会漏报。confirm-doc 对同批所有行写同一个 of=docs.length,故正常批次组内一致;
723
+ // 改用 some 消除该顺序脆弱性,并对 craft 出来的不一致批次按「最大值」报,方向偏严不偏松)
724
+ if (!rows.some((r) => typeof r.of === 'number' && r.of > 1)) continue;
725
+ // of > 1 = 一次调用落多份态。delegated 形态下这正是「并录」(入口已拒多份,此处抓历史/绕行);
726
+ // TTY 形态天然逐份过目(用户亲手键入),of>1 不构成违规——故只对 delegated 行报。
727
+ if (!rows.some((r) => r.source === 'chat-delegated')) continue;
728
+ const of = Math.max(...rows.map((r) => (typeof r.of === 'number' ? r.of : 0)));
729
+ const docs = rows.map((r) => String(r.doc).replace(/^workflow\//, '')).join('、');
730
+ warnings.push(`- [WARN 确认并录] 单次调用落账 ${rows.length} 份(batch ${b},of=${of}):${docs}——同一次 --delegated 调用放行多份,塌掉「逐件确认」门(build.md);口径见 workflow/papercuts.md 2026-09-28 与 incidents/2026-09-28-batch-ledger-audit.md`);
731
+ }
732
+ }
733
+ }
734
+
735
+ // --- 16. 量化断言指标签名对账 [warning](2026-09-28 claim-exceeds-fix;登记表单源 .agents/metric-claims.txt)---
736
+ // 判据:活跃态文档(draft/approved/open)中出现的 `{{指标名}}` 签名,实时取数比对。
737
+ // **为什么只查显式签名、不全文扫数字**(据实说明):本仓活跃文档「N 行/N 条/N 份」类表述数十处,
738
+ // 绝大多数是**历史叙述**(描述某次提交当时的规模,天然不随当前事实变化)——全文扫描会对这些
739
+ // 正确表述产生大量假阳性,而本脚本已把「无判定依据的行不产出不可消除噪声」立为红线
740
+ // (沿 audit-gate-hardening P3:噪声淹没真漏点)。故本检查假阳性恒为 0,代价是覆盖面靠登记。
741
+ // 未登记签名 / 取数器缺失 → fail-loud(不静默跳过:静默会让「登记了但其实没查」不可见)。
742
+ // 登记表缺失 → 静默跳过(未启用该检查的装户不应被噪声打扰)。
743
+ {
744
+ const mcFile = path.join(ROOT, '.agents', 'metric-claims.txt');
745
+ if (fs.existsSync(mcFile)) {
746
+ // 取数器闭集:全部本地确定性、零网络。新增指标须同时改本表与 metric-claims.txt(缺一 fail-loud)
747
+ // **取数须与扫描面同口径(复核 P2 更正)**:文档扫描走 `docFiles()`(仓库模式只取 HEAD **tracked**
748
+ // 内容,见 isTracked),首版 `countDocs` 却直接 readdir 数盘面——实测本仓 `docs.count.plans` 会
749
+ // 把并行会话的**未跟踪**新档算进去(盘面 60 / HEAD 59),于是「签名填对了」却是别的读者复现不出的
750
+ // 数。故此处同样以 isTracked 过滤,保证「扫描看得到的文档集 == 取数统计的文档集」。
751
+ const countDocs = (sub) => {
752
+ const dir = path.join(ROOT, 'workflow', sub);
753
+ return (readdirOrNull(dir) || []).filter((f) => {
754
+ // 与 docFiles() 同口径:.md + 以数字开头(排除 _TEMPLATE.md 等非实例件)+ tracked
755
+ if (!f.endsWith('.md') || !/^\d/.test(f)) return false;
756
+ return isTracked(path.relative(ROOT, path.join(dir, f)).split(path.sep).join('/'));
757
+ }).length;
758
+ };
759
+ const ledgerLines = () => {
760
+ const p = path.join(ROOT, '.agents', 'confirmations.jsonl');
761
+ const ls = (linesOf(p) || []).filter((l) => l.trim());
762
+ return { total: ls.length, withBatch: ls.filter((l) => /"batch"/.test(l)).length };
763
+ };
764
+ // 内置取数器:全部本地确定性、零网络。**装户零配置即用**(不依赖任何装户文件)。
765
+ const derivers = {
766
+ 'ledger.lines': () => ledgerLines().total,
767
+ 'ledger.linesWithBatch': () => ledgerLines().withBatch,
768
+ 'ledger.linesWithoutBatch': () => ledgerLines().total - ledgerLines().withBatch,
769
+ 'docs.count.intents': () => countDocs('intents'),
770
+ 'docs.count.specs': () => countDocs('specs'),
771
+ 'docs.count.plans': () => countDocs('plans'),
772
+ 'docs.count.incidents': () => countDocs('incidents'),
773
+ 'docs.count.all': () => countDocs('intents') + countDocs('specs') + countDocs('plans') + countDocs('incidents'),
774
+ };
775
+ // ---- 装户侧取数器(2026-09-28 adopter-derivers;修「owned 登记表配 managed 取数器」P1)----
776
+ // 背景:登记表是 owned(装户自增指标),取数器却曾硬编码在本文件(managed)——装户一新增指标
777
+ // 就报「无取数器」,唯一出路是改本文件,而那是 managed:sync 永久报「本地已改」+ doctor WARN
778
+ // + 本脚本因供应链防线**跳过执行**(门禁静默停摆)。故改为分层:
779
+ // ① 内置优先(行为与今完全一致,装户零配置可用);② 未命中则查装户模块 `.agents/metric-derivers.cjs`
780
+ // **必须是 CJS(`.cjs` + module.exports)**(2026-09-28 跨版本实测修正):
781
+ // 首版约定 `.mjs` 并用 `createRequire` 载入,在 Node v24.12 上实测可用(**故本机没暴露问题**);
782
+ // 但跨版本实测(真下 18.20.5 / 20.18.0 / 22.11.0 二进制跑)**三者全部 `ERR_REQUIRE_ESM`**——
783
+ // `require(esm)` 只在 Node ≥20.19 / ≥22.12 默认可用,Node 18 从未支持。而本仓 engines 写
784
+ // `>=18.0.0`、CI 矩阵跑 18/22 → **装户取数功能在 CI 目标版本上完全不可用**(fail-loud 正常、
785
+ // 不崩,但功能不工作)。故改用 CJS:`createRequire` 载入 `.cjs` 在 18/20/22/24 全线可用,
786
+ // 且**仍是同步**(无须把本脚本改成 async 主线)。契约要求同步函数也与本检查「零网络、
787
+ // 本地确定性」的前提一致(异步的唯一真实收益是网络/并发 IO,此处都不允许)。
788
+ // **失败一律响亮、绝不静默降级**(沿 claim-exceeds-fix「假绿比红危险」与本检查自身语法错教训):
789
+ // 若载入失败就「只用内置指标」,装户会看到「引用了 my.metric → 未登记」,看起来像**自己忘了登记**,
790
+ // 而真因是模块坏了——他会去 registry 反复核对,问题永远查不到。故载入/调用失败均出明确 WARN。
791
+ const adopterModRel = '.agents/metric-derivers.cjs';
792
+ // 旧路径(首版 .mjs):若存在则**明确提示已改用 .cjs**,避免装户对着「无取数器」查不出所以然
793
+ const adopterLegacyRel = '.agents/metric-derivers.mjs';
794
+ let adopterState = null; // { ok:true, derivers } | { ok:false, errors:[...] } —— 懒载入 + 单次运行内缓存
795
+ const loadAdopterDerivers = () => {
796
+ if (adopterState) return adopterState;
797
+ const abs = path.join(ROOT, adopterModRel);
798
+ if (!fs.existsSync(abs)) {
799
+ // 常见误写:仍用首版的 .mjs 路径 → 给出可操作的迁移指引(而非任其对着「无取数器」猜)
800
+ if (fs.existsSync(path.join(ROOT, adopterLegacyRel))) {
801
+ adopterState = {
802
+ ok: false,
803
+ errors: [`${adopterModRel} 不存在,但发现旧版路径 ${adopterLegacyRel}——该路径已于 2026-09-28 弃用(\`require(esm)\` 在 Node 18/22 不可用,实测 ERR_REQUIRE_ESM)。请改名为 ${adopterModRel} 并把写法改为 CommonJS:\`module.exports = { derivers: { ... } }\``],
804
+ };
805
+ return adopterState;
806
+ }
807
+ adopterState = { ok: true, derivers: {} }; // 缺失=正常态,零告警
808
+ return adopterState;
809
+ }
810
+ try {
811
+ const req = createRequire(import.meta.url);
812
+ const mod = req(abs);
813
+ // CJS / ESM 双兼容取导出:CJS 走 module.exports.derivers;若装户误用了 ESM 语法而环境恰好
814
+ // 支持(新 Node 的 require(esm)),default 里也可能挂 derivers——两处都认,避免假阴性。
815
+ const d = (mod && mod.derivers) || (mod && mod.default && mod.default.derivers);
816
+ if (!d || typeof d !== 'object' || Array.isArray(d)) {
817
+ adopterState = { ok: false, errors: [`${adopterModRel} 未导出 \`derivers\` 对象(实际导出形态:${d === undefined ? '无 derivers 键' : Array.isArray(d) ? 'array' : typeof d})——载入失败不降级,请用 \`module.exports = { derivers: { ... } }\``] };
818
+ return adopterState;
819
+ }
820
+ adopterState = { ok: true, derivers: d };
821
+ } catch (e) {
822
+ const hint = /ERR_REQUIRE_ESM/.test(String(e && e.code)) ? `(该错误表示模块被当成了 ESM——本契约要求 CommonJS:文件后缀 .cjs 且用 \`module.exports = { derivers: {...} }\`)` : '';
823
+ adopterState = { ok: false, errors: [`${adopterModRel} 载入失败:${String(e && e.message ? e.message : e).split('\n')[0]}${hint}——载入失败不降级(静默降级会让你误以为「忘了登记」而非「模块坏了」)`] };
824
+ }
825
+ return adopterState;
826
+ };
827
+ // ctx:注入给装户取数器的同步辅助(免其重复造轮子)
828
+ // **glob 逐字符转义**(自查修正):首版用「先整体转义特殊字符、再 replace 星号」的写法,
829
+ // 实测 `src/**/*.ts` 匹配不到 `src/a/b/c.ts`、「**/*.md」匹配不到 `a/b/c.md`——因为 `**/`
830
+ // 在被替换前已被字符类转义破坏。改为逐字符状态机,语义明确:
831
+ // `**/` → 任意层级(含零层);`**` → 任意(可跨 /);`*` → 段内任意(不跨 /);`?` → 段内单字符
832
+ // 其余一律字面转义(故 `a.b.sql` 的点是字面点、`[`/`{` 也按字面处理——本 helper 不承诺
833
+ // 字符类 / 花括号展开,避免装户误以为支持完整 glob 语法;不支持即按字面匹配,行为可预测)
834
+ const globToRegExp = (p) => {
835
+ const s = String(p);
836
+ let re = '';
837
+ for (let i = 0; i < s.length; i++) {
838
+ const c = s[i];
839
+ if (c === '*' && s[i + 1] === '*') {
840
+ if (s[i + 2] === '/') { re += '(?:.*/)?'; i += 2; } // `**/` 含零层
841
+ else { re += '.*'; i += 1; } // `**` 跨段
842
+ } else if (c === '*') re += '[^/]*';
843
+ else if (c === '?') re += '[^/]';
844
+ else re += c.replace(/[.+^${}()|[\]\\]/, '\\$&');
845
+ }
846
+ return new RegExp('^' + re + '$');
847
+ };
848
+ // 遍历时**跳过重目录 + 防环**(自查 + 复核 P2 修正):
849
+ // · 剪枝 node_modules / .git / cache——无剪枝时 `ctx.glob('**/*')` 会走进依赖树,既慢又会把
850
+ // 依赖文件算进取数(对「我项目有多少个 X」是错的数)。
851
+ // · **visited 集合防环**:复核实测软链接/junction 成环时递归到 depth 128、同一文件重复 64 份
852
+ // 才被 statSync 的 ELOOP 拦下;POSIX 无 MAX_PATH 时该递归无界。故按**真实路径**去重。
853
+ const WALK_SKIP = new Set(['node_modules', '.git', 'cache']);
854
+ const walkFiles = (dir, out = [], rel = '', visited = new Set()) => {
855
+ let real = dir;
856
+ try { real = fs.realpathSync(dir); } catch { /* 不可解析则按原路径处理 */ }
857
+ if (visited.has(real)) return out; // 环 → 剪枝
858
+ visited.add(real);
859
+ for (const e of readdirOrNull(dir) || []) {
860
+ if (WALK_SKIP.has(e)) continue;
861
+ const abs = path.join(dir, e);
862
+ const r = rel ? `${rel}/${e}` : e;
863
+ let st = null;
864
+ try { st = fs.statSync(abs); } catch { continue; }
865
+ if (st.isDirectory()) walkFiles(abs, out, r, visited);
866
+ else out.push(r);
867
+ }
868
+ return out;
869
+ };
870
+ const ctx = {
871
+ root: ROOT,
872
+ read: (rel) => { try { return fs.readFileSync(path.join(ROOT, rel), 'utf8').replace(/\r\n/g, '\n'); } catch { return ''; } },
873
+ glob: (pattern) => { const re = globToRegExp(pattern); return walkFiles(ROOT).filter((r) => re.test(r)); },
874
+ countFiles: (dir, pred) => walkFiles(path.join(ROOT, dir)).filter((r) => (typeof pred === 'function' ? pred(r) : true)).length,
875
+ };
876
+ // resolveDeriver(name) → { fn } | { error }(**绝不抛出**:引擎不因装户代码崩溃)
877
+ const resolveDeriver = (name) => {
878
+ if (derivers[name]) return { fn: derivers[name], builtin: true };
879
+ const st = loadAdopterDerivers();
880
+ if (!st.ok) return { error: st.errors.join(';') };
881
+ const fn = st.derivers[name];
882
+ if (typeof fn !== 'function') {
883
+ if (fn !== undefined) return { error: `${adopterModRel} 的「${name}」不是函数(实际类型:${typeof fn})` };
884
+ return { error: `无对应取数器——内置 8 个指标均不含该名,${adopterModRel} 亦未定义(登记了却没实现=其实没查)` };
885
+ }
886
+ return { fn, builtin: false };
887
+ };
888
+ // 装户模块载入失败:**独立成条**(2026-09-28 adopter-derivers)。
889
+ // 关键设计(真绿 vs 假绿的分界):载入失败若只表现为「该指标无取数器」,装户会看到
890
+ // 「引用了 my.metric → 未登记」,看起来像**自己忘了登记**,而真因是模块坏了——他会去 registry
891
+ // 反复核对,永远查不到。故此处单独出一条,明示文件与错误,与「真的没登记」可区分。
892
+ //
893
+ // **仅在真的需要装户指标时才报**(复核 P2 更正):首版在解析登记表前无条件出账——若登记表只有
894
+ // 内置指标、而装户留了个半成品模块(或模块坏掉但没人用),会平白多一条不可消除的 advisory。
895
+ // 沿本仓红线「无判定依据的行不产出噪声」:先看登记表是否真的有**非内置**指标,再决定报不报。
896
+ const mcLines = (linesOf(mcFile) || []).map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
897
+ const hasAdopterMetric = mcLines.some((l) => {
898
+ const mm = /^([A-Za-z][\w.]*)\s*=\s*([A-Za-z][\w.]*)$/.exec(l);
899
+ return mm && !derivers[mm[1]];
900
+ });
901
+ const adopterLoad = loadAdopterDerivers();
902
+ if (!adopterLoad.ok && hasAdopterMetric) {
903
+ for (const e of adopterLoad.errors) warnings.push(`- [WARN 装户取数器载入失败] ${e}`);
904
+ }
905
+ // 解析登记表:`<指标名> = <取数表达式>`(表达式须与指标名同形,二者不一致即登记笔误)
906
+ const declared = new Map();
907
+ const precedenceWarned = new Set(); // 同名 advisory 按指标去重(复核 P2 更正:首版按行重复出账)
908
+ for (const line of mcLines) {
909
+ const m = /^([A-Za-z][\w.]*)\s*=\s*([A-Za-z][\w.]*)$/.exec(line);
910
+ if (!m) { warnings.push(`- [WARN 指标登记] .agents/metric-claims.txt 行格式非法(应为 \`指标名 = 取数表达式\`):${line}`); continue; }
911
+ if (m[1] !== m[2]) { warnings.push(`- [WARN 指标登记] 登记名与取数表达式不一致:${m[1]} ≠ ${m[2]}`); continue; }
912
+ const r = resolveDeriver(m[1]);
913
+ if (r.error) {
914
+ // 无取数器(内置无 + 装户模块无同名)或装户模块载入失败 → 明示可操作方向
915
+ const hint = adopterLoad.ok
916
+ ? `若为项目自有指标,请在 ${adopterModRel} 的 derivers 中实现(契约见 .agents/metric-claims.txt 头部)`
917
+ : `且 ${adopterModRel} 载入失败(见上方「装户取数器载入失败」条目——**这不是「忘了登记」**)`;
918
+ warnings.push(`- [WARN 指标登记] 指标「${m[1]}」无对应取数器(fail-loud:登记了却没实现=其实没查)——内置 8 指标不含该名,${hint}`);
919
+ // **不 continue**:该指标在 registry 里确实登记过,故仍计入 declared——
920
+ // 否则引用它的文档会被误报为「未登记」(把「模块坏了」伪装成「忘了登记」,正是本单要防的假绿)。
921
+ // 取数失败在扫描阶段以「指标取数失败」单独出账,两条信息不互相掩盖。
922
+ } else if (r.builtin && adopterLoad.ok && typeof adopterLoad.derivers[m[1]] === 'function' && !precedenceWarned.has(m[1])) {
923
+ // 内置与装户同名:内置优先(保持本仓/存量行为稳定),并明示装户定义被忽略(**按指标去重**)
924
+ precedenceWarned.add(m[1]);
925
+ warnings.push(`- [WARN 指标登记] 指标「${m[1]}」由引擎内置提供,${adopterModRel} 中的同名定义**被忽略**(内置优先)——如需改用自有实现,请换一个指标名`);
926
+ }
927
+ declared.set(m[1], true);
928
+ }
929
+ // 扫活跃态文档(沿检查 4 现有尺度:终态件的历史数字是历史叙述,不扫)
930
+ const activeSet16 = (sub) => (sub === 'incidents' ? ENUMS['incident.status.active'] : ENUMS['doc.status.active']);
931
+ const scanFiles16 = [];
932
+ for (const sub of ['intents', 'specs', 'plans', 'incidents']) {
933
+ const dir = path.join(ROOT, 'workflow', sub);
934
+ for (const f of readdirOrNull(dir) || []) {
935
+ if (!f.endsWith('.md') || f === '_TEMPLATE.md') continue;
936
+ if (!inSet(fmGet(path.join(dir, f), '状态'), activeSet16(sub))) continue;
937
+ scanFiles16.push({ rel: `workflow/${sub}/${f}`, abs: path.join(dir, f) });
938
+ }
939
+ }
940
+ for (const f of readdirOrNull(path.join(ROOT, 'workflow')) || []) {
941
+ if (f.endsWith('.md')) scanFiles16.push({ rel: `workflow/${f}`, abs: path.join(ROOT, 'workflow', f) });
942
+ }
943
+ for (const { rel, abs } of scanFiles16) {
944
+ const ls = linesOf(abs) || [];
945
+ ls.forEach((line, i) => {
946
+ // 签名形态收窄为**小写点分**(如 `{{ledger.lines}}`):装户模板占位符是全大写 SCREAMING_CASE
947
+ // (构建命令 / 看板端口 / 项目名三类,由 init 渲染替换,非本检查对象)
948
+ // ——不收窄会对这些合法的模板占位符产生大量假阳性(实测:本仓 plans 内 11 处)。
949
+ // **转义(2026-09-28,本检查在自己的文档上抓到该需求)**:讲语法 / 举例 / 引用告警原文的
950
+ // 文档在签名前加反斜杠(`\{{ledger.lines}}`)表示「此处是示意、非断言」——否则本检查
951
+ // 会把「文档在教怎么用签名」误判为「作者忘了回填」。选显式转义而非「同句含『如/例』等标记词
952
+ // 即豁免」的启发式:后者会按措辞松紧漂移,且作者能无意中触发豁免而漏过真断言。
953
+ // **转义按「逐个出现」生效、不按整行**(复核自查 P1 更正):首版实现是「行内出现任一 `\{{`
954
+ // 即整行跳过」,实测可被这样藏住真断言——`… \{{a.b}} 示意… {{ledger.lines}} 未回填` 整行静默
955
+ // 放过。故改为用带后顾的否匹配排除转义位,同行的真签名照常出账。
956
+ for (const m of line.matchAll(/(?<!\\)\{\{([a-z][a-z0-9]*(?:\.[a-z0-9]+)+)\}\}/g)) {
957
+ const name = m[1];
958
+ if (!declared.has(name)) {
959
+ warnings.push(`- [WARN 指标未登记] ${rel}:${i + 1} 引用 {{${name}}} 但 .agents/metric-claims.txt 未登记该指标(未登记=无从对账)`);
960
+ continue;
961
+ }
962
+ const r = resolveDeriver(name);
963
+ if (r.error) {
964
+ // 取数器不可用(含装户模块载入失败 / 无该名 / 定义非法)——**响亮出账,绝不静默跳过**
965
+ warnings.push(`- [WARN 指标取数失败] ${rel}:${i + 1} {{${name}}} 无法取数:${r.error}`);
966
+ continue;
967
+ }
968
+ let real;
969
+ try {
970
+ real = r.fn(ctx);
971
+ } catch (e) {
972
+ warnings.push(`- [WARN 指标取数失败] ${rel}:${i + 1} {{${name}}} 取数器抛异常:${String(e && e.message ? e.message : e).split('\n')[0]}`);
973
+ continue;
974
+ }
975
+ // 返回值必须是有限数字:Promise(误写 async)/ NaN / 字符串 / undefined 一律拦下并明示类型
976
+ if (typeof real !== 'number' || !Number.isFinite(real)) {
977
+ const kind = real && typeof real.then === 'function' ? 'Promise(本检查要求**同步**函数,请去掉 async/await)' : `${typeof real}${typeof real === 'number' ? `(${real})` : ''}`;
978
+ warnings.push(`- [WARN 指标取数失败] ${rel}:${i + 1} {{${name}}} 取数器返回值不是有限数字:${kind}`);
979
+ continue;
980
+ }
981
+ // 判据:签名**必须**被替换为实时值——留有 `{{...}}` 即视为未回填(写作期占位,关单前须落实)
982
+ warnings.push(`- [WARN 指标待回填] ${rel}:${i + 1} {{${name}}} 实时值 = ${real}——请把签名替换为该数字(留签名=未回填)`);
983
+ }
984
+ // **形态不符的签名也不静默放过(复核 P2 更正)**:首版只认严格小写点分形态,于是
985
+ // `{{Ledger.Lines}}` / `{{ ledger.lines }}`(含空格)这类**明显本意是签名**的写法
986
+ // 既不匹配、也不出账——静默漏过,且绕开了「未登记」的 fail-loud。此处补一道:
987
+ // 非转义的 `{{...}}` 中,凡**不是**严格形态、且**不是**装户模板占位符(全大写 SCREAMING_CASE)
988
+ // 的,一律提示形态不符(作者才能立刻发现笔误)。
989
+ for (const m of line.matchAll(/(?<!\\)\{\{([^{}]+)\}\}/g)) {
990
+ const raw = m[1];
991
+ if (/^[a-z][a-z0-9]*(?:\.[a-z0-9]+)+$/.test(raw)) continue; // 严格形态,上面已处理
992
+ if (/^[A-Z][A-Z0-9_]*$/.test(raw)) continue; // 装户模板占位符(init 渲染对象,非本检查面)
993
+ warnings.push(`- [WARN 指标形态] ${rel}:${i + 1} \`{{${raw}}}\` 不是合法签名形态(应为小写点分,如 {{ledger.lines}})——若本意是量化断言,请改正形态;若只是举例,请加反斜杠转义`);
994
+ }
995
+ });
996
+ }
997
+ }
998
+ }
999
+
501
1000
  // --- 输出(banner 沿 sh 版字样与格式 = 稳定输出契约:banner 后空行、条目 '- ' 前缀)---
502
1001
  if (blockers.length) {
503
1002
  process.stderr.write(`闭环骨架断档(check-loop.sh)— HARD-BLOCK:\n\n${blockers.join('\n')}\n\n`);
@@ -509,6 +1008,29 @@ if (warnings.length) {
509
1008
  }
510
1009
  process.exit(0);
511
1010
 
1011
+ // bindingSha256:done 内容绑定的复原重算(检查 15 专用)——confirm-doc.mjs computeFingerprint 的逆推:
1012
+ // lines 已 CRLF 归一(split(/\r?\n/)),frontmatter 区内首个「状态:」行保分隔符换值为 prev(`状态: done`
1013
+ // 双空格 → `状态: approved`——与前向按原行字面计算对称,非规范分隔符不误伤;行尾空白等更奇异格式仍会
1014
+ // 失配,触发前提本身已违反「确认落态唯一入口」约定,接受),全文剔「确认指纹:」行后 join('\n') 再 sha256。
1015
+ function bindingSha256(lines, prevStatus) {
1016
+ const isDelim = (l) => /^---\s*$/.test(l);
1017
+ const out = [];
1018
+ let i = 0;
1019
+ if (isDelim(lines[0] || '')) {
1020
+ out.push(lines[0]);
1021
+ let replaced = false;
1022
+ for (i = 1; i < lines.length; i++) {
1023
+ if (isDelim(lines[i])) { out.push(lines[i]); i++; break; }
1024
+ if (!replaced && /^状态:/.test(lines[i])) {
1025
+ out.push(lines[i].replace(/^(\s*状态:\s*)\S.*$/, `$1${prevStatus}`));
1026
+ replaced = true;
1027
+ } else out.push(lines[i]);
1028
+ }
1029
+ }
1030
+ for (; i < lines.length; i++) out.push(lines[i]);
1031
+ return createHash('sha256').update(out.filter((l) => !/^确认指纹:/.test(l)).join('\n'), 'utf8').digest('hex');
1032
+ }
1033
+
512
1034
  function readdirOrNull(dir) {
513
1035
  try { return fs.readdirSync(dir); } catch { return null; }
514
1036
  }