agentic-flow-kit 0.8.0 → 0.9.1

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 (141) hide show
  1. package/README.md +93 -69
  2. package/modules/gates/README.md +24 -0
  3. package/modules/gates/generated-readonly/README.md +13 -0
  4. package/modules/gates/generated-readonly/check-generated.mjs +61 -0
  5. package/modules/gates/generated-readonly/local-pre-commit.line +1 -0
  6. package/modules/gates/node-layer/README.md +9 -0
  7. package/modules/gates/node-layer/check-node-layer.mjs +29 -0
  8. package/modules/gates/node-layer/local-pre-commit.line +1 -0
  9. package/modules/gates/py-import/README.md +9 -0
  10. package/modules/gates/py-import/check-py-import.mjs +30 -0
  11. package/modules/gates/py-import/local-pre-commit.line +1 -0
  12. package/modules/hosts/claude/agents/implementer.md +52 -0
  13. package/modules/hosts/claude/agents/independent-reviewer.md +49 -0
  14. package/modules/hosts/claude/agents/ui-verifier.md +48 -0
  15. package/modules/hosts/claude/commands/wf-build.md +79 -0
  16. package/modules/hosts/claude/commands/wf-deploy.md +57 -0
  17. package/modules/hosts/claude/commands/wf-design.md +42 -0
  18. package/modules/hosts/claude/commands/wf-gate-checklist.md +77 -0
  19. package/modules/hosts/claude/commands/wf-maintain.md +76 -0
  20. package/modules/hosts/claude/commands/wf-new-task.md +65 -0
  21. package/modules/hosts/claude/commands/wf-plan.md +41 -0
  22. package/modules/hosts/claude/commands/wf-review.md +47 -0
  23. package/modules/hosts/claude/commands/wf-source-sync-check.md +89 -0
  24. package/modules/hosts/claude/commands/wf-sync-hosts.md +66 -0
  25. package/modules/hosts/claude/commands/wf-test.md +66 -0
  26. package/modules/hosts/codex/agents/implementer.md +52 -0
  27. package/modules/hosts/codex/agents/independent-reviewer.md +49 -0
  28. package/modules/hosts/codex/agents/ui-verifier.md +48 -0
  29. package/modules/hosts/codex/commands/wf-build.md +79 -0
  30. package/modules/hosts/codex/commands/wf-deploy.md +57 -0
  31. package/modules/hosts/codex/commands/wf-design.md +42 -0
  32. package/modules/hosts/codex/commands/wf-gate-checklist.md +77 -0
  33. package/modules/hosts/codex/commands/wf-maintain.md +76 -0
  34. package/modules/hosts/codex/commands/wf-new-task.md +65 -0
  35. package/modules/hosts/codex/commands/wf-plan.md +41 -0
  36. package/modules/hosts/codex/commands/wf-review.md +47 -0
  37. package/modules/hosts/codex/commands/wf-source-sync-check.md +89 -0
  38. package/modules/hosts/codex/commands/wf-sync-hosts.md +66 -0
  39. package/modules/hosts/codex/commands/wf-test.md +66 -0
  40. package/modules/hosts/codex/skills/flow-kit/SKILL.md +14 -0
  41. package/modules/hosts/cursor/agents/implementer.md +52 -0
  42. package/modules/hosts/cursor/agents/independent-reviewer.md +49 -0
  43. package/modules/hosts/cursor/agents/ui-verifier.md +48 -0
  44. package/modules/hosts/cursor/commands/wf-build.md +79 -0
  45. package/modules/hosts/cursor/commands/wf-deploy.md +57 -0
  46. package/modules/hosts/cursor/commands/wf-design.md +42 -0
  47. package/modules/hosts/cursor/commands/wf-gate-checklist.md +77 -0
  48. package/modules/hosts/cursor/commands/wf-maintain.md +76 -0
  49. package/modules/hosts/cursor/commands/wf-new-task.md +65 -0
  50. package/modules/hosts/cursor/commands/wf-plan.md +41 -0
  51. package/modules/hosts/cursor/commands/wf-review.md +47 -0
  52. package/modules/hosts/cursor/commands/wf-source-sync-check.md +89 -0
  53. package/modules/hosts/cursor/commands/wf-sync-hosts.md +66 -0
  54. package/modules/hosts/cursor/commands/wf-test.md +66 -0
  55. package/modules/hosts/omp/agents/implementer.md +1 -1
  56. package/modules/hosts/omp/agents/independent-reviewer.md +1 -1
  57. package/modules/hosts/omp/agents/ui-verifier.md +1 -1
  58. package/modules/hosts/opencode/agents/implementer.md +1 -1
  59. package/modules/hosts/opencode/agents/independent-reviewer.md +1 -1
  60. package/modules/hosts/opencode/agents/ui-verifier.md +1 -1
  61. package/modules/hosts/opencode/commands/wf-build.md +3 -4
  62. package/modules/hosts/opencode/commands/wf-design.md +2 -2
  63. package/modules/hosts/opencode/commands/wf-maintain.md +1 -1
  64. package/modules/hosts/opencode/commands/wf-plan.md +3 -3
  65. package/modules/hosts/opencode/commands/wf-review.md +1 -1
  66. package/modules/hosts/opencode/commands/wf-source-sync-check.md +1 -1
  67. package/modules/hosts/opencode/commands/wf-test.md +3 -1
  68. package/modules/hosts/trae/agents/implementer.md +1 -1
  69. package/modules/hosts/trae/agents/independent-reviewer.md +1 -1
  70. package/modules/hosts/trae/agents/ui-verifier.md +1 -1
  71. package/modules/hosts/trae/commands/wf-build.md +3 -4
  72. package/modules/hosts/trae/commands/wf-design.md +2 -2
  73. package/modules/hosts/trae/commands/wf-maintain.md +1 -1
  74. package/modules/hosts/trae/commands/wf-plan.md +3 -3
  75. package/modules/hosts/trae/commands/wf-review.md +1 -1
  76. package/modules/hosts/trae/commands/wf-source-sync-check.md +1 -1
  77. package/modules/hosts/trae/commands/wf-test.md +3 -1
  78. package/modules/hosts/zcode/agents/implementer.md +1 -1
  79. package/modules/hosts/zcode/agents/independent-reviewer.md +1 -1
  80. package/modules/hosts/zcode/agents/ui-verifier.md +1 -1
  81. package/package.json +45 -45
  82. package/src/cli.mjs +16 -4
  83. package/src/doctor.mjs +49 -1
  84. package/src/fresh-init.test.mjs +36 -0
  85. package/src/gates.test.mjs +126 -0
  86. package/src/init.mjs +8 -1
  87. package/src/init.test.mjs +7 -1
  88. package/src/profiles.mjs +10 -4
  89. package/src/run-tests.mjs +3 -0
  90. package/src/stack-profile.test.mjs +89 -0
  91. package/src/sync-hosts.test.mjs +26 -6
  92. package/templates/AGENTS.md +4 -4
  93. package/templates/_agents/board/index.html +3 -3
  94. package/templates/_agents/commands/build.md +4 -5
  95. package/templates/_agents/commands/design.md +4 -4
  96. package/templates/_agents/commands/maintain.md +1 -1
  97. package/templates/_agents/commands/new-task.md +13 -2
  98. package/templates/_agents/commands/plan.md +4 -4
  99. package/templates/_agents/commands/review.md +1 -1
  100. package/templates/_agents/commands/source-sync-check.md +1 -1
  101. package/templates/_agents/commands/test.md +4 -2
  102. package/templates/_agents/hooks/commit-check.cjs +45 -24
  103. package/templates/_agents/lane-surfaces.txt +14 -0
  104. package/templates/_agents/roles/implementer.md +1 -1
  105. package/templates/_agents/roles/independent-reviewer.md +1 -1
  106. package/templates/_agents/roles/ui-verifier.md +1 -1
  107. package/templates/_agents/scripts/check-lane-surface.mjs +164 -0
  108. package/templates/_agents/scripts/check-lane-surface.test.mjs +200 -0
  109. package/templates/_agents/scripts/check-loop-rev.test.mjs +337 -0
  110. package/templates/_agents/scripts/check-loop.mjs +460 -268
  111. package/templates/_agents/scripts/check-loop.test.mjs +677 -17
  112. package/templates/_agents/scripts/check-metric-claims.mjs +275 -0
  113. package/templates/_agents/scripts/commit-check-trigger.test.mjs +64 -1
  114. package/templates/_agents/scripts/confirm-doc.mjs +159 -15
  115. package/templates/_agents/scripts/confirm-doc.test.mjs +362 -10
  116. package/templates/_agents/scripts/doctor.test.mjs +102 -1
  117. package/templates/_agents/scripts/fill-intent.mjs +8 -7
  118. package/templates/_agents/scripts/fill-intent.test.mjs +12 -3
  119. package/templates/_agents/scripts/fill-plan.mjs +27 -11
  120. package/templates/_agents/scripts/fill-plan.test.mjs +148 -8
  121. package/templates/_agents/scripts/fill-spec.mjs +18 -3
  122. package/templates/_agents/scripts/fill-spec.test.mjs +198 -1
  123. package/templates/_agents/scripts/gate-checklist.mjs +4 -1
  124. package/templates/_agents/scripts/gen-workflow-index.mjs +6 -2
  125. package/templates/_agents/scripts/gen-workflow-index.test.mjs +12 -0
  126. package/templates/_agents/scripts/policy.mjs +46 -0
  127. package/templates/_agents/scripts/pre-push.test.mjs +82 -0
  128. package/templates/_agents/scripts/solidify-task.mjs +107 -0
  129. package/templates/_agents/scripts/solidify-task.test.mjs +126 -0
  130. package/templates/_agents/scripts/stage-gates.mjs +256 -0
  131. package/templates/_agents/scripts/trust-mode.mjs +67 -0
  132. package/templates/_agents/scripts/wf-journal.mjs +71 -2
  133. package/templates/_agents/scripts/wf-journal.test.mjs +66 -0
  134. package/templates/_agents/scripts/workflow-board-server.mjs +18 -15
  135. package/templates/_agents/scripts/workflow-board-server.test.mjs +30 -1
  136. package/templates/_agents/workflows/pipeline-closing.md +1 -1
  137. package/templates/_githooks/pre-commit +33 -2
  138. package/templates/_githooks/pre-push +58 -10
  139. package/templates/workflow/README.md +22 -3
  140. package/templates/workflow/incidents/_TEMPLATE.md +4 -0
  141. package/templates/workflow/intents/_TEMPLATE.md +1 -0
