agentic-flow-kit 1.7.0 → 1.7.16

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 (162) hide show
  1. package/README.md +81 -9
  2. package/modules/gates/README.md +1 -0
  3. package/modules/gates/test-protect/README.md +23 -0
  4. package/modules/gates/test-protect/check-test-protect.mjs +64 -0
  5. package/modules/gates/test-protect/local-pre-commit.line +1 -0
  6. package/modules/hosts/README.md +57 -0
  7. package/modules/hosts/claude/commands/wf-build.md +5 -24
  8. package/modules/hosts/claude/commands/wf-deploy.md +1 -0
  9. package/modules/hosts/claude/commands/wf-design.md +7 -6
  10. package/modules/hosts/claude/commands/wf-gate-checklist.md +1 -3
  11. package/modules/hosts/claude/commands/wf-new-task.md +42 -21
  12. package/modules/hosts/claude/commands/wf-plan.md +1 -1
  13. package/modules/hosts/claude/commands/wf-review.md +1 -1
  14. package/modules/hosts/claude/commands/wf-sync-hosts.md +8 -16
  15. package/modules/hosts/claude/commands/wf-test.md +5 -4
  16. package/modules/hosts/codex/commands/wf-build.md +5 -24
  17. package/modules/hosts/codex/commands/wf-deploy.md +1 -0
  18. package/modules/hosts/codex/commands/wf-design.md +7 -6
  19. package/modules/hosts/codex/commands/wf-gate-checklist.md +1 -3
  20. package/modules/hosts/codex/commands/wf-new-task.md +42 -21
  21. package/modules/hosts/codex/commands/wf-plan.md +1 -1
  22. package/modules/hosts/codex/commands/wf-review.md +1 -1
  23. package/modules/hosts/codex/commands/wf-sync-hosts.md +8 -16
  24. package/modules/hosts/codex/commands/wf-test.md +5 -4
  25. package/modules/hosts/cursor/commands/wf-build.md +5 -24
  26. package/modules/hosts/cursor/commands/wf-deploy.md +1 -0
  27. package/modules/hosts/cursor/commands/wf-design.md +7 -6
  28. package/modules/hosts/cursor/commands/wf-gate-checklist.md +1 -3
  29. package/modules/hosts/cursor/commands/wf-new-task.md +42 -21
  30. package/modules/hosts/cursor/commands/wf-plan.md +1 -1
  31. package/modules/hosts/cursor/commands/wf-review.md +1 -1
  32. package/modules/hosts/cursor/commands/wf-sync-hosts.md +8 -16
  33. package/modules/hosts/cursor/commands/wf-test.md +5 -4
  34. package/modules/hosts/opencode/commands/wf-build.md +5 -24
  35. package/modules/hosts/opencode/commands/wf-deploy.md +1 -0
  36. package/modules/hosts/opencode/commands/wf-design.md +7 -6
  37. package/modules/hosts/opencode/commands/wf-gate-checklist.md +1 -3
  38. package/modules/hosts/opencode/commands/wf-new-task.md +42 -21
  39. package/modules/hosts/opencode/commands/wf-plan.md +1 -1
  40. package/modules/hosts/opencode/commands/wf-review.md +1 -1
  41. package/modules/hosts/opencode/commands/wf-sync-hosts.md +8 -16
  42. package/modules/hosts/opencode/commands/wf-test.md +5 -4
  43. package/modules/hosts/trae/commands/wf-build.md +5 -24
  44. package/modules/hosts/trae/commands/wf-deploy.md +1 -0
  45. package/modules/hosts/trae/commands/wf-design.md +7 -6
  46. package/modules/hosts/trae/commands/wf-gate-checklist.md +1 -3
  47. package/modules/hosts/trae/commands/wf-new-task.md +42 -21
  48. package/modules/hosts/trae/commands/wf-plan.md +1 -1
  49. package/modules/hosts/trae/commands/wf-review.md +1 -1
  50. package/modules/hosts/trae/commands/wf-sync-hosts.md +8 -16
  51. package/modules/hosts/trae/commands/wf-test.md +5 -4
  52. package/package.json +2 -2
  53. package/src/add-gate.mjs +25 -1
  54. package/src/add-gate.test.mjs +103 -0
  55. package/src/cli.mjs +59 -5
  56. package/src/doctor.mjs +136 -12
  57. package/src/executor.mjs +245 -0
  58. package/src/executor.test.mjs +292 -0
  59. package/src/gates.test.mjs +17 -0
  60. package/src/init.mjs +14 -1
  61. package/src/init.test.mjs +19 -0
  62. package/src/instance-ref.test.mjs +62 -0
  63. package/src/jscpd-drift.test.mjs +144 -0
  64. package/src/profiles.mjs +3 -0
  65. package/src/render.mjs +6 -1
  66. package/src/rule-budget.test.mjs +103 -0
  67. package/src/sync-hosts.mjs +41 -45
  68. package/src/sync-hosts.test.mjs +67 -56
  69. package/src/trae-hooks.test.mjs +15 -3
  70. package/src/validate.mjs +72 -0
  71. package/src/validate.test.mjs +95 -0
  72. package/templates/.gitattributes +8 -0
  73. package/templates/AGENTS.md +2 -1
  74. package/templates/_agents/commands/build.md +5 -24
  75. package/templates/_agents/commands/deploy.md +1 -0
  76. package/templates/_agents/commands/design.md +7 -6
  77. package/templates/_agents/commands/gate-checklist.md +1 -3
  78. package/templates/_agents/commands/new-task.md +42 -21
  79. package/templates/_agents/commands/plan.md +1 -1
  80. package/templates/_agents/commands/review.md +1 -1
  81. package/templates/_agents/commands/sync-hosts.md +8 -16
  82. package/templates/_agents/commands/test.md +5 -4
  83. package/templates/_agents/control-surfaces.txt +53 -0
  84. package/templates/_agents/engine-lock.json +246 -0
  85. package/templates/_agents/lane-surfaces.txt +1 -1
  86. package/templates/_agents/level-hints.txt +30 -0
  87. package/templates/_agents/scripts/agg-delegations.cjs +51 -6
  88. package/templates/_agents/scripts/agg-delegations.test.mjs +84 -4
  89. package/templates/_agents/scripts/check-engine-integrity.mjs +217 -0
  90. package/templates/_agents/scripts/check-engine-integrity.test.mjs +215 -0
  91. package/templates/_agents/scripts/check-lane-surface.mjs +78 -24
  92. package/templates/_agents/scripts/check-lane-surface.test.mjs +66 -0
  93. package/templates/_agents/scripts/check-ledger-invariant.mjs +112 -14
  94. package/templates/_agents/scripts/check-ledger-invariant.test.mjs +5 -1
  95. package/templates/_agents/scripts/check-loop-rev.test.mjs +6 -4
  96. package/templates/_agents/scripts/check-loop.mjs +255 -45
  97. package/templates/_agents/scripts/check-loop.test.mjs +330 -15
  98. package/templates/_agents/scripts/check-metric-claims.mjs +4 -2
  99. package/templates/_agents/scripts/compact-ledgers.mjs +97 -0
  100. package/templates/_agents/scripts/compact-ledgers.test.mjs +162 -0
  101. package/templates/_agents/scripts/confirm-doc.mjs +54 -12
  102. package/templates/_agents/scripts/confirm-doc.test.mjs +59 -0
  103. package/templates/_agents/scripts/doctor.test.mjs +294 -1
  104. package/templates/_agents/scripts/draft-sediment.mjs +178 -0
  105. package/templates/_agents/scripts/draft-sediment.test.mjs +272 -0
  106. package/templates/_agents/scripts/edit-face-check.test.mjs +5 -4
  107. package/templates/_agents/scripts/fill-intent.mjs +2 -2
  108. package/templates/_agents/scripts/fixture-scripts.mjs +153 -0
  109. package/templates/_agents/scripts/fixture-scripts.test.mjs +107 -0
  110. package/templates/_agents/scripts/gate-checklist.mjs +6 -3
  111. package/templates/_agents/scripts/gate-checklist.test.mjs +2 -5
  112. package/templates/_agents/scripts/gen-wiki-board.mjs +61 -6
  113. package/templates/_agents/scripts/gen-wiki-board.test.mjs +74 -1
  114. package/templates/_agents/scripts/gen-workflow-dashboard.mjs +10 -13
  115. package/templates/_agents/scripts/gen-workflow-metrics.mjs +12 -30
  116. package/templates/_agents/scripts/git-merge-jsonl.mjs +63 -0
  117. package/templates/_agents/scripts/git-merge-jsonl.test.mjs +125 -0
  118. package/templates/_agents/scripts/kb-search.mjs +87 -12
  119. package/templates/_agents/scripts/kb-search.test.mjs +83 -0
  120. package/templates/_agents/scripts/ledger-doc-chain.test.mjs +166 -0
  121. package/templates/_agents/scripts/ledger-loader.mjs +108 -0
  122. package/templates/_agents/scripts/ledger-loader.test.mjs +131 -0
  123. package/templates/_agents/scripts/ledger-prune.mjs +96 -0
  124. package/templates/_agents/scripts/ledger-prune.test.mjs +120 -0
  125. package/templates/_agents/scripts/ledger-ts-monotonic.test.mjs +135 -0
  126. package/templates/_agents/scripts/level-criteria.mjs +232 -0
  127. package/templates/_agents/scripts/level-criteria.test.mjs +279 -0
  128. package/templates/_agents/scripts/pipeline-run.mjs +5 -3
  129. package/templates/_agents/scripts/policy.mjs +47 -0
  130. package/templates/_agents/scripts/solidify-task.mjs +1 -1
  131. package/templates/_agents/scripts/stage-gates.mjs +52 -10
  132. package/templates/_agents/scripts/stage-gates.test.mjs +105 -0
  133. package/templates/_agents/scripts/verify-wiki-consistency.mjs +34 -0
  134. package/templates/_agents/scripts/verify-wiki-consistency.test.mjs +44 -2
  135. package/templates/_agents/scripts/verify.mjs +1 -1
  136. package/templates/_agents/scripts/wf-journal.test.mjs +1 -1
  137. package/templates/_agents/scripts/wiki-search.test.mjs +5 -6
  138. package/templates/_agents/scripts/workflows-check.mjs +1 -1
  139. package/templates/_agents/scripts-test-exempt.txt +3 -1
  140. package/templates/_agents/skills/wiki/SKILL.md +5 -3
  141. package/templates/_agents/workflows/pipeline-closing.md +4 -5
  142. package/templates/_githooks/pre-commit +3 -34
  143. package/templates/_githooks/pre-push +3 -3
  144. package/templates/wiki/INDEX.md +44 -1
  145. package/templates/wiki//347/237/245/350/257/206/346/262/211/346/267/200/346/200/273/350/247/210.html +22 -1
  146. package/templates/workflow/README.md +11 -0
  147. package/templates/workflow/delegations.md +5 -4
  148. package/templates/workflow/incidents/_TEMPLATE.md +2 -0
  149. package/templates/workflow/intents/_TEMPLATE.md +3 -0
  150. package/templates/workflow/plans/_TEMPLATE.md +4 -0
  151. package/templates/workflow/specs/_TEMPLATE.md +23 -0
  152. package/modules/hosts/claude/commands/wf-source-sync-check.md +0 -91
  153. package/modules/hosts/codex/commands/wf-source-sync-check.md +0 -91
  154. package/modules/hosts/cursor/commands/wf-source-sync-check.md +0 -91
  155. package/modules/hosts/opencode/commands/wf-source-sync-check.md +0 -91
  156. package/modules/hosts/trae/commands/wf-source-sync-check.md +0 -92
  157. package/templates/_agents/commands/source-sync-check.md +0 -105
  158. package/templates/_agents/scripts/check-ledger.mjs +0 -109
  159. package/templates/_agents/scripts/check-ledger.test.mjs +0 -197
  160. package/templates/_agents/scripts/source-sync-check.mjs +0 -156
  161. package/templates/_agents/scripts/source-sync-check.test.mjs +0 -281
  162. package/templates/_agents/workflows/source-sync-repair.md +0 -30
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+ // check-engine-integrity.mjs — 门禁本体完整性门(2026-10-09 P0-A:破「裁判给自己判无罪」)
3
+ //
4
+ // 要解决的缺陷(审查结论 P0 第一条,本仓一手证据):
5
+ // ① `.agents/` 脚本属 new-task.md §5「docs 级豁免 → 直接 commit」,而 check-lane-surface 检查 A 在
6
+ // 「无活跃入口」时只 advisory —— 两条组合 = 改弱 check-loop.mjs 里某道门可无入口直接提交;
7
+ // ② CI 的机器门跑的是**被测分支自己的** `.agents/scripts/check-loop.mjs`(ci.yml:42)——被改坏的
8
+ // 裁判验证自己的篡改,无外部参照,CI 全绿。
9
+ //
10
+ // 本门的做法:把「门禁本体当前的硬拦面」**冻结进一个入库基线** `templates/_agents/engine-lock.json`,
11
+ // 判定 = 包源盘面向量 ↔ 基线逐项比对(哈希 + 硬拦单位数)。它不是签名、也不宣称不可伪造——
12
+ // 它把「静默改弱」变成「必须显式 --update 的改弱」:
13
+ // · 硬拦单位数**下降**(删 blocker / 删 exit 1 / 从面清单删行 / 从 CI 删一次门调用)→ 无条件 BLOCK;
14
+ // · 内容变了但硬拦面没缩 → 同样 BLOCK,直到跑 --update 把新盘面显式入账;
15
+ // · 基线本身在受检面内(control-surfaces 列了 templates/_agents/engine-lock.json),
16
+ // 改基线同样要过本门(对基线的改动 = 受检面内容变更 → 未入新基线即拦)。
17
+ // 诚实边界:`--no-verify` / 不挂 hooksPath / 删掉本脚本仍可绕过本地门,兜底 = CI 复跑
18
+ // (本脚本被 .github/workflows 的 engine-integrity job 调用)+ 分支保护;本地信任边界内
19
+ // 不可机器防这一点不掩盖(沿 policy.mjs#ledgerChainHash 与 README 确认门口径)。
20
+ //
21
+ // **无声明式豁免(2026-10-13 移除入口退路)**:此前本门有两档豁免——本地层「正文提及即认」、
22
+ // 锚层「活跃 L2/L3 入口在 §改动面 点名」,参照物是 `<root>/workflow/{intents,incidents}`。
23
+ // 但 1.7.15「卸掉同仓双身份」后**包源仓已无该目录**(装户端内容清理),退路在本仓结构性不可达:
24
+ // 门消息让包源维护者去「立 L2 入口」,而立不了——不可达的能力等于误导。故整体移除(连带
25
+ // T4b/T4c/T4d 与锚层严格档测试):本仓只认 --update 这一条显式演进路径,语义更少、更可核对。
26
+ // **代价(诚实记录)**:跨提交锚只比对**硬拦单位数**、不比 sha,故「改写某检查的语义但保住
27
+ // exit 1 计数 + --update」这一形态两层都绿。补法是让锚层也比对 sha,代价是每次合法演进都需一份
28
+ // 显式凭据(改判据面,未决)。
29
+ //
30
+ // 可选外部锚两条(都是「不受被测对象控制的参照物」,缺省不启用=无外部副本/无历史时不误报):
31
+ // --against <目录> 逐字比对包源件与外部副本(装户/发布仓:node_modules/agentic-flow-kit)
32
+ // --against-ref <ref> 跨提交锚(破 CI 自指的关键一环):从**历史 ref** 读基线(git show <ref>:<lock>),
33
+ // 与盘面硬拦单位数做单调比较。本地门只比「盘面 ↔ 同分支基线」——恶意分支可同时改弱门禁并 --update
34
+ // 重写基线,两者自洽、CI 全绿(「裁判给自己判无罪」的第二形态)。跨提交锚的参照物在**祖先提交**里,
35
+ // 改它要 rewrite history + 强推(分支保护拦住),CI 由此获得一个不受被测分支控制的参照。
36
+ // 硬拦面收缩在本层**无豁免出口**:锚的参照物分支改不动,只能改判据基线并走评审。
37
+ // ref 不可解析 / 该 ref 无基线(首个入账提交)→ 跳过并报 ℹ️(锚缺失不误报)。
38
+ //
39
+ // 判据原则(沿 check-lane-surface):只用一手事实——盘面字节哈希与可数单位;
40
+ // 不从时间戳反推过程、不靠文档正文为改动背书。
41
+ // 用法:node check-engine-integrity.mjs [--root <dir>] [--update] [--against <dir>] [--against-ref <ref>] [--quiet]
42
+ // --update 按当前盘面重生成基线(合法演进的唯一路径:演进=显式改基线,不存在自动放行)
43
+ // 退出码:0 通过(含基线缺失=未启用);1 阻断(stderr 出 [标签] 与修法,或目标根不是包源仓);2 用法错误(如 --against 目录不存在)
44
+ import fs from 'node:fs';
45
+ import path from 'node:path';
46
+ import { createHash } from 'node:crypto';
47
+ import { spawnSync } from 'node:child_process';
48
+
49
+ const argv = process.argv.slice(2);
50
+ const argOf = (k) => {
51
+ const i = argv.indexOf(k);
52
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : '';
53
+ };
54
+ const QUIET = argv.includes('--quiet');
55
+ const UPDATE = argv.includes('--update');
56
+
57
+ const ROOT = argOf('--root') || (() => {
58
+ const r = spawnSync('git', ['-C', process.cwd(), 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
59
+ return r.status === 0 ? String(r.stdout || '').trim() : process.cwd();
60
+ })();
61
+ // 只认包源。基线固定 templates/_agents/engine-lock.json。受检面只扫包源模板
62
+ // (scripts / _githooks / templates/_github / .github/workflows / templates/AGENTS.md / templates/_agents/*.txt)。
63
+ // `.github/workflows/*.yml` 是包源自身的 CI 编排——本门的调用点在那儿,不纳入就等于门可被无声关闭。
64
+ // 不扫 .agents、不扫项目根 .githooks、不按「有没有 .agents」猜布局。
65
+ // 无 templates/_agents 时所有模式都拒绝:静默扫到 0 个文件会看起来像通过。
66
+ // render.mjs 排除 .agents/engine-lock.json,避免把包源基线下发进装户。
67
+ const pkgAgents = path.join(ROOT, 'templates', '_agents');
68
+ if (!fs.existsSync(pkgAgents)) {
69
+ console.error(`check-engine-integrity 只在包源仓运行(需要 templates/_agents)。目标根 ${ROOT} 不是包源仓。`);
70
+ process.exit(1);
71
+ }
72
+ const LOCK_REL = 'templates/_agents/engine-lock.json';
73
+ const LOCK_ABS = path.join(ROOT, LOCK_REL);
74
+
75
+ // ---- 受检面(单源:本函数;基线与比对同一份清单,防「基线少记一项」的静默空洞)----
76
+ const gateFiles = () => {
77
+ const out = new Set();
78
+ const add = (rel) => { if (fs.existsSync(path.join(ROOT, rel)) && fs.statSync(path.join(ROOT, rel)).isFile()) out.add(rel); };
79
+ const addDir = (relDir, filter = () => true) => {
80
+ const abs = path.join(ROOT, relDir);
81
+ if (!fs.existsSync(abs)) return;
82
+ for (const f of fs.readdirSync(abs, { withFileTypes: true })) {
83
+ if (!f.isFile()) continue;
84
+ const rel = `${relDir}/${f.name}`;
85
+ if (filter(rel)) add(rel);
86
+ }
87
+ };
88
+ addDir('templates/_agents/scripts', (r) => /\.mjs$/.test(r) && !r.endsWith('.test.mjs'));
89
+ addDir('templates/_githooks');
90
+ addDir('templates/_github/workflows', (r) => r.endsWith('.yml'));
91
+ // 包源自身的机器门编排(.github/workflows/*.yml)。**刻意纳入**:本门是「唯一能防改弱门禁本体」的检查,
92
+ // 而门要成为门就得有人调用它——调用点只存在于 CI 编排里。不纳入的话,删掉编排里的本门调用
93
+ // (或整个 job)没有任何检查会发现:被保护面之外的空白,正是它自身失效的那条路径。
94
+ addDir('.github/workflows', (r) => r.endsWith('.yml'));
95
+ for (const f of ['lane-surfaces.txt', 'control-surfaces.txt', 'workflow-enums.txt', 'workflow-modules.txt',
96
+ 'scripts-test-exempt.txt', 'rule-budgets.txt', 'metric-claims.txt',
97
+ // level-hints.txt(2026-10-13 flow-kit-route 补录):风险级别的**路径下限**清单,直接驱动
98
+ // flow-kit route 定级结果,且语法错会 fail-loud 抛 LEVEL_HINTS_INVALID 改判级结论。
99
+ // 与 lane-surfaces.txt / workflow-modules.txt 同属「喂判据的输入面」,此前漏在受检面外。
100
+ 'level-hints.txt']) {
101
+ add(`templates/_agents/${f}`);
102
+ }
103
+ add('templates/AGENTS.md');
104
+ return [...out].sort();
105
+ };
106
+
107
+ // LF 归一 sha(口径同 src/sync.mjs#shaText:CRLF 检出与 LF 克隆同值,基线跨平台稳定)
108
+ const shaOf = (abs) => createHash('sha256')
109
+ .update(Buffer.from(fs.readFileSync(abs, 'utf8').replace(/\r\n/g, '\n'), 'utf8')).digest('hex');
110
+
111
+ // 硬拦单位数:文件里「这道门会拦下来」的可数形态。判据刻意保守(只数结构性标志,不数注释/文案)——
112
+ // 目的是抓「删掉一道拦人的分支」,不是抓「改写了一句提示」。
113
+ const hardUnits = (rel, text) => {
114
+ const count = (re) => (text.match(re) || []).length;
115
+ if (/\.m?c?js$/.test(rel) && !rel.endsWith('.test.mjs')) return count(/blockers\.push\(/g) + count(/process\.exit\(1\)/g);
116
+ if (/^(\.githooks|templates\/_githooks)\//.test(rel) || /\.sh$/.test(rel) || rel.endsWith('local-pre-commit')) {
117
+ return count(/\bexit\s+1\b/g);
118
+ }
119
+ if (rel.endsWith('.yml')) return count(/node [^\s]*scripts\/[^\s]*\.(mjs|cjs|sh)/g); // CI 里每道门的调用
120
+ if (/\.(txt|json)$/.test(rel)) {
121
+ return text.split(/\r?\n/).filter((l) => l.trim() && !l.trim().startsWith('#')).length; // 面清单:行即判据
122
+ }
123
+ if (rel.endsWith('AGENTS.md')) return count(/^[-#]/gm); // 常驻规则面条目(协议面)
124
+ return 0;
125
+ };
126
+
127
+ const buildLock = () => {
128
+ const files = {};
129
+ for (const rel of gateFiles()) {
130
+ const abs = path.join(ROOT, rel);
131
+ files[rel] = { sha256: shaOf(abs), hard: hardUnits(rel, fs.readFileSync(abs, 'utf8')) };
132
+ }
133
+ return { schema: 1, generatedAt: new Date().toISOString(), files };
134
+ };
135
+
136
+ const readJson = (p) => {
137
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
138
+ };
139
+
140
+
141
+ if (UPDATE) {
142
+ fs.mkdirSync(path.dirname(LOCK_ABS), { recursive: true });
143
+ const lock = buildLock();
144
+ const prev = readJson(LOCK_ABS);
145
+ const changed = prev ? Object.keys(lock.files).filter((k) => !prev.files[k] || prev.files[k].sha256 !== lock.files[k].sha256) : Object.keys(lock.files);
146
+ const removed = prev ? Object.keys(prev.files).filter((k) => !lock.files[k]) : [];
147
+ fs.writeFileSync(LOCK_ABS, JSON.stringify(lock, null, 2) + '\n');
148
+ console.log(`引擎基线已更新:${LOCK_REL}(受检面 ${Object.keys(lock.files).length} 个文件)`);
149
+ if (changed.length) console.log(` 本次入账为改动件:${changed.length} 个`);
150
+ if (removed.length) console.log(` 本次移除(文件已不在盘):${removed.join(' ')}`);
151
+ console.log(' ⚠️ 基线更新本身是控制面改动:须与该次演进的 CHANGELOG 记录同批提交(改基线 = 受检面内容变更,不入新基线即被本门拦)');
152
+ process.exit(0);
153
+ }
154
+
155
+ const lock = readJson(LOCK_ABS);
156
+ const problems = [];
157
+ const current = {};
158
+ for (const rel of gateFiles()) current[rel] = { sha256: shaOf(path.join(ROOT, rel)), hard: hardUnits(rel, fs.readFileSync(path.join(ROOT, rel), 'utf8')) };
159
+
160
+ // ---- 跨提交锚(先于本地基线判定:恶意分支把盘面 engine-lock.json 删掉也逃不掉这一层——参照物读自 git 对象库)----
161
+ if (argOf('--against-ref')) {
162
+ const ref = argOf('--against-ref');
163
+ let anchor = null;
164
+ const r = spawnSync('git', ['-C', ROOT, 'show', `${ref}:${LOCK_REL}`], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
165
+ try { anchor = JSON.parse(String(r.stdout || '')); } catch { /* 该 ref 无基线 */ }
166
+ if (!anchor || !anchor.files) anchor = null;
167
+ if (!anchor) {
168
+ if (!QUIET) console.log(`ℹ️ 跨提交锚缺失(${ref} 无 ${LOCK_REL})——该层跳过(首个入账提交/浅克隆)`);
169
+ } else {
170
+ for (const [rel, was] of Object.entries(anchor.files)) {
171
+ const got = current[rel];
172
+ if (!got) { problems.push(`[门禁件消失(跨提交锚)] ${rel} 在 ${ref} 的基线内但盘面不存在——删除一道门等于禁用该检查`); continue; }
173
+ if (typeof was.hard !== 'number' || got.hard >= was.hard) continue;
174
+ problems.push(`[硬拦面收缩(跨提交锚)] ${rel} 硬拦单位数 ${was.hard}(见 ${ref})→ 盘面 ${got.hard},减少 ${was.hard - got.hard}——祖先提交是参照物,本分支改不动它。本门无声明式豁免出口(2026-10-13 移除:入口退路在本仓结构性不可达,详见文件头),收缩一律拦`);
175
+ }
176
+ }
177
+ }
178
+
179
+ if (!lock || !lock.files) {
180
+ if (!QUIET) console.log(`ℹ️ 引擎基线未启用(无 ${LOCK_REL})——跑 node templates/_agents/scripts/check-engine-integrity.mjs --update 生成(跨提交锚与外部副本锚照常执行)`);
181
+ } else {
182
+ for (const [rel, want] of Object.entries(lock.files)) {
183
+ const got = current[rel];
184
+ const name = path.basename(rel);
185
+ if (!got) { problems.push(`[门禁件消失] ${rel} 在基线内但盘面不存在——删除一道门等于禁用该检查`); continue; }
186
+ if (got.sha256 === want.sha256) continue;
187
+ if (typeof want.hard === 'number' && got.hard < want.hard) {
188
+ problems.push(`[硬拦面收缩] ${rel} 硬拦单位数 ${want.hard} → ${got.hard}(减少 ${want.hard - got.hard})——删 blocker/exit/判据行的结构性形态,一律拦`);
189
+ } else {
190
+ problems.push(`[控制面改动未入基线] ${rel} 内容与基线不符(${name} 硬拦单位数 ${want.hard} → ${got.hard})——门禁本体的每次演进都要显式更新基线,不存在「声明过的改动」自动放行`);
191
+ }
192
+ }
193
+ for (const rel of Object.keys(current)) {
194
+ if (!lock.files[rel]) problems.push(`[门禁件未入基线] ${rel} 是新出现的受检文件——合法新增判据须跑 --update 入账`);
195
+ }
196
+ }
197
+
198
+ if (argOf('--against')) {
199
+ const ext = path.resolve(argOf('--against'));
200
+ if (!fs.existsSync(ext)) { console.error(`用法:--against 目录不存在(${ext})`); process.exit(2); }
201
+ const extProblems = [];
202
+ for (const rel of Object.keys(current)) {
203
+ const extAbs = path.join(ext, rel);
204
+ if (!fs.existsSync(extAbs)) continue; // 外部副本不含该件(如装户无 templates/)——不判
205
+ if (shaOf(extAbs) !== current[rel].sha256) extProblems.push(` ${rel} 与外部已知良好副本失配`);
206
+ }
207
+ if (extProblems.length) problems.push(`[外部锚失配] 受检面与 ${argOf('--against')} 逐字比对不一致(外部裁判不认本次改动面):\n${extProblems.join('\n')}`);
208
+ }
209
+
210
+ if (problems.length) {
211
+ console.error(`引擎完整性 — HARD-BLOCK:\n\n${problems.map((p) => `- ${p}`).join('\n')}\n`);
212
+ console.error(` 修法:确属门禁演进 → 跑 node templates/_agents/scripts/check-engine-integrity.mjs --update 入账,并与 CHANGELOG 记录同批提交(提交评审即对质场;本门无声明式豁免出口,见文件头)`);
213
+ console.error(' 修法:非演进(误改/被他人改动)→ git diff 核对并回退,勿改基线绕过(基线本身在控制面清单内,改它同样是受检面变更)');
214
+ process.exit(1);
215
+ }
216
+ if (!QUIET) console.log(`✅ 引擎完整性:受检面 ${Object.keys(current).length} 个文件(硬拦面无收缩${lock && lock.files ? '、盘面与基线一致' : '、本地基线未启用'})`);
217
+ process.exit(0);
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env node
2
+ // check-engine-integrity.test.mjs — 门禁本体完整性门 fixture 套件(2026-10-09 P0-A guard-the-guards)
3
+ // 方法:构造临时根(templates/_agents/scripts + .githooks + workflow/intents),spawn 脚本断言
4
+ // exit code 与 [标签];跨提交锚用真 git 仓(init→commit→改弱→--update)端到端验证——
5
+ // 那一条是本门存在的理由:本地层自洽(盘面↔基线同刷)时,唯祖先提交参照物仍能出账。
6
+ // 用法:node templates/_agents/scripts/check-engine-integrity.test.mjs(npm test 随跑)
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { spawnSync } from 'node:child_process';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
14
+ const SCRIPT = path.join(SCRIPT_DIR, 'check-engine-integrity.mjs');
15
+
16
+ let pass = 0;
17
+ let fail = 0;
18
+ const check = (desc, cond, detail = '') => {
19
+ if (cond) { pass++; console.log(`PASS ${desc}`); }
20
+ else { fail++; console.log(`FAIL ${desc}${detail ? '\n ' + detail : ''}`); }
21
+ };
22
+
23
+ const w = (root, rel, content) => {
24
+ const abs = path.join(root, rel);
25
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
26
+ fs.writeFileSync(abs, content);
27
+ };
28
+ const run = (root, args = []) => {
29
+ const r = spawnSync(process.execPath, [SCRIPT, '--root', root, ...args], { encoding: 'utf8' });
30
+ return { code: r.status, out: `${r.stdout || ''}${r.stderr || ''}` };
31
+ };
32
+ const git = (root, args) => spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
33
+ const haveGit = (() => { const r = spawnSync('git', ['--version'], { encoding: 'utf8' }); return r.status === 0; })();
34
+
35
+ // 通用 fixture:一份门禁脚本(2 个 blocker + 1 个 exit(1) = 3 硬拦单位)、一个钩子(1 个 exit 1)、AGENTS.md
36
+ const mkfix = ({ withEntry = false, entryNames = 'gate.mjs' } = {}) => {
37
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'engine-integrity-test-'));
38
+ w(d, 'templates/_agents/scripts/gate.mjs',
39
+ '// 门禁样例\nblockers.push(`A`);\nblockers.push(`B`);\nif (bad) process.exit(1);\n');
40
+ w(d, 'templates/_githooks/pre-commit', '#!/bin/sh\nif ! node check.mjs; then\n echo blocked >&2\n exit 1\nfi\nexit 0\n');
41
+ w(d, 'templates/AGENTS.md', '# 协议\n- 规则一\n- 规则二\n');
42
+ if (withEntry) {
43
+ w(d, 'workflow/intents/2026-10-09-ctrl.md',
44
+ `---\n状态: approved\n级别: L2\n日期: 2026-10-09\n模块: gate\n---\n# INTENT\n改动面:templates/_agents/scripts/${entryNames}\n`);
45
+ }
46
+ return d;
47
+ };
48
+
49
+ // --- T1 基线缺失 = 未启用(旧装副本零改动,exit 0)---
50
+ {
51
+ const r = run(mkfix());
52
+ check('T1 无基线 → exit 0 且报「未启用」', r.code === 0 && r.out.includes('未启用'), r.out);
53
+ }
54
+
55
+ // --- T2 --update 生成基线后自身一致 ---
56
+ {
57
+ const d = mkfix();
58
+ const u = run(d, ['--update']);
59
+ const lockPath = path.join(d, 'templates', '_agents', 'engine-lock.json');
60
+ let lock = null;
61
+ try { lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch { /* 断言兜 */ }
62
+ const r = run(d);
63
+ check('T2a --update → exit 0 且基线入账受检面', u.code === 0 && !!lock && !!lock.files['templates/_agents/scripts/gate.mjs'], u.out);
64
+ check('T2b 基线记 hard=3(2 blocker + 1 exit(1))', !!lock && lock.files['templates/_agents/scripts/gate.mjs'].hard === 3, JSON.stringify(lock && lock.files['templates/_agents/scripts/gate.mjs']));
65
+ check('T2c 钩子入面 hard=1(exit 1 计数)', !!lock && lock.files['templates/_githooks/pre-commit'].hard === 1, JSON.stringify(lock && lock.files['templates/_githooks/pre-commit']));
66
+ check('T2d 盘面未变 → exit 0 且报「与基线一致」', r.code === 0 && r.out.includes('盘面与基线一致'), r.out);
67
+ }
68
+
69
+ // --- T3 硬拦单位数下降 → 无条件拦(有入口也不放行)---
70
+ {
71
+ const d = mkfix({ withEntry: true });
72
+ run(d, ['--update']);
73
+ w(d, 'templates/_agents/scripts/gate.mjs', '// 门禁样例(退役一条 blocker)\nblockers.push(`A`);\nif (bad) process.exit(1);\n');
74
+ const r = run(d);
75
+ check('T3 删 blocker.push → [硬拦面收缩] 且点名文件与减量',
76
+ r.code === 1 && r.out.includes('[硬拦面收缩]') && r.out.includes('gate.mjs') && r.out.includes('3 → 2'), r.out);
77
+ check('T3b 有活跃 L2 入口仍拦(收缩无豁免出口)', r.code === 1 && r.out.includes('[硬拦面收缩]'), r.out);
78
+ }
79
+
80
+ // --- T4 非收缩改动:内容漂移即拦(2026-10-13 移除入口退路后无豁免出口)---
81
+ {
82
+ const d = mkfix();
83
+ run(d, ['--update']);
84
+ w(d, 'templates/_agents/scripts/gate.mjs', '// 门禁样例(改写文案)\n// 说明行\ntoast(`hint`);\nblockers.push(`A`);\nblockers.push(`B`);\nif (bad) process.exit(1);\n');
85
+ const drift = run(d);
86
+ check('T4a 内容漂移(硬拦面未缩)→ [控制面改动未入基线]',
87
+ drift.code === 1 && drift.out.includes('[控制面改动未入基线]') && drift.out.includes('gate.mjs'), drift.out);
88
+ check('T4a 修法指向 --update(不再宣传不可达的「立 L2 入口」)',
89
+ drift.out.includes('--update') && !drift.out.includes('立 L2 入口'), drift.out);
90
+ check('T4a --update 入账后放行', run(d, ['--update']).code === 0 && run(d).code === 0);
91
+ }
92
+ // --- T4e 入口面即便存在也不豁免(锁死新口径)---
93
+ // 1.7.15 卸掉同仓双身份后,包源仓没有 workflow/intents —— 旧「立入口即放行」退路在本仓不可达。
94
+ // 删掉该能力后,即使有人在包源仓手工造出入口文档点名该件,**也不得**成为放行口:
95
+ // 否则一个 mkdir 就把门重新打开,等于用未验证的路径恢复未验证的能力。
96
+ {
97
+ const d = mkfix();
98
+ run(d, ['--update']);
99
+ w(d, 'templates/_agents/scripts/gate.mjs', 'blockers.push(`A`);\nblockers.push(`B`);\nif (bad) process.exit(1);\n// 追加注释\n');
100
+ w(d, 'workflow/intents/2026-10-09-ctrl.md',
101
+ '---\n状态: approved\n级别: L2\n日期: 2026-10-09\n模块: gate\n---\n# INTENT\n改动面:gate.mjs\n');
102
+ const r = run(d);
103
+ check('T4e 活跃 L2 入口点名该件 → 仍拦(本门无声明式豁免出口)',
104
+ r.code === 1 && r.out.includes('[控制面改动未入基线]'), r.out);
105
+ }
106
+
107
+ // --- T5 门禁件消失 / 新增件未入基线 ---
108
+ {
109
+ const d = mkfix();
110
+ run(d, ['--update']);
111
+ fs.rmSync(path.join(d, 'templates/_agents/scripts/gate.mjs'));
112
+ let r = run(d);
113
+ check('T5a 删除门禁件 → [门禁件消失]', r.code === 1 && r.out.includes('[门禁件消失]'), r.out);
114
+ }
115
+ {
116
+ const d = mkfix();
117
+ run(d, ['--update']);
118
+ w(d, 'templates/_agents/scripts/newgate.mjs', 'blockers.push(`X`);\n');
119
+ const r = run(d);
120
+ check('T5b 新受检件未入账 → [门禁件未入基线],--update 后放行',
121
+ r.code === 1 && r.out.includes('[门禁件未入基线]') && r.out.includes('newgate.mjs'), r.out);
122
+ run(d, ['--update']);
123
+ check('T5c --update 把新件入账后 exit 0', run(d).code === 0);
124
+ }
125
+
126
+ // --- T6 外部副本锚 ---
127
+ {
128
+ const d = mkfix();
129
+ run(d, ['--update']);
130
+ const bad = run(d, ['--against', path.join(d, 'nope')]);
131
+ check('T6a --against 目录不存在 → 用法错误 exit 2', bad.code === 2 && bad.out.includes('目录不存在'), bad.out);
132
+ const ext = fs.mkdtempSync(path.join(os.tmpdir(), 'engine-integrity-ext-'));
133
+ w(ext, 'templates/_agents/scripts/gate.mjs', '// 外部已知良好副本(与盘面不同)\nblockers.push(`A`);\n');
134
+ const r = run(d, ['--against', ext]);
135
+ check('T6b 与外部副本逐字失配 → [外部锚失配]', r.code === 1 && r.out.includes('[外部锚失配]'), r.out);
136
+ }
137
+
138
+ // --- T7 跨提交锚(真 git 仓端到端:本门存在的理由)---
139
+ if (haveGit) {
140
+ const d = mkfix();
141
+ git(d, ['init', '-q']);
142
+ git(d, ['config', 'user.email', 't@example.com']);
143
+ git(d, ['config', 'user.name', 'fixture']);
144
+ run(d, ['--update']);
145
+ git(d, ['add', '-A']);
146
+ git(d, ['commit', '-q', '-m', 'chore: 基线入账']);
147
+ // 改弱门禁 + 同批刷基线:本地层完全自洽(这正是 CI 自指的形态)
148
+ w(d, 'templates/_agents/scripts/gate.mjs', '// 退役一条判据\nblockers.push(`A`);\nif (bad) process.exit(1);\n');
149
+ run(d, ['--update']);
150
+ const local = run(d);
151
+ check('T7a 改弱 + 刷基线 → 本地层自洽放行(缺陷场景复现)', local.code === 0, local.out);
152
+ const anchored = run(d, ['--against-ref', 'HEAD']);
153
+ check('T7b 跨提交锚(祖先提交基线)出账 [硬拦面收缩(跨提交锚)]',
154
+ anchored.code === 1 && anchored.out.includes('[硬拦面收缩(跨提交锚)]') && /3(见 HEAD)→ 盘面 2/.test(anchored.out), anchored.out);
155
+ // T7c/T7e(2026-10-13 移除入口退路后删除):原为「活跃 L2 入口点名 → 锚层放行」与「严格档:
156
+ // 模糊提及不豁免」两条断言。二者均依赖 workflow/intents 入口面,而该面在本仓结构性不可达
157
+ // (1.7.15 卸掉同仓双身份),故能力与测试一并移除——锚层的收缩判定现在**无豁免出口**。
158
+ // T7c 替代断言:造出入口文档点名该件,锚层仍须拦(锁死「无豁免」不随时间被悄悄恢复)
159
+ w(d, 'workflow/intents/2026-10-09-retire.md',
160
+ '---\n状态: approved\n级别: L2\n日期: 2026-10-09\n模块: gate\n---\n# INTENT\n改动面:templates/_agents/scripts/gate.mjs(退役 B 判据)\n');
161
+ git(d, ['add', '-A']);
162
+ git(d, ['commit', '-q', '-m', 'docs(gate): 造入口文档(应不构成放行口)']);
163
+ const declared = run(d, ['--against-ref', 'HEAD~1']);
164
+ check('T7c 入口文档点名该件 → 锚层仍拦(本门无声明式豁免出口)',
165
+ declared.code === 1 && declared.out.includes('[硬拦面收缩(跨提交锚)]'), declared.out);
166
+ // 锚缺失(首个入账提交之前)→ 跳过不误报
167
+ const missing = run(d, ['--against-ref', 'HEAD~9']);
168
+ check('T7d ref 不可解析 → 锚缺失跳过(exit 0 不假阳性)', missing.code === 0 && missing.out.includes('跨提交锚缺失'), missing.out);
169
+ } else {
170
+ console.log('SKIP T7 跨提交锚(未找到 git)');
171
+ }
172
+
173
+ // --- T8 --quiet 静默面(钩子里调用不刷屏)---
174
+ {
175
+ const d = mkfix();
176
+ run(d, ['--update']);
177
+ const r = run(d, ['--quiet']);
178
+ check('T8 --quiet 通过时不打印 ✅ 行', r.code === 0 && !r.out.includes('✅'), r.out);
179
+ }
180
+
181
+ // --- T9 非包源根:拒绝,不写基线、不建 templates/_agents ---
182
+ {
183
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'engine-integrity-test-'));
184
+ w(d, '.agents/scripts/gate.mjs', 'blockers.push(`A`);\nblockers.push(`B`);\nif (bad) process.exit(1);\n');
185
+ w(d, 'AGENTS.md', '# 协议\n- 规则一\n');
186
+ const u = run(d, ['--update']);
187
+ const plain = run(d);
188
+ check('T9a 非包源根 --update 拒绝', u.code !== 0 && /只在包源仓运行/.test(u.out), u.out);
189
+ check('T9b 非包源根默认模式同样拒绝', plain.code !== 0 && /只在包源仓运行/.test(plain.out), plain.out);
190
+ check('T9c 不写 .agents/engine-lock.json,不建 templates/_agents',
191
+ !fs.existsSync(path.join(d, '.agents', 'engine-lock.json'))
192
+ && !fs.existsSync(path.join(d, 'templates', '_agents')), '');
193
+ fs.rmSync(d, { recursive: true, force: true });
194
+ }
195
+
196
+ // --- T10 包源布局:无 .agents/ → 基线落包源径,不得重建装户目录(装户端内容清理后回归锁)---
197
+ {
198
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'engine-integrity-test-'));
199
+ w(d, 'templates/_agents/scripts/gate.mjs', 'blockers.push(`A`);\nblockers.push(`B`);\nif (bad) process.exit(1);\n');
200
+ w(d, 'templates/AGENTS.md', '# 协议\n- 规则一\n');
201
+ const u = run(d, ['--update']);
202
+ let lock = null;
203
+ try { lock = JSON.parse(fs.readFileSync(path.join(d, 'templates', '_agents', 'engine-lock.json'), 'utf8')); } catch { /* 断言兜 */ }
204
+ check('T10a 无 .agents/ → --update 落 templates/_agents/engine-lock.json 且入账受检面',
205
+ u.code === 0 && !!lock && !!lock.files['templates/_agents/scripts/gate.mjs'], u.out);
206
+ check('T10b --update 不重建 .agents/ 目录(旧实现按装户径兜底会重建已清理的装户目录)',
207
+ !fs.existsSync(path.join(d, '.agents')), '');
208
+ const r = run(d);
209
+ check('T10c 基线启用 → 「盘面与基线一致」且未启用提示点包源径',
210
+ r.code === 0 && r.out.includes('盘面与基线一致'), r.out);
211
+ fs.rmSync(d, { recursive: true, force: true });
212
+ }
213
+
214
+ console.log(`\ncheck-engine-integrity: ${pass} PASS / ${fail} FAIL`);
215
+ process.exit(fail ? 1 : 0);
@@ -1,5 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  // check-lane-surface.mjs — 泳道完整性门禁(pre-commit 增量;2026-10-01 drift-hardening)
3
+ // 布局前提:本门读 .agents/lane-surfaces.txt,豁免出口是 workflow/intents 的活跃 L2/L3 入口——
4
+ // 二者都是**装户侧**件。纯包源仓(装户端内容清理后)两处皆无:清单缺失 = 检查 A/C 按既定
5
+ // 「装户无感」口径跳过,本门整体惰性。此时把清单解析改双路径**只会让门跑起来而不会可用**——
6
+ // 无 L2 入口机器时,任何控制面改动都走「无活跃入口」分支(A 出 advisory / C 出 BLOCK),
7
+ // 纯包源仓的控制面保护改由 npm test(47 套件覆盖门禁逻辑)+ 评审 + CHANGELOG 承担。
3
8
  // 检查 A「触达面判低」:暂存 diff 命中项目 L2 触达面(.agents/lane-surfaces.txt,ERE 逐行;
4
9
  // # 注释与空行忽略;文件缺失或清单为空 = 检查跳过,装户无感)时——
5
10
  // 存在活跃 L2/L3 入口 → 放行(归因歧义为已知边界:机器不猜「哪个入口在干这批改动」);
@@ -10,7 +15,7 @@
10
15
  // 检查 B「探索标记缺失」:experiment/* 分支暂存的 workflow/intents/*.md(排除 _TEMPLATE)
11
16
  // frontmatter 无「阶段: exploring」且无「状态: exploring」(口径同 check-loop 加固门)→ hard 拦。
12
17
  // 标记是加固门覆盖率的前提且补写零成本——advisory 免的是纪律负担,不免标记本身。
13
- // 判据原则(incidents/2026-09-28-batch-ledger-audit 教训):只用一手事实——暂存文件清单、
18
+ // 判据原则(2026-09-28 台账批量代录事故教训):只用一手事实——暂存文件清单、
14
19
  // 当前分支名、工作区 frontmatter 字段;不用时间戳 / 字符串相等反推过程事实。
15
20
  // 边界:git 调用一律 -c core.quotepath=off(非 ASCII 路径教训,同 check-pairing-incremental.sh);
16
21
  // frontmatter 读取容忍 CRLF(显式按 \r?\n 切分);detached HEAD 分支不可判 → A 按 strict、
@@ -25,6 +30,7 @@ import path from 'node:path';
25
30
  import { spawnSync } from 'node:child_process';
26
31
  import { laneOfEntry, laneOfDoc } from './stage-gates.mjs';
27
32
  import { loadKitPolicy } from './policy.mjs';
33
+ import { loadLevelHints, hintLevelFor, rankOf, maxLevel } from './level-criteria.mjs';
28
34
 
29
35
  const argv = process.argv.slice(2);
30
36
  const argOf = (k) => {
@@ -121,32 +127,80 @@ const collectActive = () => {
121
127
  const blocks = [];
122
128
  const advisories = [];
123
129
 
130
+ // --- 检查 A0:level-hints 路径下限对账(2026-10-13 hints-backstop)----------------
131
+ // 背景:此前「就高不就低」只在 flow-kit route 输出 advisory,而提交面判低门只认 lane-surfaces.txt
132
+ // → 两份清单各管一头、**无机器对账**:项目在 hints 里登记了「改这些面至少 L2/L3」,提交面却可能
133
+ // 靠一个 L1 活跃入口放行(判低有机可乘)。本段把 hints 接进提交面:
134
+ // ① hints 命中 → 要求存在**级别 ≥ 命中档**的活跃入口(不只「有个 high 入口」),否则 hard 拦;
135
+ // ② hints 已登记高风险面、lane-surfaces.txt 却缺失/为空 → advisory 提示「意图面登记了、提交面无兜底」。
136
+ // fail-closed:hints 语法错 → hard 拦(写错配置比没配置危险,见 level-criteria parseLevelHints)。
137
+ const hintsState = (() => {
138
+ try {
139
+ const { hints } = loadLevelHints(ROOT);
140
+ return { hints, invalid: null };
141
+ } catch (e) {
142
+ return { hints: [], invalid: e.message };
143
+ }
144
+ })();
145
+
146
+ if (hintsState.invalid) {
147
+ blocks.push([
148
+ '[level-hints 配置错误] .agents/level-hints.txt 非法(档位只收 L2/L3;ERE 不得带行内注释)——配置面坏了整条兜底不可信:',
149
+ ...String(hintsState.invalid).split('\n').map((l) => ` ${l}`),
150
+ ' 修法:修正清单后重试(fail-closed,防静默空洞——写错配置比没配置危险:没配置会提示,写错会误判档位)',
151
+ ].join('\n'));
152
+ }
153
+
154
+ // 命中判定复用判据单源的 hintLevelFor(2026-10-13 executor-review):此前本文件手写了一遍
155
+ // 「路径归一化 + 多条命中取高档」,与 level-criteria.mjs:104 的实现同形但独立演进——
156
+ // 双源漂移是本仓硬规则(doctor 段就写着「不复刻解析器」),一旦归一化口径调整,
157
+ // route 会判 L1 而本门拦 L3,且没有任何测试能发现。
158
+ const hintHits = hintsState.hints.length
159
+ ? STAGED
160
+ .map((p) => ({ p, lv: hintLevelFor(hintsState.hints, [p]).level }))
161
+ .filter((x) => x.lv)
162
+ : [];
163
+ const hintLevel = hintHits.reduce((acc, x) => maxLevel(acc, x.lv), '');
164
+
124
165
  // --- 检查 A:触达面判低 ---
125
166
  const patterns = loadPatterns();
167
+
168
+ if (hintLevel && !patterns.length) {
169
+ advisories.push(
170
+ `[level-hints 无提交面兜底] .agents/level-hints.txt 登记了 ${hintHits.length} 个命中(${hintHits.slice(0, 5).map((x) => `${x.p}→${x.lv}`).join(' ')})——`
171
+ + '但 .agents/lane-surfaces.txt 缺失或为空,触达面判低检查整体跳过:意图面登记了高风险面,提交面却无兜底',
172
+ );
173
+ }
126
174
  const hits = patterns.length ? STAGED.filter((p) => patterns.some((re) => re.test(p))) : [];
127
- if (hits.length) {
175
+ // 本次提交触达到的**最低应然档位**:lane-surfaces 命中隐含 L2(沿原口径),hints 命中按登记档取。
176
+ // 混档取高——与 level-criteria maxLevel 同一口径(就高不就低在提交面同样成立)。
177
+ const requiredLevel = maxLevel(hits.length ? 'L2' : '', hintLevel);
178
+ if (requiredLevel) {
128
179
  const active = collectActive();
129
- const hasHigh = active.some((e) => e.lane === 'high');
130
- if (!hasHigh) {
131
- const low = active.filter((e) => e.lane !== 'high'); // low + suspect 均拦(suspect 不作豁免依据)
132
- if (low.length) {
133
- const newest = [...low].sort((a, b) => (b.date || '').localeCompare(a.date || '') || b.rel.localeCompare(a.rel))[0];
134
- const fixVerb = newest.lane === 'suspect'
135
- ? '① 对齐该入口「级别」与「risk_level」两字段至一致的 L2/L3(fill-intent 双写口径;现两值缺失/非法/分歧)并补同名 spec(.agents/commands/design.md)'
136
- : '① 升级该入口「级别」与「risk_level」两字段至 L2/L3 并补同名 spec(.agents/commands/design.md)';
137
- const text = [
138
- `[触达面判低] 暂存命中 L2 触达面,但活跃入口无泳道 high(存在 low/suspect):`,
139
- ` 命中: ${hits.join(' ')}`,
140
- ` 关联入口: ${newest.rel}(级别 ${newest.level || '缺失'}/risk_level ${newest.risk || '缺失'}→ 泳道 ${newest.lane}${newest.date ? `,${newest.date}` : ''};活跃非 high 共 ${low.length} 件)`,
141
- ` 修法(三选一): ${fixVerb}`,
142
- ` ② 该入口已完成则关单(done)或显式放弃(superseded/cancelled)——不留活跃残留`,
143
- ` ③ 面清单误配则修 .agents/lane-surfaces.txt`,
144
- ].join('\n');
145
- if (isExperiment) advisories.push(`(advisory·experiment 泳道,不阻断;转正由 pre-push 加固门兜底)\n${text}`);
146
- else blocks.push(text);
147
- } else {
148
- advisories.push(`[触达面判低](advisory)暂存命中 L2 触达面(${hits.length} 个文件),但当前无任何活跃入口——若非 docs / bootstrap 豁免类改动(.agents/commands/new-task.md §intent 豁免),请先立入口文档`);
149
- }
180
+ const requiredRank = rankOf(requiredLevel) ?? 1;
181
+ // 入口是否够格:级别 rank ≥ 应然档。**suspect(级别/risk_level 缺失、非法或分歧)一律不够格**——
182
+ // 沿用原口径「suspect 不作豁免依据」:分歧(L2/L1)这类看似够档的入口,机器无法确认它真罩着这批改动。
183
+ const enough = (e) => e.lane !== 'suspect' && (rankOf(e.level) ?? -1) >= requiredRank;
184
+ const insufficient = active.filter((e) => !enough(e));
185
+ if (insufficient.length) {
186
+ const newest = [...insufficient].sort((a, b) => (b.date || '').localeCompare(a.date || '') || b.rel.localeCompare(a.rel))[0];
187
+ const hitList = [...hits.map((p) => `${p}(触达面)`), ...hintHits.map((x) => `${x.p}(hints→${x.lv})`)];
188
+ const fixVerb = newest.lane === 'suspect'
189
+ ? `① 对齐该入口「级别」与「risk_level」两字段至一致的 ${requiredLevel}(fill-intent 双写口径;现两值缺失/非法/分歧)并补同名 spec(.agents/commands/design.md)`
190
+ : `① 升级该入口「级别」与「risk_level」两字段至 ${requiredLevel} 及以上并补同名 spec(.agents/commands/design.md)`;
191
+ const text = [
192
+ `[触达面判低] 暂存命中高风险面,但活跃入口无 ${requiredLevel} 及以上(存在更低档/low/suspect):`,
193
+ ` 命中: ${hitList.join(' ')}`,
194
+ ` 关联入口: ${newest.rel}(级别 ${newest.level || '缺失'}/risk_level ${newest.risk || '缺失'}→ 泳道 ${newest.lane}${newest.date ? `,${newest.date}` : ''};活跃不足共 ${insufficient.length} 件)`,
195
+ ...(hintLevel ? [' 判据:.agents/level-hints.txt 登记的路径下限——就高不就低在提交面同样成立(此前只 advisory,无门)'] : []),
196
+ ` 修法(三选一): ${fixVerb}`,
197
+ ` ② 该入口已完成则关单(done)或显式放弃(superseded/cancelled)——不留活跃残留`,
198
+ ` ③ 清单误配则修 .agents/lane-surfaces.txt${hintLevel ? ' 或 .agents/level-hints.txt' : ''}`,
199
+ ].join('\n');
200
+ if (isExperiment) advisories.push(`(advisory·experiment 泳道,不阻断;转正由 pre-push 加固门兜底)\n${text}`);
201
+ else blocks.push(text);
202
+ } else if (!active.length) {
203
+ advisories.push(`[触达面判低](advisory)暂存命中高风险面(${hits.length + hintHits.length} 个文件,应然 ${requiredLevel}),但当前无任何活跃入口——若非 docs / bootstrap 豁免类改动(.agents/commands/new-task.md §intent 豁免),请先立入口文档`);
150
204
  }
151
205
  }
152
206
 
@@ -171,7 +225,7 @@ if (isExperiment) {
171
225
  for (const a of advisories) console.error(`泳道完整性(增量)— advisory:\n${a}\n`);
172
226
  if (blocks.length) {
173
227
  for (const b of blocks) console.error(`泳道完整性(增量)— BLOCK:\n${b}\n`);
174
- console.error('pre-commit: 泳道完整性未通过,提交已阻止(spec: workflow/specs/2026-10-01-drift-hardening.md)');
228
+ console.error('pre-commit: 泳道完整性未通过,提交已阻止(判据单源见 .agents/scripts/level-criteria.mjs)');
175
229
  process.exit(1);
176
230
  }
177
231
  process.exit(0);