@@ -13,7 +13,9 @@
13
13
  // 违例输出到 stderr 并 exit 1;只剩警告时 exit 0。
14
14
  //
15
15
  // 检查项清单(编号/标题/severity 逐条沿 sh 版头部——gate-checklist 配对登记表按 id 消费,不得增删改号):
16
- // 1. 入口文档/spec/plan 同名配对(L1 必须有 plan;L2/L3 必须有 spec+plan) [hard-block]
16
+ // 1. 入口文档/spec/plan 同名配对(L1 必须有 plan;L2/L3 必须有 spec+plan;L0 协作道豁免 plan;
17
+ // L0/L1 勾触达红线 → 「红线判低」hard——协作道不受理 STOP 级改动,就高不就低,
18
+ // 2026-09-30 hybrid-governance-risk-lanes) [hard-block]
17
19
  // 2. 模板字段占位符残留(YYYY-MM-DD / <主题> 等未替换;<主题> 与 .md 同行 = 命名约定描述,豁免) [warning]
18
20
  // 3. incidents 复盘三件套完整性 + 状态严格枚举 + 新 intent 回路(回路断档=hard,其他=warning)
19
21
  // 4. 引用有效性(文档/指令中引用的 .agents/ 路径必须存在;支持 fill-{a,b,c}.mjs 花括号展开与 fill-*.mjs 通配;
@@ -65,6 +67,12 @@
65
67
  // 等于引导伪装)/噪声不可消退(不按日期门豁免,退役前本仓 22 条 advisory 中 14 条为该告警)/
66
68
  // quote 字段职责冲突(对质凭据 vs 并录指纹,现归还单一职责)。无 batch 的历史行静默跳过
67
69
  // (无判定依据的行不产出不可消除噪声——沿 audit-gate-hardening P3 教训)
70
+ // 17. 发版提交树上仍未收口的 intent/spec/plan [hard-block]
71
+ // 18. 委派台账对账 [warning]
72
+ // 19. 逐阶段审计(起草先于入口确认 / 台账审批顺序倒置) [warning]
73
+ // 判据 A=在途扫描(同名 spec/plan 为 draft 且日期≥stageGateSince 时判入口确认;plan 另判 L2/L3 的 spec);
74
+ // 判据 B=台账 approved 行按主题校验 intents≤specs≤plans 顺序(组内最早 ts 日期≥stageGateSince 才判);
75
+ // stageGateSince 缺键(v1) → 本检查整体跳过;口径与 stage-gates.mjs / fill-* / confirm-doc 前置门互引
68
76
  // 16. 量化断言指标签名对账 [warning](2026-09-28 起;登记表单源 .agents/metric-claims.txt):
69
77
  // 判据 = 活跃态文档(draft/approved/open)中的 `{{指标名}}` 签名须替换为实时值,留签名=未回填=warning。
70
78
  // **只查显式签名、不全文扫数字**(据实说明):本仓活跃文档「N 行/N 条/N 份」类表述数十处,绝大多数是
@@ -80,21 +88,63 @@
80
88
  // 运行模式:CHECK_LOOP_ROOT 注入 fixture 根(全扫不过滤,非 git 时检查 10/14 跳过);
81
89
  // 缺省 = git 仓库根(不在 git 仓库内 stderr 提示后 exit 0 跳过);仓库模式只扫已提交(HEAD)内容
82
90
  // (并行会话未跟踪半成品不拦别人的 push;ls-tree 失败退化为全扫,门禁不失效)。
91
+ // --rev <sha>:把该提交剥到 commit 后挂到临时 detached worktree,ROOT 改到那里再扫。
92
+ // 原工作区的未提交改动不进入这次扫描。与 CHECK_LOOP_ROOT 同时出现则 exit 1。全 0 sha exit 1。
83
93
  // 文档协议:机器字段一律由文件头 YAML frontmatter(受限子集:每行 `键: 值`)承载,本脚本只扫 frontmatter 取字段;
84
94
  // 叙述性字段(独立复核/复盘三件套/验收勾验)按正文行锚定。枚举单源 .agents/workflow-enums.txt
85
95
  // (缺文件/缺键 fail-loud exit 1;经 workflow-enums.mjs 读取,CRLF 天然容忍)。
86
96
  // 已确认状态: approved/done=已批或闭环; superseded/cancelled=放弃留档(仍算确认,不挡 push); incident: fixed/closed
87
- // 用法:node .agents/scripts/check-loop.mjs (或经 check-loop.sh shim)
97
+ // 用法:node .agents/scripts/check-loop.mjs [--rev <sha>] [--hardening] (或经 check-loop.sh shim)
98
+ // --hardening:追加「加固门」(experiment 泳道 exploring 任务入 main 的转正门槛,hard-block;
99
+ // .githooks/pre-push 对 remote=refs/heads/main 的推送传入;不占 1-19 编号,gate-checklist 登记表
100
+ // 按号配对面不变,门体说明见加固门段注释)
88
101
  // 测试:node templates/_agents/scripts/check-loop.test.mjs(fixture 注入 CHECK_LOOP_ROOT)
89
102
  import fs from 'node:fs';
103
+ import os from 'node:os';
90
104
  import path from 'node:path';
91
105
  import { spawnSync } from 'node:child_process';
92
106
  import { createHash } from 'node:crypto';
93
107
  import { createRequire } from 'node:module';
94
108
  import { fileURLToPath } from 'node:url';
95
109
  import { loadEnums } from './workflow-enums.mjs';
110
+ import { runCheck16 } from './check-metric-claims.mjs';
111
+ import { auditEnabled, loadKitPolicy } from './policy.mjs';
112
+ // MARK_RE:incident 留痕形态的单源(第七轮复核 N3——此前 check-loop 内联复制一份同口径字面量,
113
+ // 两处靠注释与人工同步;改为复用 stage-gates.mjs 的导出,从结构上消除漂移可能)
114
+ import { MARK_RE, approvedTraceHit } from './stage-gates.mjs';
96
115
 
97
116
  const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
117
+ const GIT = process.platform === 'win32' ? 'git.exe' : 'git';
118
+
119
+ function takeOpts() {
120
+ const args = process.argv.slice(2);
121
+ const opts = { rev: null, hardening: false };
122
+ for (let i = 0; i < args.length; i++) {
123
+ if (args[i] === '--rev') {
124
+ opts.rev = args[++i] ?? '';
125
+ if (!opts.rev) {
126
+ console.error('check-loop: 用法 node check-loop.mjs [--rev <sha>] [--hardening]');
127
+ process.exit(1);
128
+ }
129
+ } else if (args[i] === '--hardening') {
130
+ opts.hardening = true; // 加固门(2026-09-30 hybrid-governance-explore-hardening):pre-push 对 main 目标传入
131
+ } else {
132
+ console.error('check-loop: 用法 node check-loop.mjs [--rev <sha>] [--hardening]');
133
+ process.exit(1);
134
+ }
135
+ }
136
+ return opts;
137
+ }
138
+ const { rev: REV_ARG, hardening: HARDENING } = takeOpts();
139
+ if (REV_ARG && process.env.CHECK_LOOP_ROOT) {
140
+ console.error('check-loop: CHECK_LOOP_ROOT 与 --rev 不能同时使用');
141
+ process.exit(1);
142
+ }
143
+ if (REV_ARG && /^0+$/.test(REV_ARG)) {
144
+ console.error(`check-loop: --rev 不是提交 ${REV_ARG}`);
145
+ process.exit(1);
146
+ }
147
+ let REV = null;
98
148
 
99
149
  // ---- 根目录与枚举单源 ----
100
150
  let ROOT;
@@ -107,10 +157,38 @@ if (process.env.CHECK_LOOP_ROOT) {
107
157
  } else {
108
158
  const top = gitOut(['rev-parse', '--show-toplevel']);
109
159
  if (top === null) {
160
+ if (REV_ARG) {
161
+ console.error(`check-loop: --rev 剥不到提交 ${REV_ARG}`);
162
+ process.exit(1);
163
+ }
110
164
  console.error('check-loop: 不在 git 仓库内,跳过扫描');
111
165
  process.exit(0);
112
166
  }
113
167
  ROOT = top.trim();
168
+ if (REV_ARG) {
169
+ const peeled = gitOut(['rev-parse', '--verify', `${REV_ARG}^{commit}`]);
170
+ if (!peeled) {
171
+ console.error(`check-loop: --rev 剥不到提交 ${REV_ARG}`);
172
+ process.exit(1);
173
+ }
174
+ REV = peeled.trim();
175
+ const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-rev-'));
176
+ const wt = path.join(parent, 'wt');
177
+ const main = ROOT;
178
+ const add = spawnSync(GIT, ['worktree', 'add', '--detach', '--quiet', wt, REV], {
179
+ cwd: main, encoding: 'utf8',
180
+ });
181
+ if (add.status !== 0) {
182
+ fs.rmSync(parent, { recursive: true, force: true });
183
+ console.error(`check-loop: 导出提交失败 ${REV_ARG}\n${add.stderr || add.stdout || ''}`);
184
+ process.exit(1);
185
+ }
186
+ process.on('exit', () => {
187
+ spawnSync(GIT, ['worktree', 'remove', '--force', wt], { cwd: main });
188
+ fs.rmSync(parent, { recursive: true, force: true });
189
+ });
190
+ ROOT = wt;
191
+ }
114
192
  }
115
193
  // 枚举单源(<root>/.agents/workflow-enums.txt——fixture 契约:缺文件/缺键 fail-loud)
116
194
  let ENUMS;
@@ -130,7 +208,7 @@ const stOkDoc = (st) => inSet(st, ENUMS['doc.status.confirmed']);
130
208
 
131
209
  // git 调用(参数数组形式不走 shell——Windows cmd 不认单引号;git.exe 显式解析沿 doctor 先例)
132
210
  function gitOut(args, { ok = () => true } = {}) {
133
- const r = spawnSync(process.platform === 'win32' ? 'git.exe' : 'git', args,
211
+ const r = spawnSync(GIT, args,
134
212
  { cwd: ROOT, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
135
213
  if (r.error || r.status !== 0 || !ok(r)) return null;
136
214
  return r.stdout || '';
@@ -161,6 +239,12 @@ const WF = 'workflow';
161
239
  const DOC_DIRS = ['intents', 'specs', 'plans', 'incidents'];
162
240
  const blockers = [];
163
241
  const warnings = [];
242
+ // audit:false(init 新装)只保留 blockers。缺省与 audit:true 保持全量警告。
243
+ // 硬规则:配对 / 验收勾验的阻断 / 确认留痕 / 发版草稿。卫生项走 warnings。
244
+ const kitPolicy = loadKitPolicy(ROOT);
245
+ if (!auditEnabled(kitPolicy)) {
246
+ warnings.push = () => warnings.length;
247
+ }
164
248
 
165
249
  // ---- 已提交(HEAD)过滤:仓库模式只扫 tracked(fixture 模式恒真;ls-tree 失败退化全扫)----
166
250
  let trackedSet = null; // null = 不过滤
@@ -179,11 +263,12 @@ const docFiles = (sub) => {
179
263
  .sort();
180
264
  };
181
265
 
182
- // --- 1. intent/spec/plan 同名配对 + 状态确认 + L3 确认三件 [hard-block] ---
266
+ // --- 1. intent/spec/plan 同名配对 + 状态确认 + L3 确认三件 + 风险泳道一致性 [hard-block] ---
183
267
  for (const intent of docFiles('intents')) {
184
268
  const base = path.basename(intent);
185
269
  const st = fmGet(intent, '状态');
186
270
  const lvl = fmGet(intent, '级别');
271
+ const risk = fmGet(intent, 'risk_level'); // 风险泳道字段(fill-intent 落,=级别;2026-09-30 hybrid-governance-risk-lanes)
187
272
  if (!stOkDoc(st)) {
188
273
  const msg = `${base}(frontmatter 状态键当前值:『${st || '缺失'}』)`;
189
274
  if (lvl === 'L3') blockers.push(`- [状态未确认] L3 intent 必须为 approved/done/superseded/cancelled:${msg}`);
@@ -194,9 +279,19 @@ for (const intent of docFiles('intents')) {
194
279
  blockers.push(`- [配对断裂] intent 缺 spec:${base}(应在 ${WF}/specs/ 下同名)`);
195
280
  }
196
281
  }
197
- if (!fs.existsSync(path.join(ROOT, WF, 'plans', base))) {
282
+ // 协作道(L0)豁免 plan 配对——异步审计兜底(hybrid-governance-risk-lanes);L1 仍须 plan,L2/L3 须 spec+plan
283
+ if (lvl !== 'L0' && !fs.existsSync(path.join(ROOT, WF, 'plans', base))) {
198
284
  blockers.push(`- [配对断裂] intent 缺 plan:${base}(应在 ${WF}/plans/ 下同名)`);
199
285
  }
286
+ // 风险泳道一致性:L0/L1(级别或 risk_level 任一判定)勾选触达红线 = 自认触及规则/契约或数据/运行时
287
+ // 结构面——就高不就低,协作道不受理 → hard 判低,须升级 L2/L3 并补同名 spec
288
+ if (['L0', 'L1'].includes(lvl) || ['L0', 'L1'].includes(risk)) {
289
+ const redlineTouched = (linesOf(intent) || [])
290
+ .some((l) => /^- \[x\]/.test(l) && /(\$\\rightarrow\$|→)\s*级别|级别至少\s*L[23]/.test(l));
291
+ if (redlineTouched) {
292
+ blockers.push(`- [红线判低] ${base} 级别/风险泳道为 ${[lvl, risk].filter(Boolean).join('/')} 但触达红线已勾选(规则/契约或 schema/结构面)——协作道不受理 STOP 级改动:就高不就低,先把「级别/risk_level」升到 L2/L3 并补同名 spec`);
293
+ }
294
+ }
200
295
  }
201
296
 
202
297
  for (const spec of docFiles('specs')) {
@@ -456,6 +551,53 @@ const addedDateOf = (absDoc) => {
456
551
  return iso ? iso.slice(0, 10) : '';
457
552
  };
458
553
 
554
+ // 证据真相校验:检查证据字符串是否包含合法的 commit SHA,且该 commit 触及了 plan 声明的文件
555
+ function verifyEvidenceTruth(evidenceStr, planBase, root) {
556
+ const shaMatch = evidenceStr.match(/\b([a-f0-9]{7,40})\b/i);
557
+ // 排除纯数字串(时间戳/ID),必须是包含字母的 hex 串才疑似 SHA
558
+ if (!shaMatch || /^\d+$/.test(shaMatch[1])) return { ok: true, type: 'text' };
559
+
560
+ const sha = shaMatch[1];
561
+ const revParse = spawnSync(GIT, ['rev-parse', sha], { cwd: ROOT, encoding: 'utf8' });
562
+ if (revParse.status !== 0) {
563
+ // 区分本地伪造与外部仓库引用:
564
+ // 剔除 SHA 与泛指词后仍有其他内容(如项目名、路径描述)→ 视为外部仓库引用,不硬拦(本地无法核验他仓哈希);
565
+ // 剔除后为空(如纯「commit a1b2c3d」「提交 a1b2c3d」)→ 视为本地引用伪造,硬拦。
566
+ const residue = evidenceStr.replace(sha, '')
567
+ .replace(/证据[::]|commit|提交|[()()。—-]/gi, '')
568
+ .trim();
569
+ if (residue.length > 0) return { ok: true, type: 'external' };
570
+ return { ok: false, type: 'forged', msg: `提交 ${sha} 不存在` };
571
+ }
572
+
573
+ // 语义核验:该 commit 必须触及 plan 中声明的任何一个文件
574
+ const planPath = path.join(ROOT, WF, 'plans', planBase + '.md');
575
+ if (!fs.existsSync(planPath)) return { ok: true, type: 'no-plan' }; // 缺 plan 走 Check 1 拦截
576
+
577
+ const planLines = linesOf(planPath) || [];
578
+ const declaredFiles = [];
579
+ // 扫描「改动面」或「任务拆解」节
580
+ let inDeclaredSec = false;
581
+ for (const line of planLines) {
582
+ if (/^##\s+(改动面|任务拆解)/.test(line)) { inDeclaredSec = true; continue; }
583
+ if (inDeclaredSec && /^##\s+/.test(line)) { inDeclaredSec = false; continue; }
584
+ if (inDeclaredSec) {
585
+ const fileMatch = line.match(/([a-zA-Z0-9._\/-]+\.[a-zA-Z0-9]+)/);
586
+ if (fileMatch) declaredFiles.push(fileMatch[1]);
587
+ }
588
+ }
589
+
590
+ const show = spawnSync(GIT, ['show', '--name-only', sha], { cwd: ROOT, encoding: 'utf8' });
591
+ if (show.status !== 0) return { ok: false, type: 'error', msg: `无法读取提交 ${sha} 的文件列表` };
592
+
593
+ const changedFiles = show.stdout.split(/\r?\n/).filter(Boolean);
594
+ const hasIntersection = changedFiles.some(f => declaredFiles.some(df => f.includes(df) || df.includes(f)));
595
+
596
+ if (!hasIntersection) return { ok: false, type: 'irrelevant', msg: `提交 ${sha} 未触及 plan 声明的任何文件` };
597
+
598
+ return { ok: true, type: 'sha' };
599
+ }
600
+
459
601
  // --- 8. intent 验收标准对账(done 须逐条勾验并补证据;新建 hard,存量聚合 warning)---
460
602
  // 生效日锚 2026-09-28 改「git 首次加入日期」(此前取文件名前 10 字符——命名规范强制的字段、
461
603
  // 写早零成本,见 incidents/2026-09-28-check8-git-anchor)。非 git → 不可判定 → 走存量口径(不误报 hard)。
@@ -472,16 +614,40 @@ const addedDateOf = (absDoc) => {
472
614
  let hs = false, uc = false, ne = false;
473
615
  let insec = false, ex = false;
474
616
  let pendX = false; // 上一 [x] 项尚无证据,证据可能在紧随的续行
617
+ let currentEvidence = ''; // 当前条目的证据内容,用于语义校验
475
618
  const evRe = /证据[::]/;
476
- const closeItem = () => { if (pendX) { ne = true; pendX = false; } };
619
+ const closeItem = () => {
620
+ if (pendX) {
621
+ ne = true;
622
+ pendX = false;
623
+ }
624
+ if (currentEvidence && !ex) {
625
+ const truth = verifyEvidenceTruth(currentEvidence, base.replace(/\.md$/, ''), ROOT);
626
+ if (!truth.ok) {
627
+ blockers.push(`- [证据${truth.type === 'forged' ? '伪造' : '无关'}] ${base} 验收证据校验失败: ${truth.msg}`);
628
+ }
629
+ }
630
+ currentEvidence = '';
631
+ };
477
632
  for (const line of lines) {
478
633
  if (/存量对账豁免(/.test(line)) ex = true;
479
634
  if (/^\s*##\s+[^#]*验收标准/.test(line)) { hs = true; insec = true; closeItem(); continue; }
480
635
  if (insec && /^\s*##\s/.test(line)) { insec = false; closeItem(); continue; }
481
636
  if (!insec) continue;
482
637
  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; // 续行补上证据
638
+ if (/^\s*- \[x\]/.test(line)) {
639
+ closeItem();
640
+ pendX = !evRe.test(line);
641
+ if (evRe.test(line)) currentEvidence = line.split(/证据[::]/)[1].trim();
642
+ continue;
643
+ }
644
+ if (pendX && evRe.test(line)) {
645
+ pendX = false;
646
+ currentEvidence = line.split(/证据[::]/)[1].trim();
647
+ } else if (currentEvidence && line.trim() && !/^\s*- /.test(line)) {
648
+ // 简单累加续行证据
649
+ currentEvidence += ' ' + line.trim();
650
+ }
485
651
  }
486
652
  closeItem(); // 节末(或全文末)仍无证据 → 计缺证据
487
653
  // 生效日锚 = git 首次加入日期(2026-09-28 改;此前取文件名前 10 字符)。**准确收益**:关掉
@@ -496,12 +662,12 @@ const addedDateOf = (absDoc) => {
496
662
  continue;
497
663
  }
498
664
  if (!uc && !ne) continue;
499
- if (isNew) {
500
- if (uc) blockers.push(`- [验收未对账] done intent 验收标准有未勾验项:${base}(逐条勾验并补证据:commit/用例/冒烟输出)`);
501
- if (ne) warnings.push(`- [WARN 验收缺证据] ${base} 验收标准勾选项缺「证据:」标注`);
502
- } else if (!ex) {
503
- legacyUnaccounted++;
504
- }
665
+ if (isNew) {
666
+ if (uc) blockers.push(`- [验收未对账] done intent 验收标准有未勾验项:${base}(逐条勾验并补证据:commit/用例/冒烟输出)`);
667
+ if (ne) blockers.push(`- [验收缺证据] ${base} 验收标准勾选项缺「证据:」标注——证据是闭环准确性的唯一机器事实`);
668
+ } else if (!ex) {
669
+ legacyUnaccounted++;
670
+ }
505
671
  }
506
672
  if (legacyUnaccounted > 0) {
507
673
  warnings.push(`- [WARN 验收对账存量] ${legacyUnaccounted} 个存量 done intent 验收标准未对账(生效日锚之前加入仓库,豁免 hard,不回填)`);
@@ -576,8 +742,8 @@ if (gitOut(['rev-parse', '--git-dir']) !== null) {
576
742
  if (!d) d = /^\d{4}-\d{2}-\d{2}$/.test(base.slice(0, 10)) ? base.slice(0, 10) : '';
577
743
  if (mod) {
578
744
  if (!vocab.has(mod)) warnings.push(`- [WARN 模块元数据] ${base}「模块: ${mod}」不在词表(见 .agents/workflow-modules.txt)`);
579
- } else if (/^\d{4}-\d{2}-\d{2}$/.test(d) && d >= '2026-09-22') {
580
- warnings.push(`- [WARN 模块元数据] ${base} 缺「模块:」字段(2026-09-22 起新建文档必填)`);
745
+ } else if (/^\d{4}-\d{2}-\d{2}$/.test(d) && d >= kitPolicy.moduleSince) {
746
+ warnings.push(`- [WARN 模块元数据] ${base} 缺「模块:」字段(${kitPolicy.moduleSince} 起新建文档必填)`);
581
747
  }
582
748
  }
583
749
  }
@@ -605,12 +771,14 @@ if (gitOut(['rev-parse', '--git-dir']) !== null && gitOut(['rev-parse', '-q', '-
605
771
  const base = path.basename(doc);
606
772
  let d = fmGet(doc, '日期') || fmGet(doc, '发现');
607
773
  if (!d) d = /^\d{4}-\d{2}-\d{2}$/.test(base.slice(0, 10)) ? base.slice(0, 10) : '';
608
- if (!/^\d{4}-\d{2}-\d{2}$/.test(d) || d < '2026-09-23') continue; // 生效 2026-09-23 起(规则发布次日)
774
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(d) || d < kitPolicy.check14Since) continue; // policy.mjs check14Since
609
775
  const lines = linesOf(doc) || [];
610
776
  if (lines.some((l) => l.includes('存量确认态豁免('))) continue;
611
777
  const rel = path.relative(ROOT, doc).split(path.sep).join('/');
612
- const hit = gitOut(['log', '-1', '--format=%H', '-G', '^状态:[[:space:]]*approved', '--', rel]);
613
- if (hit !== null && hit.trim() === '') {
778
+ // 命中判据单源(stage-gates.approvedTraceHit,2026-09-30 confirm-gate-approved-history):
779
+ // 返回 ''=从未出现 / <sha>=命中 / null=git 调用失败(不报,沿本段 fail-open 口径)
780
+ const hit = approvedTraceHit(ROOT, rel);
781
+ if (hit !== null && hit === '') {
614
782
  warnings.push(`- [WARN 确认态缺失] ${base} 状态已 done 但 git 历史中从未出现行首「状态: approved」——确认环节未留痕(draft 直跳 done)`);
615
783
  }
616
784
  }
@@ -631,7 +799,7 @@ if (gitOut(['rev-parse', '--git-dir']) !== null && gitOut(['rev-parse', '-q', '-
631
799
  // 独立生效日 2026-09-28 常量退役:台账起算日 = docs 生效日,两档等价)。
632
800
  // 台账坏行容忍跳过(审计件,jsonl 追加式);frontmatter 存 16 位、台账存 64 位,按前 16 位配对。
633
801
  {
634
- const EFFECTIVE = '2026-09-27';
802
+ const EFFECTIVE = kitPolicy.confirmDocsEffective;
635
803
  const ledgerPath = path.join(ROOT, '.agents', 'confirmations.jsonl');
636
804
  const ledger = [];
637
805
  if (fs.existsSync(ledgerPath)) {
@@ -666,7 +834,7 @@ if (gitOut(['rev-parse', '--git-dir']) !== null && gitOut(['rev-parse', '-q', '-
666
834
  // 仅靠 ① 会被误判为存量豁免而静默放行)。
667
835
  // 两条件皆不满足 = 存量豁免(生效日前既有、从未走确认门,不追溯)。
668
836
  const docEntries = ledger.filter((e) => e && e.doc === rel && VALID_STAGES.has(e.stage));
669
- const eff = sub === 'incidents' ? '2026-09-28' : EFFECTIVE;
837
+ const eff = sub === 'incidents' ? kitPolicy.confirmIncidentsEffective : EFFECTIVE;
670
838
  let d = fmGet(doc, '日期') || fmGet(doc, '发现');
671
839
  if (!d) d = /^\d{4}-\d{2}-\d{2}$/.test(base.slice(0, 10)) ? base.slice(0, 10) : '';
672
840
  const dateManaged = /^\d{4}-\d{2}-\d{2}$/.test(d) && d >= eff;
@@ -690,7 +858,7 @@ if (gitOut(['rev-parse', '--git-dir']) !== null && gitOut(['rev-parse', '-q', '-
690
858
  const entry = doneEntries[doneEntries.length - 1]; // append-only 台账,末次生效(重确认场景)
691
859
  if (!entry || !entry.prev) {
692
860
  warnings.push(`- [WARN 绑定降级] ${base} 台账 stage=${st} 行缺 prev 字段(schema 演进前行),内容绑定跳过——仅配对判定`);
693
- } else if (!(typeof entry.ts === 'string' && entry.ts >= '2026-09-28')) {
861
+ } else if (!(typeof entry.ts === 'string' && entry.ts >= kitPolicy.bindingTs)) {
694
862
  // 该 done 确认发生在生效锚前(旧关单顺序时代)——豁免内容绑定,配对判定照常
695
863
  } else if (bindingSha256(linesOf(doc) || [], entry.prev) !== entry.fingerprint) {
696
864
  blockers.push(`- [确认内容漂移] ${base} ${st} 后内容与确认台账不符——已关单文档不得直接改(关单编辑先于关单确认);确需修订走 superseded 或新 intent 引用`);
@@ -725,6 +893,10 @@ if (gitOut(['rev-parse', '--git-dir']) !== null && gitOut(['rev-parse', '-q', '-
725
893
  // of > 1 = 一次调用落多份态。delegated 形态下这正是「并录」(入口已拒多份,此处抓历史/绕行);
726
894
  // TTY 形态天然逐份过目(用户亲手键入),of>1 不构成违规——故只对 delegated 行报。
727
895
  if (!rows.some((r) => r.source === 'chat-delegated')) continue;
896
+ // 协作道批量豁免(2026-09-30 hybrid-governance-risk-lanes):confirm-doc --batch 仅受理 L0/L1
897
+ // (逐份读级别,L2/L3 逐份拒绝),台账行带 brief:true——全行带标记的批次是 sanctioned 泳道行为
898
+ // (异步审计兜底),不出「确认并录」;缺标记(旧版脚本/手造台账)照旧告警,判据偏严不偏松。
899
+ if (rows.every((r) => r.brief === true)) continue;
728
900
  const of = Math.max(...rows.map((r) => (typeof r.of === 'number' ? r.of : 0)));
729
901
  const docs = rows.map((r) => String(r.doc).replace(/^workflow\//, '')).join('、');
730
902
  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`);
@@ -733,270 +905,290 @@ if (gitOut(['rev-parse', '--git-dir']) !== null && gitOut(['rev-parse', '-q', '-
733
905
  }
734
906
 
735
907
  // --- 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
- // 登记表缺失 → 静默跳过(未启用该检查的装户不应被噪声打扰)。
908
+ // 判据要点:活跃态文档(draft/approved/open)中的 `{{指标名}}`(小写点分)签名须替换为实时值,留签名=未回填=
909
+ // warning;只查显式签名、不全文扫数字(历史叙述假阳性恒 0);未登记签名 / 取数器缺失 → fail-loud 出账;
910
+ // 登记表缺失 → 静默跳过(未启用该检查的装户零噪声);装户自有指标走 .agents/metric-derivers.cjs(CJS 契约,
911
+ // 内置优先)。实现自 2026-09-28 check16-inline-debt 起迁出至 check-metric-claims.mjs(原内联块,判据语义/
912
+ // 消息文案/exit 语义零变化,迁出经全量输出 diff 基线验证)。ctx 显式携带本检查引用的外层符号
913
+ // (ROOT/ENUMS/docFiles/fmGet/inSet/isTracked/linesOf/readdirOrNull/warnings,以代码实际引用集为准);
914
+ // 调用位置须保持在检查 15 之后、输出段之前——warnings 按插入序输出,位置变化会改变输出行序。
915
+ runCheck16({ ROOT, ENUMS, docFiles, fmGet, inSet, isTracked, linesOf, readdirOrNull, warnings });
916
+
917
+ // --- 17. 发版提交树上仍未收口的 intent/spec/plan [hard-block] ---
918
+ // 最近一次 package.json version 发生变化的提交里,当时状态已是 draft 或 approved 的
919
+ // intent/spec/plan,被扫描的树上仍是 draft 或 approved 则阻断。
920
+ // 当时已是 draft:不看版本锚。当时已是 approved:只在发版版本大于
921
+ // policy.check17UnclosedAfter 时阻断。版本 1 没有该键,因此不启用后两格。
922
+ // 版本按主、次、修订三段整数比较。沿 package.json 的全部历史查找,不设次数上限。
923
+ // 该提交之后新建的文件不在范围内。open 的 incident 不在此列。
743
924
  {
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;
925
+ const log = gitOut(['log', '--format=%H', '--', 'package.json']);
926
+ if (log) {
927
+ let release = null;
928
+ let releaseVer = '';
929
+ for (const sha of log.split('\n').map((s) => s.trim()).filter(Boolean)) {
930
+ const curTxt = gitOut(['show', `${sha}:package.json`]);
931
+ if (!curTxt) continue;
932
+ let cur = null;
933
+ try { cur = JSON.parse(curTxt).version; } catch { continue; }
934
+ const prevTxt = gitOut(['show', `${sha}^:package.json`]);
935
+ let prev = null;
936
+ if (prevTxt) { try { prev = JSON.parse(prevTxt).version; } catch { prev = null; } }
937
+ if (cur && cur !== prev) { release = sha; releaseVer = cur; break; }
938
+ }
939
+ const tree = release && gitOut(['ls-tree', '-r', '--name-only', release, '--', `${WF}/intents`, `${WF}/specs`, `${WF}/plans`]);
940
+ if (release && tree) {
941
+ for (const rel of tree.split('\n').map((s) => s.trim()).filter(Boolean)) {
942
+ if (!rel.endsWith('.md') || rel.endsWith('_TEMPLATE.md')) continue;
943
+ const thenTxt = gitOut(['show', `${release}:${rel}`]);
944
+ if (!thenTxt) continue;
945
+ const thenSt = fmStatus(thenTxt);
946
+ const nowSt = fmGet(path.join(ROOT, rel), '状态');
947
+ if ((thenSt !== 'draft' && thenSt !== 'approved') || (nowSt !== 'draft' && nowSt !== 'approved')) continue;
948
+ if (thenSt === 'approved' && !versionGreater(releaseVer, kitPolicy.check17UnclosedAfter)) continue;
949
+ const here = REV ? `提交 ${REV.slice(0, 7)}` : '工作区';
950
+ blockers.push(`- [发版草稿] ${rel} 在 version=${releaseVer} 的提交 ${release.slice(0, 7)} 上已是 ${thenSt},${here}仍是 ${nowSt}。发版提交树上未收口的 intent/spec/plan 须先离开 draft 与 approved。覆盖面只含该提交当时已在树上的 intent/spec/plan;其后新建的文件不在此列`);
809
951
  }
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;
952
+ }
953
+ }
954
+ }
955
+
956
+ // --- 18. 委派台账对账 [warning] ---
957
+ // L2/L3 的 intent、spec、plan 状态为 done,或同级别 incident 状态为 fixed 或 closed 时,
958
+ // 「委派结果」表与「自做任务结果」表均没有日期不早于文档日期、且含该文件名(带/不带 .md 均匹配)的一行,则警告。
959
+ // 缺级别、缺日期、以及其他状态不警告。audit:false 时 warnings.push 已被换成空函数。
960
+ // 两表解析口径与 agg-delegations.cjs splitTables 保持一致,一边改另一边须跟。
961
+ {
962
+ const rows = delegationResultRows(path.join(ROOT, WF, 'delegations.md'));
963
+ for (const sub of ['intents', 'specs', 'plans', 'incidents']) {
964
+ for (const abs of docFiles(sub)) {
965
+ const lvl = fmGet(abs, '级别');
966
+ if (lvl !== 'L2' && lvl !== 'L3') continue;
967
+ const st = fmGet(abs, '状态');
968
+ const inScope = sub === 'incidents' ? (st === 'fixed' || st === 'closed') : st === 'done';
969
+ if (!inScope) continue;
970
+ const date = sub === 'incidents' ? (fmGet(abs, '发现') || fmGet(abs, '日期')) : fmGet(abs, '日期');
971
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
972
+ const base = path.basename(abs);
973
+ const stripped = base.replace(/\.md$/, '');
974
+ if (rows.some((r) => r.date >= date && (r.line.includes(base) || r.line.includes(stripped)))) continue;
975
+ const rel = path.relative(ROOT, abs).split(path.sep).join('/');
976
+ warnings.push(`- [WARN 委派台账] ${rel} 在 ${date} 及之后的委派结果/自做任务结果表中没有该文件名`);
977
+ }
978
+ }
979
+ }
980
+
981
+ // --- 19. 逐阶段审计 [warning](2026-09-30 stage-gate-machine;口径与 stage-gates.mjs / fill-* / confirm-doc 前置门互引) ---
982
+ // 判据 A(在途扫描):同名 spec/plan 为 draft 且其日期 ≥ stageGateSince 时——入口未确认(intent:状态非
983
+ // approved/done、或缺台账行且非存量;incident:「时间线」小节缺行首「用户确认」条目)→ warning;
984
+ // 入口缺合法「级别」→ warning(与起草门 fail-closed 同口径,2026-09-30 复核 P2-7);
985
+ // plan 另判(入口级别 L2/L3 且 spec 未确认或缺失)→ warning。
986
+ // 判据 B(台账顺序):同主题 {intents,specs,plans} 的 approved 行(每 doc 取最早)时间须非降序;仅对
987
+ // 组内最早 ts 日期 ≥ stageGateSince 的主题判定;含更早 ts 的主题整组跳过(历史豁免)。
988
+ // 协作道豁免(2026-09-30 hybrid-governance-risk-lanes):入口 intent 为 draft 且级别 L0/L1 → 判据 A 静默
989
+ // (「先动手后确认」是泳道语义;approved/done 无台账行仍照报);判据 B 对入口 L0/L1 的主题豁免顺序
990
+ // 倒置判定(入口文件缺失不豁免)。
991
+ // stageGateSince 缺键(policy v1)→ 本检查整体跳过;全部 warning、不 hard-block。
992
+ {
993
+ const since = kitPolicy.stageGateSince;
994
+ if (typeof since === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(since)) {
995
+ const ledgerRows = [];
996
+ {
997
+ const lp = path.join(ROOT, '.agents', 'confirmations.jsonl');
998
+ if (fs.existsSync(lp)) {
999
+ for (const line of linesOf(lp) || []) {
1000
+ if (!line.trim()) continue;
1001
+ try { ledgerRows.push(JSON.parse(line)); } catch { /* 坏行跳过(同检查 15 口径) */ }
819
1002
  }
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
1003
  }
825
- return adopterState;
1004
+ }
1005
+ const hasRow = (rel) => ledgerRows.some((e) => e && e.doc === rel && (e.stage === 'approved' || e.stage === 'done'));
1006
+ const docConfirmed = (rel, abs) => {
1007
+ const st = fmGet(abs, '状态');
1008
+ if (st !== 'approved' && st !== 'done') return { ok: false, legacy: false, why: `状态「${st || '缺失'}」` };
1009
+ if (hasRow(rel)) return { ok: true, level: fmGet(abs, '级别'), legacy: false };
1010
+ const d = fmGet(abs, '日期') || fmGet(abs, '发现');
1011
+ const legacy = (linesOf(abs) || []).some((l) => /^流程: legacy/.test(l))
1012
+ || (/^\d{4}-\d{2}-\d{2}$/.test(d) && d < kitPolicy.confirmDocsEffective);
1013
+ if (legacy) return { ok: true, level: fmGet(abs, '级别'), legacy: true };
1014
+ return { ok: false, legacy: false, why: '无确认台账行' };
826
1015
  };
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(/[.+^${}()|[\]\\]/, '\\$&');
1016
+ // incident 留痕判定:**直接复用 stage-gates.mjs 的 MARK_RE**(第七轮复核 N3:此前此处内联复制
1017
+ // 一份同口径字面量,两处靠注释人工同步、无测试钉住——改为单源复用,从结构上消除漂移可能)。
1018
+ // 判据同源:「## 时间线」小节内、行首列表条目含「用户确认」(词边界见 MARK_RE)。
1019
+ const incidentMarked19 = (lines) => {
1020
+ let inTimeline = false;
1021
+ for (const l of lines || []) {
1022
+ if (/^##\s/.test(l)) { inTimeline = /^##\s*时间线/.test(l); continue; }
1023
+ if (inTimeline && MARK_RE.test(l)) return true;
845
1024
  }
846
- return new RegExp('^' + re + '$');
1025
+ return false;
847
1026
  };
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);
1027
+ const entryConfirmed19 = (base) => {
1028
+ const irel = `${WF}/intents/${base}.md`;
1029
+ const iabs = path.join(ROOT, irel);
1030
+ if (fs.existsSync(iabs)) {
1031
+ const st19 = fmGet(iabs, '状态');
1032
+ const lvl19 = fmGet(iabs, '级别');
1033
+ const r = docConfirmed(irel, iabs);
1034
+ const body = (linesOf(iabs) || []).join('\n');
1035
+ const d19 = fmGet(iabs, '日期') || fmGet(iabs, '发现');
1036
+ const storLegacy = /^流程: legacy/m.test(body) || (/^\d{4}-\d{2}-\d{2}$/.test(d19) && d19 < kitPolicy.confirmDocsEffective);
1037
+ return { ...r, level: lvl19, entryDraft: st19 === 'draft', storageLegacy: storLegacy };
867
1038
  }
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} 亦未定义(登记了却没实现=其实没查)` };
1039
+ const crel = `${WF}/incidents/${base}.md`;
1040
+ const cabs = path.join(ROOT, crel);
1041
+ if (fs.existsSync(cabs)) {
1042
+ const st = fmGet(cabs, '状态');
1043
+ if ((st === 'open' || st === 'fixed' || st === 'closed') && incidentMarked19(linesOf(cabs))) {
1044
+ return { ok: true, level: fmGet(cabs, '级别'), legacy: false, storageLegacy: false };
1045
+ }
1046
+ return { ok: false, legacy: false, storageLegacy: false, why: 'incident 未见过目留痕(时间线条目缺失)' };
885
1047
  }
886
- return { fn, builtin: false };
1048
+ return { ok: false, legacy: false, storageLegacy: false, why: '无同名入口' };
887
1049
  };
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} 中的同名定义**被忽略**(内置优先)——如需改用自有实现,请换一个指标名`);
1050
+ for (const sub of ['specs', 'plans']) {
1051
+ for (const abs of docFiles(sub)) {
1052
+ if (fmGet(abs, '状态') !== 'draft') continue;
1053
+ const base = path.basename(abs).replace(/\.md$/, '');
1054
+ // 日期:frontmatter 优先,缺则回落文件名前缀(plan 无「日期」字段;沿检查 15 口径)
1055
+ let d = fmGet(abs, '日期');
1056
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) d = /^\d{4}-\d{2}-\d{2}$/.test(base.slice(0, 10)) ? base.slice(0, 10) : '';
1057
+ if (!(/^\d{4}-\d{2}-\d{2}$/.test(d) && d >= since)) continue;
1058
+ const rel = path.relative(ROOT, abs).split(path.sep).join('/');
1059
+ const ent = entryConfirmed19(base);
1060
+ if (!ent.ok) {
1061
+ // 协作道异步审计豁免(2026-09-30 hybrid-governance-risk-lanes):入口 intent 仍为 draft 且
1062
+ // 级别 L0/L1 → 「先动手后确认」是泳道语义,不告警;approved/done 却无台账行仍照报(防手改
1063
+ // 状态冒充确认,与交叉一致性口径一致);incident 入口不豁免(过目留痕本就轻量)。
1064
+ if (ent.entryDraft && (ent.level === 'L0' || ent.level === 'L1')) continue;
1065
+ warnings.push(`- [WARN 逐阶段] ${rel} 起草先于入口确认(${ent.why})`); continue;
1066
+ }
1067
+ // 库存量形态(标记 / 日期早于生效日)短路 level 校验与 spec 档(与起草门同口径,N1)
1068
+ const storLegacy = ent.storageLegacy === true;
1069
+ if (!storLegacy && !/^L[0-3]$/.test(ent.level)) { warnings.push(`- [WARN 逐阶段] ${rel} 入口缺合法「级别」字段(无法判定前置深度;与起草门 fail-closed 同口径,库存量形态除外)`); continue; }
1070
+ if (sub === 'plans' && !storLegacy && (ent.level === 'L2' || ent.level === 'L3')) {
1071
+ const srel = `${WF}/specs/${base}.md`;
1072
+ const sabs = path.join(ROOT, srel);
1073
+ const sp = fs.existsSync(sabs) ? docConfirmed(srel, sabs) : { ok: false, why: '缺同名 spec' };
1074
+ if (!sp.ok) warnings.push(`- [WARN 逐阶段] ${rel} 起草先于 spec 确认(${sp.why})`);
1075
+ }
926
1076
  }
927
- declared.set(m[1], true);
928
1077
  }
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
- }
1078
+ const firstApproved = new Map(); // base -> { intents?, specs?, plans? } 最早 approved ts
1079
+ for (const e of ledgerRows) {
1080
+ if (!e || e.stage !== 'approved' || typeof e.doc !== 'string') continue;
1081
+ const m = /^workflow\/(intents|specs|plans)\/(.+)\.md$/.exec(e.doc);
1082
+ if (!m) continue;
1083
+ const rec = firstApproved.get(m[2]) || {};
1084
+ if (!rec[m[1]] && typeof e.ts === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(e.ts)) rec[m[1]] = e.ts;
1085
+ firstApproved.set(m[2], rec);
939
1086
  }
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) });
1087
+ for (const [base, rec] of firstApproved) {
1088
+ const seq = ['intents', 'specs', 'plans'].filter((k) => rec[k]);
1089
+ if (seq.length < 2) continue;
1090
+ // 协作道豁免(2026-09-30 hybrid-governance-risk-lanes):入口 L0/L1 的审批顺序倒置(如 plan 先于
1091
+ // intent approved——「动手后确认」)是异步审计泳道语义,非流程违规;入口文件缺失(纯台账历史行 /
1092
+ // incident 侧)不豁免,维持既有判据。
1093
+ const ilvlB = fmGet(path.join(ROOT, WF, 'intents', `${base}.md`), '级别');
1094
+ if (ilvlB === 'L0' || ilvlB === 'L1') continue;
1095
+ const earliest = seq.map((k) => rec[k]).sort()[0];
1096
+ if (!(earliest.slice(0, 10) >= since)) continue;
1097
+ let ordered = true;
1098
+ for (let i = 1; i < seq.length; i++) if (rec[seq[i]] < rec[seq[i - 1]]) ordered = false;
1099
+ if (!ordered) warnings.push(`- [WARN 逐阶段] 台账审批顺序倒置:${base}(${seq.map((k) => `${k}=${rec[k].slice(0, 19)}`).join(' / ')})`);
942
1100
  }
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
- });
1101
+ }
1102
+ }
1103
+
1104
+ // --- 加固门(仅 --hardening 启用;2026-09-30 hybrid-governance-explore-hardening)[hard-block] ---
1105
+ // 探索泳道(experiment/* 分支 + intent frontmatter「阶段: exploring」标记;「状态: exploring」亦认——
1106
+ // 非法状态本就会在检查 5 出账)允许「先动手后确认」快速 PoC;成果转正(合入 main)前必须完成加固:
1107
+ // ① intent 状态离开 draft → approved/done(探索作废走显式放弃态 superseded/cancelled——留档即出列,
1108
+ // 不加固:放弃态无代码应随行);
1109
+ // ② 同名 plan 在场且状态收口(∈ doc.status.confirmed;L0 亦不豁免——本门是转正门槛,不是配对门);
1110
+ // ③ 泳道 L2/L3(级别优先、缺失/非法回落 risk_level,与 stage-gates 起草门同款取法)另须同名 spec
1111
+ // 在场且收口。
1112
+ // 触发面:.githooks/pre-push 对 remote=refs/heads/main 的推送以 --hardening 运行本脚本(hard 失败即阻断
1113
+ // push);手跑 `node check-loop.mjs --hardening` 可转正前预检。诚实边界:分支名不进 commit,扫描树无法
1114
+ // 机械判定「曾在 experiment/* 上」——标记靠泳道纪律写入(intent 模板 / workflow README 口径),漏标记的
1115
+ // 探索件不被本门覆盖;本门拦的是「已标记未收口」。
1116
+ if (HARDENING) {
1117
+ for (const intent of docFiles('intents')) {
1118
+ if (fmGet(intent, '阶段') !== 'exploring' && fmGet(intent, '状态') !== 'exploring') continue;
1119
+ const base = path.basename(intent);
1120
+ const st = fmGet(intent, '状态');
1121
+ if (st === 'superseded' || st === 'cancelled') continue; // 放弃态:探索作废留档,不加固
1122
+ const lvl = fmGet(intent, '级别');
1123
+ const lane = /^L[0-3]$/.test(lvl) ? lvl : fmGet(intent, 'risk_level');
1124
+ if (st !== 'approved' && st !== 'done') {
1125
+ blockers.push(`- [加固未过] exploring 任务入 main 须先收口:${base} intent 状态仍为『${st || '缺失'}』——先确认至 approved/done 并补齐同名 spec+plan,或显式放弃(superseded/cancelled)`);
1126
+ continue;
1127
+ }
1128
+ const planAbs = path.join(ROOT, WF, 'plans', base);
1129
+ const planSt = fs.existsSync(planAbs) ? fmGet(planAbs, '状态') : '';
1130
+ if (!fs.existsSync(planAbs)) {
1131
+ blockers.push(`- [加固未过] exploring 任务入 main 缺同名 plan:${base}(应在 ${WF}/plans/ 下同名)`);
1132
+ } else if (!stOkDoc(planSt)) {
1133
+ blockers.push(`- [加固未过] exploring 任务入 main 的 plan 未收口:${base}(plan 状态『${planSt || '缺失'}』须为 approved/done)`);
1134
+ }
1135
+ if (lane === 'L2' || lane === 'L3') {
1136
+ const specAbs = path.join(ROOT, WF, 'specs', base);
1137
+ const specSt = fs.existsSync(specAbs) ? fmGet(specAbs, '状态') : '';
1138
+ if (!fs.existsSync(specAbs)) {
1139
+ blockers.push(`- [加固未过] exploring 任务入 main 缺同名 spec:${base}(级别 ${lane},应在 ${WF}/specs/ 下同名)`);
1140
+ } else if (!stOkDoc(specSt)) {
1141
+ blockers.push(`- [加固未过] exploring 任务入 main 的 spec 未收口:${base}(spec 状态『${specSt || '缺失'}』须为 approved/done)`);
1142
+ }
996
1143
  }
997
1144
  }
998
1145
  }
999
1146
 
1147
+ function delegationResultRows(file) {
1148
+ let text = '';
1149
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
1150
+ const lines = text.split(/\r?\n/);
1151
+ const targetSec = (line) => /^##\s+(委派结果|自做任务结果)\s*$/.test(line);
1152
+ let inSec = false;
1153
+ const rows = [];
1154
+ for (const line of lines) {
1155
+ if (/^##\s+/.test(line)) {
1156
+ inSec = targetSec(line);
1157
+ continue;
1158
+ }
1159
+ if (!inSec || !/^\s*\|/.test(line)) continue;
1160
+ const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
1161
+ if (!cells.length || cells.every((c) => /^[-: ]*$/.test(c))) continue;
1162
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(cells[0])) continue;
1163
+ rows.push({ date: cells[0], line });
1164
+ }
1165
+ return rows;
1166
+ }
1167
+
1168
+ function versionGreater(a, b) {
1169
+ const parse = (v) => {
1170
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(v ?? ''));
1171
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
1172
+ };
1173
+ const x = parse(a);
1174
+ const y = parse(b);
1175
+ if (!x || !y) return false;
1176
+ for (let i = 0; i < 3; i++) {
1177
+ if (x[i] !== y[i]) return x[i] > y[i];
1178
+ }
1179
+ return false;
1180
+ }
1181
+
1182
+ function fmStatus(text) {
1183
+ const lines = String(text || '').split(/\r?\n/);
1184
+ if (!/^---\s*$/.test(lines[0] || '')) return '';
1185
+ for (let i = 1; i < lines.length; i++) {
1186
+ if (/^---\s*$/.test(lines[i])) return '';
1187
+ if (lines[i].startsWith('状态:')) return lines[i].slice('状态:'.length).trim();
1188
+ }
1189
+ return '';
1190
+ }
1191
+
1000
1192
  // --- 输出(banner 沿 sh 版字样与格式 = 稳定输出契约:banner 后空行、条目 '- ' 前缀)---
1001
1193
  if (blockers.length) {
1002
1194
  process.stderr.write(`闭环骨架断档(check-loop.sh)— HARD-BLOCK:\n\n${blockers.join('\n')}\n\n`);