agentic-flow-kit 0.9.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +93 -93
  2. package/modules/hosts/claude/commands/wf-build.md +3 -3
  3. package/modules/hosts/claude/commands/wf-design.md +3 -3
  4. package/modules/hosts/claude/commands/wf-new-task.md +14 -2
  5. package/modules/hosts/claude/commands/wf-pipeline-run.md +35 -0
  6. package/modules/hosts/claude/commands/wf-plan.md +3 -3
  7. package/modules/hosts/claude/commands/wf-sync-hosts.md +1 -1
  8. package/modules/hosts/claude/commands/wf-test.md +1 -1
  9. package/modules/hosts/codex/commands/wf-build.md +3 -3
  10. package/modules/hosts/codex/commands/wf-design.md +3 -3
  11. package/modules/hosts/codex/commands/wf-new-task.md +14 -2
  12. package/modules/hosts/codex/commands/wf-pipeline-run.md +35 -0
  13. package/modules/hosts/codex/commands/wf-plan.md +3 -3
  14. package/modules/hosts/codex/commands/wf-sync-hosts.md +1 -1
  15. package/modules/hosts/codex/commands/wf-test.md +1 -1
  16. package/modules/hosts/cursor/commands/wf-build.md +3 -3
  17. package/modules/hosts/cursor/commands/wf-design.md +3 -3
  18. package/modules/hosts/cursor/commands/wf-new-task.md +14 -2
  19. package/modules/hosts/cursor/commands/wf-pipeline-run.md +35 -0
  20. package/modules/hosts/cursor/commands/wf-plan.md +3 -3
  21. package/modules/hosts/cursor/commands/wf-sync-hosts.md +1 -1
  22. package/modules/hosts/cursor/commands/wf-test.md +1 -1
  23. package/modules/hosts/opencode/commands/wf-build.md +3 -3
  24. package/modules/hosts/opencode/commands/wf-design.md +3 -3
  25. package/modules/hosts/opencode/commands/wf-new-task.md +14 -2
  26. package/modules/hosts/opencode/commands/wf-pipeline-run.md +35 -0
  27. package/modules/hosts/opencode/commands/wf-plan.md +3 -3
  28. package/modules/hosts/opencode/commands/wf-sync-hosts.md +1 -1
  29. package/modules/hosts/opencode/commands/wf-test.md +1 -1
  30. package/modules/hosts/trae/commands/wf-build.md +3 -3
  31. package/modules/hosts/trae/commands/wf-design.md +3 -3
  32. package/modules/hosts/trae/commands/wf-new-task.md +14 -2
  33. package/modules/hosts/trae/commands/wf-pipeline-run.md +36 -0
  34. package/modules/hosts/trae/commands/wf-plan.md +3 -3
  35. package/modules/hosts/trae/commands/wf-sync-hosts.md +1 -1
  36. package/modules/hosts/trae/commands/wf-test.md +1 -1
  37. package/modules/hosts/trae/rules/project-workflow.md +1 -1
  38. package/package.json +45 -45
  39. package/templates/_agents/commands/build.md +2 -2
  40. package/templates/_agents/commands/design.md +1 -1
  41. package/templates/_agents/commands/new-task.md +1 -0
  42. package/templates/_agents/commands/pipeline-run.md +43 -0
  43. package/templates/_agents/commands/plan.md +3 -3
  44. package/templates/_agents/commands/sync-hosts.md +1 -1
  45. package/templates/_agents/scripts/check-lane-surface.mjs +20 -7
  46. package/templates/_agents/scripts/check-lane-surface.test.mjs +24 -2
  47. package/templates/_agents/scripts/check-ledger-invariant.mjs +135 -0
  48. package/templates/_agents/scripts/check-ledger-invariant.test.mjs +161 -0
  49. package/templates/_agents/scripts/check-loop.mjs +70 -18
  50. package/templates/_agents/scripts/check-loop.test.mjs +155 -8
  51. package/templates/_agents/scripts/confirm-doc.mjs +19 -7
  52. package/templates/_agents/scripts/confirm-doc.test.mjs +31 -0
  53. package/templates/_agents/scripts/fill-intent.mjs +1 -1
  54. package/templates/_agents/scripts/fill-plan.test.mjs +4 -3
  55. package/templates/_agents/scripts/gate-checklist.mjs +1 -0
  56. package/templates/_agents/scripts/gen-workflow-metrics.mjs +75 -5
  57. package/templates/_agents/scripts/gen-workflow-metrics.test.mjs +104 -2
  58. package/templates/_agents/scripts/pipeline-run.mjs +999 -0
  59. package/templates/_agents/scripts/pipeline-run.test.mjs +537 -0
  60. package/templates/_agents/scripts/policy.mjs +1 -0
  61. package/templates/_agents/scripts/pre-push.test.mjs +8 -1
  62. package/templates/_agents/scripts/solidify-task.test.mjs +5 -2
  63. package/templates/_agents/scripts/stage-gates.mjs +29 -6
  64. package/templates/_agents/scripts/trust-mode.test.mjs +110 -0
  65. package/templates/_agents/scripts-test-exempt.txt +10 -0
  66. package/templates/_githooks/pre-commit +12 -0
  67. package/templates/workflow/README.md +3 -2
  68. package/templates/workflow/plans/_TEMPLATE.md +1 -1
@@ -70,6 +70,8 @@
70
70
  // 17. 发版提交树上仍未收口的 intent/spec/plan [hard-block]
71
71
  // 18. 委派台账对账 [warning]
72
72
  // 19. 逐阶段审计(起草先于入口确认 / 台账审批顺序倒置) [warning]
73
+ // 20. 引擎脚本测试覆盖(包源环境:templates/_agents/scripts/ 下每个非 *.test.mjs 引擎脚本须有同名兄弟
74
+ // .test.mjs,或在 .agents/scripts-test-exempt.txt 登记豁免;2026-10-01 gate-script-test-coverage) [warning]
73
75
  // 判据 A=在途扫描(同名 spec/plan 为 draft 且日期≥stageGateSince 时判入口确认;plan 另判 L2/L3 的 spec);
74
76
  // 判据 B=台账 approved 行按主题校验 intents≤specs≤plans 顺序(组内最早 ts 日期≥stageGateSince 才判);
75
77
  // stageGateSince 缺键(v1) → 本检查整体跳过;口径与 stage-gates.mjs / fill-* / confirm-doc 前置门互引
@@ -96,7 +98,7 @@
96
98
  // 已确认状态: approved/done=已批或闭环; superseded/cancelled=放弃留档(仍算确认,不挡 push); incident: fixed/closed
97
99
  // 用法:node .agents/scripts/check-loop.mjs [--rev <sha>] [--hardening] (或经 check-loop.sh shim)
98
100
  // --hardening:追加「加固门」(experiment 泳道 exploring 任务入 main 的转正门槛,hard-block;
99
- // .githooks/pre-push 对 remote=refs/heads/main 的推送传入;不占 1-19 编号,gate-checklist 登记表
101
+ // .githooks/pre-push 对 remote=refs/heads/main 的推送传入;不占 1-20 编号,gate-checklist 登记表
100
102
  // 按号配对面不变,门体说明见加固门段注释)
101
103
  // 测试:node templates/_agents/scripts/check-loop.test.mjs(fixture 注入 CHECK_LOOP_ROOT)
102
104
  import fs from 'node:fs';
@@ -109,6 +111,7 @@ import { fileURLToPath } from 'node:url';
109
111
  import { loadEnums } from './workflow-enums.mjs';
110
112
  import { runCheck16 } from './check-metric-claims.mjs';
111
113
  import { auditEnabled, loadKitPolicy } from './policy.mjs';
114
+ import { laneOfEntry } from './stage-gates.mjs';
112
115
  // MARK_RE:incident 留痕形态的单源(第七轮复核 N3——此前 check-loop 内联复制一份同口径字面量,
113
116
  // 两处靠注释与人工同步;改为复用 stage-gates.mjs 的导出,从结构上消除漂移可能)
114
117
  import { MARK_RE, approvedTraceHit } from './stage-gates.mjs';
@@ -283,13 +286,14 @@ for (const intent of docFiles('intents')) {
283
286
  if (lvl !== 'L0' && !fs.existsSync(path.join(ROOT, WF, 'plans', base))) {
284
287
  blockers.push(`- [配对断裂] intent 缺 plan:${base}(应在 ${WF}/plans/ 下同名)`);
285
288
  }
286
- // 风险泳道一致性:L0/L1(级别或 risk_level 任一判定)勾选触达红线 = 自认触及规则/契约或数据/运行时
287
- // 结构面——就高不就低,协作道不受理 → hard 判低,须升级 L2/L3 并补同名 spec
288
- if (['L0', 'L1'].includes(lvl) || ['L0', 'L1'].includes(risk)) {
289
+ // 风险泳道一致性(2026-10-02 caliber-convergence 起单源 laneOfEntry·方案 C 分歧双严):非 high
290
+ // (low 或 suspect——级别/risk_level 缺失、非法、分歧)勾选触达红线 = 自认触及规则/契约或数据/运行时
291
+ // 结构面——就高不就低,协作道不受理 → hard 判低,须对齐两字段至 L2/L3 并补同名 spec
292
+ if (laneOfEntry(lvl, risk, kitPolicy.riskLevelSince, fmGet(intent, '日期')) !== 'high') {
289
293
  const redlineTouched = (linesOf(intent) || [])
290
294
  .some((l) => /^- \[x\]/.test(l) && /(\$\\rightarrow\$|→)\s*级别|级别至少\s*L[23]/.test(l));
291
295
  if (redlineTouched) {
292
- blockers.push(`- [红线判低] ${base} 级别/风险泳道为 ${[lvl, risk].filter(Boolean).join('/')} 但触达红线已勾选(规则/契约或 schema/结构面)——协作道不受理 STOP 级改动:就高不就低,先把「级别/risk_level」升到 L2/L3 并补同名 spec`);
296
+ blockers.push(`- [红线判低] ${base} 级别/风险泳道为 ${[lvl, risk].filter(Boolean).join('/')} 但触达红线已勾选(规则/契约或 schema/结构面)——协作道不受理 STOP 级改动:就高不就低,把「级别」与「risk_level」两字段对齐到 L2/L3(fill-intent 双写口径)并补同名 spec`);
293
297
  }
294
298
  }
295
299
  }
@@ -336,12 +340,24 @@ for (const plan of docFiles('plans')) {
336
340
  // 「复制本模板为 YYYY-MM-DD-<主题>.md」),非未填占位符;真未填的占位(标题 `# INTENT — <主题>`、
337
341
  // `日期: YYYY-MM-DD`)不含 .md,仍照拦(2026-09-25-wf-runtime incident 记录的误报口径)
338
342
  const isNamingConv = (line) => line.includes('<主题>') && /\.md/.test(line);
343
+ // 样例引用豁免(2026-10-02 caliber-convergence 假阳性消除,先例=检查 18 行尾归一):命中位于行内代码
344
+ // (反引号包裹)或围栏代码块内 = 引用样例而非未填占位符,不报;正文裸占位符照报——真占位符均为
345
+ // 裸文本(模板正文无反引号包裹),检测面不缩。
346
+ const stripSamples = (lines) => {
347
+ let inFence = false;
348
+ return lines.map((line) => {
349
+ if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; return ''; }
350
+ if (inFence) return '';
351
+ return line.replace(/`[^`]*`/g, '');
352
+ });
353
+ };
339
354
  const groups = new Map(); // file -> [ "行号:内容" ](首现序)
340
355
  for (const sub of DOC_DIRS) {
341
356
  for (const f of docFiles(sub)) {
357
+ const lines = linesOf(f) || [];
342
358
  const hits = [];
343
- (linesOf(f) || []).forEach((line, i) => {
344
- if (phRe.test(line) && !boilerRe.test(line) && !isNamingConv(line)) hits.push(`${i + 1}:${line}`);
359
+ stripSamples(lines).forEach((line, i) => {
360
+ if (phRe.test(line) && !boilerRe.test(line) && !isNamingConv(line)) hits.push(`${i + 1}:${lines[i]}`);
345
361
  });
346
362
  if (hits.length) groups.set(f, hits);
347
363
  }
@@ -592,9 +608,18 @@ function verifyEvidenceTruth(evidenceStr, planBase, root) {
592
608
 
593
609
  const changedFiles = show.stdout.split(/\r?\n/).filter(Boolean);
594
610
  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
-
611
+
612
+ if (!hasIntersection) {
613
+ // 过程证据(2026-10-03 check-evidence-process):执行器驱动的提交可作验收证据——提交信息带
614
+ // 「pipeline-run <runId>」标记(docsCommit 四种格式共有段,runId 严格形态 日期8-时间4-主题-尾;
615
+ // 与 run 事件流同源,git 历史留痕可对质)。只救 irrelevant:forged(SHA 不存在)在前已判,其余判据不动。
616
+ const subj = spawnSync(GIT, ['log', '-1', '--format=%s', sha], { cwd: ROOT, encoding: 'utf8' });
617
+ if (subj.status === 0 && /pipeline-run [0-9]{8}-[0-9]{4}-[A-Za-z0-9-]+/.test(subj.stdout)) {
618
+ return { ok: true, type: 'process' };
619
+ }
620
+ return { ok: false, type: 'irrelevant', msg: `提交 ${sha} 未触及 plan 声明的任何文件` };
621
+ }
622
+
598
623
  return { ok: true, type: 'sha' };
599
624
  }
600
625
 
@@ -1034,7 +1059,7 @@ runCheck16({ ROOT, ENUMS, docFiles, fmGet, inSet, isTracked, linesOf, readdirOrN
1034
1059
  const body = (linesOf(iabs) || []).join('\n');
1035
1060
  const d19 = fmGet(iabs, '日期') || fmGet(iabs, '发现');
1036
1061
  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 };
1062
+ return { ...r, level: lvl19, risk: fmGet(iabs, 'risk_level'), date: d19, entryDraft: st19 === 'draft', storageLegacy: storLegacy };
1038
1063
  }
1039
1064
  const crel = `${WF}/incidents/${base}.md`;
1040
1065
  const cabs = path.join(ROOT, crel);
@@ -1058,10 +1083,11 @@ runCheck16({ ROOT, ENUMS, docFiles, fmGet, inSet, isTracked, linesOf, readdirOrN
1058
1083
  const rel = path.relative(ROOT, abs).split(path.sep).join('/');
1059
1084
  const ent = entryConfirmed19(base);
1060
1085
  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;
1086
+ // 协作道异步审计豁免(2026-09-30 hybrid-governance-risk-lanes;2026-10-02 caliber-convergence
1087
+ // 起单源 laneOfEntry·方案 C):入口 intent 仍为 draft 且泳道 low(两字段一致 L0/L1)→「先动手后
1088
+ // 确认」是泳道语义,不告警;suspect(缺失/非法/分歧)不豁免;approved/done 却无台账行仍照报
1089
+ // (防手改状态冒充确认,与交叉一致性口径同);incident 入口不豁免(过目留痕本就轻量)。
1090
+ if (ent.entryDraft && laneOfEntry(ent.level, ent.risk, kitPolicy.riskLevelSince, ent.date) === 'low') continue;
1065
1091
  warnings.push(`- [WARN 逐阶段] ${rel} 起草先于入口确认(${ent.why})`); continue;
1066
1092
  }
1067
1093
  // 库存量形态(标记 / 日期早于生效日)短路 level 校验与 spec 档(与起草门同口径,N1)
@@ -1120,7 +1146,8 @@ if (HARDENING) {
1120
1146
  const st = fmGet(intent, '状态');
1121
1147
  if (st === 'superseded' || st === 'cancelled') continue; // 放弃态:探索作废留档,不加固
1122
1148
  const lvl = fmGet(intent, '级别');
1123
- const lane = /^L[0-3]$/.test(lvl) ? lvl : fmGet(intent, 'risk_level');
1149
+ // 泳道单源(2026-10-02 caliber-convergence 方案 C):suspect(缺失/非法/分歧)按 high——L2/L3 另须 spec
1150
+ const lane = laneOfEntry(lvl, fmGet(intent, 'risk_level'), kitPolicy.riskLevelSince, fmGet(intent, '日期'));
1124
1151
  if (st !== 'approved' && st !== 'done') {
1125
1152
  blockers.push(`- [加固未过] exploring 任务入 main 须先收口:${base} intent 状态仍为『${st || '缺失'}』——先确认至 approved/done 并补齐同名 spec+plan,或显式放弃(superseded/cancelled)`);
1126
1153
  continue;
@@ -1132,11 +1159,13 @@ if (HARDENING) {
1132
1159
  } else if (!stOkDoc(planSt)) {
1133
1160
  blockers.push(`- [加固未过] exploring 任务入 main 的 plan 未收口:${base}(plan 状态『${planSt || '缺失'}』须为 approved/done)`);
1134
1161
  }
1135
- if (lane === 'L2' || lane === 'L3') {
1162
+ // 泳道单源(2026-10-02 caliber-convergence 方案 C;复核 P1-1 修正):suspect(缺失/非法/分歧)与
1163
+ // high 同样另须同名 spec——仅 low(两字段一致 L0/L1)免 spec,只增不松。
1164
+ if (lane !== 'low') {
1136
1165
  const specAbs = path.join(ROOT, WF, 'specs', base);
1137
1166
  const specSt = fs.existsSync(specAbs) ? fmGet(specAbs, '状态') : '';
1138
1167
  if (!fs.existsSync(specAbs)) {
1139
- blockers.push(`- [加固未过] exploring 任务入 main 缺同名 spec:${base}(级别 ${lane},应在 ${WF}/specs/ 下同名)`);
1168
+ blockers.push(`- [加固未过] exploring 任务入 main 缺同名 spec:${base}(级别 ${lvl}/risk ${fmGet(intent, 'risk_level') || '缺失'},应在 ${WF}/specs/ 下同名)`);
1140
1169
  } else if (!stOkDoc(specSt)) {
1141
1170
  blockers.push(`- [加固未过] exploring 任务入 main 的 spec 未收口:${base}(spec 状态『${specSt || '缺失'}』须为 approved/done)`);
1142
1171
  }
@@ -1189,6 +1218,29 @@ function fmStatus(text) {
1189
1218
  return '';
1190
1219
  }
1191
1220
 
1221
+ // --- 20. 引擎脚本测试覆盖 [warning](2026-10-01 gate-script-test-coverage;仅包源环境) ---
1222
+ // 包源开发纪律:templates/_agents/scripts/ 下每个非 *.test.mjs 引擎脚本须有同名兄弟 .test.mjs,
1223
+ // 或在 .agents/scripts-test-exempt.txt 登记豁免(managed 下发)。装户(无 templates/)整体跳过。
1224
+ {
1225
+ const scriptsDir = path.join(ROOT, 'templates', '_agents', 'scripts');
1226
+ if (fs.existsSync(scriptsDir)) {
1227
+ const exemptFile = path.join(ROOT, '.agents', 'scripts-test-exempt.txt');
1228
+ const exempt = fs.existsSync(exemptFile)
1229
+ ? fs.readFileSync(exemptFile, 'utf8').split(/\r?\n/)
1230
+ .map((l) => l.trim())
1231
+ .filter((l) => l && !l.startsWith('#'))
1232
+ .map((l) => l.split('|')[0].trim())
1233
+ .filter(Boolean)
1234
+ : [];
1235
+ const orphans = fs.readdirSync(scriptsDir)
1236
+ .filter((f) => f.endsWith('.mjs') && !f.endsWith('.test.mjs') && !exempt.includes(f))
1237
+ .filter((f) => !fs.existsSync(path.join(scriptsDir, f.replace(/\.mjs$/, '.test.mjs'))));
1238
+ for (const f of orphans.sort()) {
1239
+ warnings.push(`- [WARN 脚本测试缺失] ${f} 缺同名 .test.mjs——新增引擎脚本默认必须带测试;库件/工具可在 .agents/scripts-test-exempt.txt 登记豁免`);
1240
+ }
1241
+ }
1242
+ }
1243
+
1192
1244
  // --- 输出(banner 沿 sh 版字样与格式 = 稳定输出契约:banner 后空行、条目 '- ' 前缀)---
1193
1245
  if (blockers.length) {
1194
1246
  process.stderr.write(`闭环骨架断档(check-loop.sh)— HARD-BLOCK:\n\n${blockers.join('\n')}\n\n`);
@@ -12,7 +12,7 @@ import { spawnSync } from 'node:child_process';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { computeFingerprint } from './confirm-doc.mjs';
14
14
  import { POLICIES, loadKitPolicy } from './policy.mjs';
15
- import { entryConfirmed, MARK_RE } from './stage-gates.mjs';
15
+ import { entryConfirmed, MARK_RE, laneOfEntry, laneOfDoc } from './stage-gates.mjs';
16
16
 
17
17
  const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
18
18
  const CHECK_LOOP = path.join(SCRIPT_DIR, 'check-loop.mjs');
@@ -599,6 +599,44 @@ const mkConfirmedDoc = (root, rel, fmBody) => {
599
599
  r.status === 0 && !outOf(r).includes('模板未填'), `exit=${r.status}\n${outOf(r)}`);
600
600
  rmfix(T);
601
601
  }
602
+
603
+ // ---- 场景 5x/5y/5z:检查 8 证据核验——过程证据形态(2026-10-03 check-evidence-process)----
604
+ // 造法:gitInit(激活证据核验的 rev-parse 路径)+ 锚前自报日期(豁免检查 15 存量面);
605
+ // 证据引用「改动面外文件的提交」:带执行器标记 → process 放行;无标记 → irrelevant 拦;SHA 不存在 → forged 拦。
606
+ {
607
+ const T = mkfix();
608
+ gitInit(T);
609
+ w(T, 'workflow/plans/2026-09-26-pe1.md', PLAN('pe1', '状态: done\n级别: L1\n模块: material', '\n## 改动方案\n\n- src/app.mjs:实现功能\n'));
610
+ fs.mkdirSync(path.join(T, 'notes'), { recursive: true });
611
+ w(T, 'notes/other.md', 'x\n');
612
+ gitCommitAll(T, 'docs(x): 演练穿越(pipeline-run 20260926-0900-pe1-abc1)');
613
+ const G = process.platform === 'win32' ? 'git.exe' : 'git';
614
+ const sha = spawnSync(G, ['rev-parse', '--short', 'HEAD'], { cwd: T, encoding: 'utf8' }).stdout.trim();
615
+ w(T, 'workflow/intents/2026-09-26-pe1.md', INTENT('pe1', '状态: done\n级别: L1\n日期: 2026-09-26', `\n## 验收标准(可测试)\n- [x] 演练穿越(证据:${sha})\n`));
616
+ expectOk('过程证据:提交信息带执行器标记(改动面外)→ 放行 exit 0', T);
617
+ rmfix(T);
618
+ }
619
+ {
620
+ const T = mkfix();
621
+ gitInit(T);
622
+ w(T, 'workflow/plans/2026-09-26-pe2.md', PLAN('pe2', '状态: done\n级别: L1\n模块: material', '\n## 改动方案\n\n- src/app.mjs:实现功能\n'));
623
+ fs.mkdirSync(path.join(T, 'notes'), { recursive: true });
624
+ w(T, 'notes/other.md', 'x\n');
625
+ gitCommitAll(T, 'docs(x): 演练穿越(L0,无标记提交)');
626
+ const G = process.platform === 'win32' ? 'git.exe' : 'git';
627
+ const sha = spawnSync(G, ['rev-parse', '--short', 'HEAD'], { cwd: T, encoding: 'utf8' }).stdout.trim();
628
+ w(T, 'workflow/intents/2026-09-26-pe2.md', INTENT('pe2', '状态: done\n级别: L1\n日期: 2026-09-26', `\n## 验收标准(可测试)\n- [x] 演练穿越(证据:${sha})\n`));
629
+ expectHard('过程证据:无标记无关提交 → 仍拦 证据无关', T, '证据无关');
630
+ rmfix(T);
631
+ }
632
+ {
633
+ const T = mkfix();
634
+ gitInit(T);
635
+ w(T, 'workflow/plans/2026-09-26-pe3.md', PLAN('pe3', '状态: done\n级别: L1\n模块: material', '\n## 改动方案\n\n- src/app.mjs:实现功能\n'));
636
+ w(T, 'workflow/intents/2026-09-26-pe3.md', INTENT('pe3', '状态: done\n级别: L1\n日期: 2026-09-26', '\n## 验收标准(可测试)\n- [x] 演练穿越(证据:deadbee)\n'));
637
+ expectHard('过程证据:SHA 不存在 → forged 仍拦(防伪线不放松)', T, '证据伪造');
638
+ rmfix(T);
639
+ }
602
640
  // ---- 场景 45:检查 2——真未填占位(标题 <主题>,无 .md)→ 仍报模板未填 ----
603
641
  {
604
642
  const T = mkfix();
@@ -1676,14 +1714,25 @@ const mkConfirmedDoc = (root, rel, fmBody) => {
1676
1714
  w(T, 'workflow/plans/2026-09-29-after.md', PLAN('after', '状态: draft\n级别: L1'));
1677
1715
  gitCommitAll(T, 'draft after release');
1678
1716
  r = run(T, { git: true });
1679
- check('检查17 发版之后新建的 draft 不拦', r.status === 0 && !outOf(r).includes('发版草稿'), `exit=${r.status}\n${outOf(r)}`);
1680
- rmfix(T);
1717
+ check('检查17 发版之后新建的 draft 不拦', r.status === 0 && !outOf(r).includes('发版草稿'), `exit=${r.status}\n${outOf(r)}`);
1718
+ rmfix(T);
1681
1719
  }
1682
1720
  {
1683
- const T = mkfix();
1684
- gitInit(T);
1685
- w(T, 'package.json', '{"name":"t","version":"0.1.0"}\n');
1686
- w(T, 'workflow/intents/2026-09-12-deep.md', INTENT('deep', '状态: draft\n级别: L1\n日期: 2026-09-12'));
1721
+ const T = mkfix();
1722
+ gitInit(T);
1723
+ w(T, 'package.json', '{"name":"t","version":"0.1.0"}\n');
1724
+ w(T, 'workflow/incidents/2026-09-12-inc17.md', `---\n状态: open\n级别: L1\n发现: 2026-09-12\n模块: pipeline\n---\n# INCIDENT — inc17\n\n${三件套(false)}`);
1725
+ w(T, 'workflow/plans/2026-09-12-inc17.md', PLAN('inc17', '状态: draft\n级别: L1\n模块: pipeline'));
1726
+ gitCommitAll(T, 'v0.1.0');
1727
+ const r = run(T, { git: true });
1728
+ check('检查17 发版树上的 open incident 不参与(阻断由 plan 触发且不含 incident 路径)', r.status === 1 && outOf(r).includes('发版草稿') && !outOf(r).includes('workflow/incidents/2026-09-12-inc17.md'), `exit=${r.status}\n${outOf(r)}`);
1729
+ rmfix(T);
1730
+ }
1731
+ {
1732
+ const T = mkfix();
1733
+ gitInit(T);
1734
+ w(T, 'package.json', '{"name":"t","version":"0.1.0"}\n');
1735
+ w(T, 'workflow/intents/2026-09-12-deep.md', INTENT('deep', '状态: draft\n级别: L1\n日期: 2026-09-12'));
1687
1736
  w(T, 'workflow/plans/2026-09-12-deep.md', PLAN('deep', '状态: draft\n级别: L1'));
1688
1737
  gitCommitAll(T, 'v0.1.0');
1689
1738
  for (let i = 1; i <= 41; i++) {
@@ -2179,7 +2228,7 @@ const mkConfirmedDoc = (root, rel, fmBody) => {
2179
2228
  rmfix(T);
2180
2229
  }
2181
2230
 
2182
- // ---- 加固门(2026-09-30 hybrid-governance-explore-hardening;仅 --hardening 启用,不占 1-19 编号)----
2231
+ // ---- 加固门(2026-09-30 hybrid-governance-explore-hardening;仅 --hardening 启用,不占 1-20 编号)----
2183
2232
  {
2184
2233
  // H1:未标记 exploring 的 draft 件 → 带/不带 --hardening 均不拦(本门只管「已标记未收口」,
2185
2234
  // 常规流程的 draft 中间态不受影响——intent approved 后 plan 起草属正常在途)
@@ -2253,5 +2302,103 @@ const mkConfirmedDoc = (root, rel, fmBody) => {
2253
2302
  rmfix(T);
2254
2303
  }
2255
2304
 
2305
+ // --- 20. 引擎脚本测试覆盖 [warning](2026-10-01 gate-script-test-coverage) ---
2306
+ {
2307
+ // C20-1:缺测试报 warning、有测试与豁免登记不报(包源环境)
2308
+ const T = mkfix();
2309
+ const sd = path.join(T, 'templates', '_agents', 'scripts');
2310
+ fs.mkdirSync(sd, { recursive: true });
2311
+ fs.writeFileSync(path.join(sd, 'foo.mjs'), 'export {};\n');
2312
+ fs.writeFileSync(path.join(sd, 'foo.test.mjs'), '// ok\n');
2313
+ fs.writeFileSync(path.join(sd, 'bar.mjs'), 'export {};\n');
2314
+ fs.writeFileSync(path.join(sd, 'baz.mjs'), 'export {};\n');
2315
+ fs.writeFileSync(path.join(T, '.agents', 'scripts-test-exempt.txt'), 'baz.mjs | 库件 | 无\n');
2316
+ const r1 = run(T);
2317
+ const o1 = outOf(r1);
2318
+ check('检查20 C20-1 缺测试报 warning、有测试与豁免不报',
2319
+ r1.status === 0 && o1.includes('[WARN 脚本测试缺失] bar.mjs') && !o1.includes('foo.mjs') && !o1.includes('baz.mjs'), o1);
2320
+ rmfix(T);
2321
+ }
2322
+ {
2323
+ // C20-2:装户环境(无 templates/)整体跳过
2324
+ const T = mkfix();
2325
+ const r2 = run(T);
2326
+ check('检查20 C20-2 装户(无 templates/)跳过', !outOf(r2).includes('[WARN 脚本测试缺失]'), outOf(r2));
2327
+ rmfix(T);
2328
+ }
2329
+ {
2330
+ // C20-3:豁免文件缺失时全量列出(无豁免兜底)
2331
+ const T = mkfix();
2332
+ const sd = path.join(T, 'templates', '_agents', 'scripts');
2333
+ fs.mkdirSync(sd, { recursive: true });
2334
+ fs.writeFileSync(path.join(sd, 'zeta.mjs'), 'export {};\n');
2335
+ const r3 = run(T);
2336
+ check('检查20 C20-3 无豁免文件时缺测试脚本被列出', outOf(r3).includes('[WARN 脚本测试缺失] zeta.mjs'), outOf(r3));
2337
+ rmfix(T);
2338
+ }
2339
+
2340
+ // --- 方案 C:laneOfEntry 单源三态 + 检查 1 suspect 红线 + 检查 2 样例豁免(2026-10-02 caliber-convergence) ---
2341
+ {
2342
+ check('laneOfEntry 一致 low / 一致 high',
2343
+ laneOfEntry('L1', 'L1', '2026-10-01', '2026-10-02') === 'low' && laneOfEntry('L2', 'L2', '2026-10-01', '2026-10-02') === 'high');
2344
+ check('laneOfEntry 协议期:分歧 / risk 缺失 / 级别缺失 → suspect',
2345
+ laneOfEntry('L2', 'L1', '2026-10-01', '2026-10-02') === 'suspect'
2346
+ && laneOfEntry('L2', '', '2026-10-01', '2026-10-02') === 'suspect'
2347
+ && laneOfEntry('', 'L2', '2026-10-01', '2026-10-02') === 'suspect');
2348
+ check('laneOfEntry 协议前:单字段按级别 / 两值俱在分歧仍 suspect',
2349
+ laneOfEntry('L2', '', '2026-10-01', '2026-09-28') === 'high'
2350
+ && laneOfEntry('L2', 'L1', '2026-10-01', '2026-09-28') === 'suspect');
2351
+ check('laneOfDoc 单字段三态', laneOfDoc('L1') === 'low' && laneOfDoc('L3') === 'high' && laneOfDoc('') === 'suspect');
2352
+ }
2353
+ {
2354
+ // 检查 1:协议期分歧件(L2/L1)勾红线 → suspect 按 low 拦红线(hard)
2355
+ const T = mkfix();
2356
+ fs.writeFileSync(path.join(T, 'workflow', 'intents', '2026-10-02-x.md'),
2357
+ INTENT('x', '状态: approved\n级别: L2\nrisk_level: L1\n日期: 2026-10-02\n模块: pipeline',
2358
+ '\n## 触达红线\n- [x] 规则 / 契约变更(→ 级别至少 L2)\n'));
2359
+ fs.writeFileSync(path.join(T, 'workflow', 'plans', '2026-10-02-x.md'), '# PLAN\n');
2360
+ const r = run(T);
2361
+ check('检查1 分歧件勾红线 → hard-block(suspect 拦红线)', r.status === 1 && outOf(r).includes('[红线判低]') && outOf(r).includes('两字段对齐'), outOf(r));
2362
+ rmfix(T);
2363
+ }
2364
+ {
2365
+ // 检查 1 对照:协议前存量(级别 L2 无 risk_level)勾红线 → 不拦(单字段 high)
2366
+ const T = mkfix();
2367
+ fs.writeFileSync(path.join(T, 'workflow', 'intents', '2026-09-28-y.md'),
2368
+ INTENT('y', '状态: approved\n级别: L2\n日期: 2026-09-28\n模块: pipeline',
2369
+ '\n## 触达红线\n- [x] 规则 / 契约变更(→ 级别至少 L2)\n'));
2370
+ fs.writeFileSync(path.join(T, 'workflow', 'plans', '2026-09-28-y.md'), '# PLAN\n');
2371
+ const r = run(T);
2372
+ check('检查1 协议前 L2 单字段勾红线 → 不拦(存量零新增损害)', !outOf(r).includes('[红线判低]'), outOf(r));
2373
+ rmfix(T);
2374
+ }
2375
+ {
2376
+ // 检查 2:样例豁免三态——反引号/围栏块内不报,正文裸占位符照报
2377
+ const T = mkfix();
2378
+ fs.writeFileSync(path.join(T, 'workflow', 'intents', '2026-10-02-z.md'),
2379
+ INTENT('z', '状态: approved\n级别: L2\nrisk_level: L2\n日期: 2026-10-02\n模块: pipeline',
2380
+ '\n行内样例 `YYYY-MM-DD` 与 `<主题>` 不报。\n\n```\n日期: YYYY-MM-DD\n```\n\n裸占位:日期: YYYY-MM-DD\n'));
2381
+ fs.writeFileSync(path.join(T, 'workflow', 'plans', '2026-10-02-z.md'), '# PLAN\n');
2382
+ const r = run(T);
2383
+ const o = outOf(r);
2384
+ const zWarn = (o.match(/2026-10-02-z\.md 含模板占位符:[\s\S]*?(?=\n- |\n\n|$)/) || [''])[0];
2385
+ check('检查2 反引号/围栏样例不报、裸占位符照报',
2386
+ zWarn.includes('裸占位') && !zWarn.includes('行内样例') && !zWarn.includes('```'), o);
2387
+ rmfix(T);
2388
+ }
2389
+
2390
+ {
2391
+ // H7(2026-10-02 caliber-convergence 复核 P1-1):suspect(级别 L2/risk_level L1 分歧,协议期)转正
2392
+ // → 与 high 同样另须同名 spec——缺 spec 即拦(只增不松:suspect 不得逃 spec 前置)。
2393
+ const T = mkfix();
2394
+ w(T, 'workflow/intents/2026-10-02-h7.md',
2395
+ INTENT('h7', '状态: approved\n级别: L2\nrisk_level: L1\n日期: 2026-10-02\n模块: pipeline\n阶段: exploring'));
2396
+ w(T, 'workflow/plans/2026-10-02-h7.md', PLAN('h7', '状态: approved\n级别: L2'));
2397
+ const r = run(T, { args: ['--hardening'] });
2398
+ check('加固门 H7:suspect(L2/L1 分歧)转正 → 缺同名 spec 即拦',
2399
+ r.status === 1 && outOf(r).includes('缺同名 spec'), outOf(r));
2400
+ rmfix(T);
2401
+ }
2402
+
2256
2403
  console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
2257
2404
  process.exit(fail ? 1 : 0);
@@ -39,7 +39,8 @@ import path from 'node:path';
39
39
  import readline from 'node:readline';
40
40
  import { createHash, randomBytes } from 'node:crypto';
41
41
  import { fileURLToPath } from 'node:url';
42
- import { confirmGateFor, doneGateFor } from './stage-gates.mjs';
42
+ import { confirmGateFor, doneGateFor, laneOfEntry, laneOfDoc } from './stage-gates.mjs';
43
+ import { loadKitPolicy } from './policy.mjs';
43
44
 
44
45
  // computeFingerprint(text):CRLF 归一 → 剔指纹行 → sha256 hex(64 位)
45
46
  export function computeFingerprint(text) {
@@ -267,6 +268,16 @@ if (isMain) {
267
268
  }
268
269
  const target = resolveTransition(st, toTarget);
269
270
  const lvl = fmLevel(nl); // 级别(AI 自治门 / 协作道批量门共用;缺 = '')
271
+ // 泳道单源(2026-10-02 caliber-convergence 方案 C 分歧双严):intent 双字段(级别+risk_level,协议
272
+ // 锚 policy.riskLevelSince)三态判定;其余件级别单字段。suspect(缺失/非法/分歧)按 high——--auto
273
+ // 与 --batch 均不受理,须先两字段对齐(fill-intent 双写口径)。
274
+ const fmOf = (key) => {
275
+ const m = nl.find((l) => l.startsWith(`${key}:`));
276
+ return m ? m.slice(key.length + 1).trim() : '';
277
+ };
278
+ const lane = doc.includes('/intents/')
279
+ ? laneOfEntry(lvl, fmOf('risk_level'), loadKitPolicy(root).riskLevelSince, fmOf('日期'))
280
+ : laneOfDoc(lvl);
270
281
  if (!target) {
271
282
  console.error(`跳过 ${doc}:当前状态「${st || '缺失'}」无合法跳转(前向:docs draft→approved / approved→done;incidents open→fixed / fixed→closed;放弃 --to:cancelled 自 draft/approved/open/fixed,superseded 自 approved/done/fixed/closed)`);
272
283
  continue;
@@ -299,12 +310,12 @@ if (isMain) {
299
310
  // ② Level 1:仅 draft→approved;Level 2:draft→approved 与 approved→done;
300
311
  // ③ incidents 永不自治(open→fixed/fixed→closed 必须人工)。
301
312
  if (autoMode) {
302
- const isLowLevel = lvl === 'L0' || lvl === 'L1';
313
+ const isLowLevel = lane === 'low';
303
314
  const stageAllowed = trustConfig.level === 2
304
315
  ? (target === 'approved' || target === 'done')
305
316
  : target === 'approved';
306
317
  if (!isLowLevel || !stageAllowed || doc.startsWith('workflow/incidents/')) {
307
- console.error(`❌ ${doc} 未过 AI 自治门(级别「${lvl || '缺失'}」/ 目标态「${target}」/ 类型):Level ${trustConfig.level} 仅授权 L0/L1 的 ${trustConfig.level === 2 ? 'approved/done' : 'approved'} 且不含 incidents`);
318
+ console.error(`❌ ${doc} 未过 AI 自治门(级别「${lvl || '缺失'}」/ 泳道「${lane}」/ 目标态「${target}」/ 类型):Level ${trustConfig.level} 仅授权泳道 low(intent 两字段一致 L0/L1;suspect 须先对齐「级别/risk_level」)的 ${trustConfig.level === 2 ? 'approved/done' : 'approved'} 且不含 incidents`);
308
319
  refused++;
309
320
  continue;
310
321
  }
@@ -320,11 +331,12 @@ if (isMain) {
320
331
  continue;
321
332
  }
322
333
  if (delegated) {
323
- // 协作道门(2026-09-30 hybrid-governance-risk-lanes):--batch 批量仅受理 L0/L1——L2/L3(或缺级别,
324
- // fail-closed)逐份拒绝落账;不带 --batch 的单份代录不限级别(既有口径不变)。
325
- const low = lvl === 'L0' || lvl === 'L1';
334
+ // 协作道门(2026-09-30 hybrid-governance-risk-lanes;2026-10-02 caliber-convergence 起单源泳道):
335
+ // --batch 批量仅受理泳道 low(intent 两字段一致 L0/L1;suspect 拒)——high/suspect 逐份拒绝落账;
336
+ // 不带 --batch 的单份代录不限级别(既有口径不变)。
337
+ const low = lane === 'low';
326
338
  if (batchMode && !low) {
327
- console.error(`❌ ${doc} 级别「${lvl || '缺失'}」不在协作道(L0/L1)——--batch 批量代录仅限 L0/L1,L2/L3 防御道须逐份确认(本份未落账、未写盘)`);
339
+ console.error(`❌ ${doc} 泳道「${lane}」(级别「${lvl || '缺失'}」)不在协作道——--batch 批量代录仅限两字段一致的 L0/L1;suspect(级别/risk_level 缺失、非法或分歧)须先对齐两字段,L2/L3 防御道须逐份确认(本份未落账、未写盘)`);
328
340
  refused++;
329
341
  continue;
330
342
  }
@@ -572,6 +572,37 @@ const gitHead = (root) => {
572
572
  fs.rmSync(root, { recursive: true, force: true });
573
573
  }
574
574
 
575
+ // ---- S31b【方案 C 分歧双严】--batch 分歧件(级别 L1 / risk_level L2,协议期)→ 泳道 suspect 逐份拒绝(2026-10-02 caliber-convergence)----
576
+ {
577
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'confirm-bt3-'));
578
+ fs.mkdirSync(path.join(root, 'workflow', 'intents'), { recursive: true });
579
+ fs.mkdirSync(path.join(root, '.agents'), { recursive: true });
580
+ fs.writeFileSync(path.join(root, '.agents', 'kit.json'), JSON.stringify({ policyVersion: 2 }));
581
+ fs.writeFileSync(path.join(root, 'workflow', 'intents', '2026-10-02-dv1.md'), '---\n状态: draft\n级别: L1\nrisk_level: L2\n日期: 2026-10-02\n---\n# DV\n');
582
+ const r = spawnSync(process.execPath, [CLI, 'workflow/intents/2026-10-02-dv1.md', '--delegated', '批量放行', '--batch'], { cwd: root, encoding: 'utf8' });
583
+ const led = readLedgerOf(root);
584
+ const st = fs.readFileSync(path.join(root, 'workflow', 'intents', '2026-10-02-dv1.md'), 'utf8');
585
+ check('S31b --batch 分歧件 → 泳道 suspect 拒(exit 2 + 对齐两字段提示 + 零台账未落盘)',
586
+ r.status === 2 && /泳道「suspect」/.test(r.stderr) && /对齐/.test(r.stderr) && led.length === 0 && st.includes('状态: draft'),
587
+ JSON.stringify({ status: r.status, stderr: String(r.stderr).slice(0, 300), led }));
588
+ fs.rmSync(root, { recursive: true, force: true });
589
+ }
590
+
591
+ // ---- S31c【方案 C 分歧双严】--auto 分歧件 → suspect 拒(消息锁定,先于 trust 判定短路)----
592
+ {
593
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'confirm-bt4-'));
594
+ fs.mkdirSync(path.join(root, 'workflow', 'intents'), { recursive: true });
595
+ fs.mkdirSync(path.join(root, '.agents'), { recursive: true });
596
+ fs.writeFileSync(path.join(root, '.agents', 'kit.json'), JSON.stringify({ policyVersion: 2 }));
597
+ fs.writeFileSync(path.join(root, '.agents', 'trust-mode.json'), JSON.stringify({ enabled: true, level: 1, name: 'Standard', enabledAt: '2026-10-02T00:00:00.000Z' }));
598
+ fs.writeFileSync(path.join(root, 'workflow', 'intents', '2026-10-02-dv2.md'), '---\n状态: draft\n级别: L1\nrisk_level: L2\n日期: 2026-10-02\n---\n# DV2\n');
599
+ const r = spawnSync(process.execPath, [CLI, 'workflow/intents/2026-10-02-dv2.md', '--auto'], { cwd: root, encoding: 'utf8' });
600
+ check('S31c --auto(trust=Standard)分歧件 → 泳道 suspect 拒(消息含 suspect 与对齐指引)',
601
+ r.status !== 0 && /suspect/.test(r.stderr) && /对齐/.test(r.stderr),
602
+ JSON.stringify({ status: r.status, stderr: String(r.stderr).slice(0, 300) }));
603
+ fs.rmSync(root, { recursive: true, force: true });
604
+ }
605
+
575
606
  // ---- S32 --batch 组合约束:与 --auto / --to 互斥(exit 1,零落账)----
576
607
  {
577
608
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'confirm-bt3-'));
@@ -1,4 +1,4 @@
1
- // flow-kit fill-intent:起草 intent 草稿(背景与问题/目标/非目标/约束/影响面/触达红线/验收标准 共 7 节)
1
+ // flow-kit fill-intent:起草 intent 草稿(背景与问题/历史教训防复发/目标/非目标/约束/影响面/触达红线/验收标准 8 节正文 + 确认与复核 共 9 节)
2
2
  // 读 workflow/intents/_TEMPLATE.md 的节标题 → 输出符合 frontmatter 受限子集(6 字段,含 risk_level 风险泳道)的草稿到指定文件
3
3
  // 用法:node .agents/scripts/fill-intent.mjs --module pipeline --level L1 --topic "test" --output /tmp/x.md
4
4
  // 零依赖;CLI 解析用最简字符串匹配;不替代 AI 起草——本工具只输出模板与提示句
@@ -124,11 +124,12 @@ const runTool = (root, base, level) => spawnSync(process.execPath,
124
124
  fs.rmSync(root, { recursive: true, force: true });
125
125
  }
126
126
  {
127
- // S10c 探索泳道回落:级别缺失、risk_level=L0 → 放行(risk_level 兜底判道)
127
+ // S10c(2026-10-02 caliber-convergence 方案 C 改判):级别缺失 → suspect,不再回落 risk_level——
128
+ // 豁免仅认两字段一致的 low;缺失须先补「级别」再走起草(fail-closed 就严)。
128
129
  const root = mkws({ 'workflow/intents/2026-09-30-exp0.md': '---\n状态: draft\nrisk_level: L0\n日期: 2026-09-30\n---\n# I\n' });
129
130
  const r = runTool(root, '2026-09-30-exp0', 'L1');
130
- check('S10c 探索泳道回落:级别缺失 + risk_level L0 → 放行',
131
- r.status === 0 && fs.existsSync(path.join(root, 'workflow', 'plans', '2026-09-30-exp0.md')),
131
+ check('S10c 方案 C:级别缺失 + risk_level L0 → suspect 拒(不回落,先补级别)',
132
+ r.status === 2 && !fs.existsSync(path.join(root, 'workflow', 'plans', '2026-09-30-exp0.md')),
132
133
  JSON.stringify({ status: r.status, stderr: r.stderr }));
133
134
  fs.rmSync(root, { recursive: true, force: true });
134
135
  }
@@ -84,6 +84,7 @@ const PAIRS = [
84
84
  { doctor: '7', cl: '17', note: '经 §7 整体运行覆盖(发版提交树上仍未收口的 intent/spec/plan)' },
85
85
  { doctor: '7', cl: '18', note: '经 §7 整体运行覆盖(委派台账对账)' },
86
86
  { doctor: '7', cl: '19', note: '经 §7 整体运行覆盖(逐阶段审计——起草先于入口确认 / 台账审批顺序倒置)' },
87
+ { doctor: '7', cl: '20', note: '经 §7 整体运行覆盖(引擎脚本测试覆盖,2026-10-01 gate-script-test-coverage)' },
87
88
  ];
88
89
 
89
90
  // gateChecklist:按登记表对照两侧检查项(pure function;pairs 可注入供测试)
@@ -168,6 +168,59 @@ const idxText = fs.existsSync(path.join(WORKFLOW, 'INDEX.md')) ? fs.readFileSync
168
168
  const idxBytes = Buffer.byteLength(idxText, 'utf8');
169
169
  const idxActive = Number((idxText.match(/^## 活跃((\d+))/m) || [])[1] ?? NaN);
170
170
 
171
+ // ---- 闭环漏斗(2026-10-02 ledger-funnel-metrics;口径定义式单源 = workflow/specs/2026-10-02-ledger-funnel-metrics.md)----
172
+ // 只读台账一手字段(doc/stage/ts/source),坏行跳过(与检查 15/19 同容错);观测面不挂门禁、不新增文档断言(检查 16 零接触)。
173
+ const LEDGER_P = path.join('.agents', 'confirmations.jsonl');
174
+ function readLedgerRows() {
175
+ if (!fs.existsSync(LEDGER_P)) return [];
176
+ return fs.readFileSync(LEDGER_P, 'utf8').split(/\r?\n/)
177
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } })
178
+ .filter((e) => e && typeof e === 'object');
179
+ }
180
+
181
+ function funnelFromLedger(entries, month) {
182
+ const byDoc = new Map();
183
+ for (const e of entries) {
184
+ if (typeof e.doc !== 'string' || typeof e.ts !== 'string') continue;
185
+ if (!byDoc.has(e.doc)) byDoc.set(e.doc, []);
186
+ byDoc.get(e.doc).push(e);
187
+ }
188
+ const res = { closed: 0, full: 0, legacy: 0, once: 0, rework: 0, ledgerRows: 0, days: [], items: [] };
189
+ for (const e of entries) if (typeof e.ts === 'string' && e.ts.slice(0, 7) === month) res.ledgerRows++;
190
+ for (const [doc, rs] of byDoc) {
191
+ rs.sort((a, b) => String(a.ts).localeCompare(String(b.ts)));
192
+ const nonRevert = rs.filter((r) => !/^revert-/.test(String(r.stage || '')));
193
+ const terminals = nonRevert.filter((r) => r.stage === 'done' || r.stage === 'closed');
194
+ if (!terminals.length) continue; // 未收口件不进漏斗桶(仍计台账行数)
195
+ const lastTerm = terminals[terminals.length - 1];
196
+ if (String(lastTerm.ts).slice(0, 7) !== month) continue; // 月度归桶 = 终态行所在月
197
+ // 确认阶段按件型(复核 P2-3 收口):docs=approved;incidents=fixed(两跳确认门 fixed→closed,
198
+ // 永无 approved 行——按 approved 判会把已走确认门的 incident 误归协议前)
199
+ const isInc = doc.includes('/incidents/');
200
+ const confirmStage = isInc ? 'fixed' : 'approved';
201
+ const confirms = nonRevert.filter((r) => r.stage === confirmStage);
202
+ const hasChain = confirms.length >= 1;
203
+ const hasRevert = rs.some((r) => /^revert-/.test(String(r.stage || '')));
204
+ const kind = !hasChain ? 'legacy' : (hasRevert || confirms.length >= 2 || terminals.length >= 2 ? 'rework' : 'once');
205
+ const firstTs = hasChain ? confirms[0].ts : rs[0].ts; // 协议前件首行起算
206
+ const days = (Date.parse(lastTerm.ts) - Date.parse(firstTs)) / 86400000;
207
+ res.closed++;
208
+ if (kind === 'legacy') res.legacy++;
209
+ else { res.full++; res[kind === 'once' ? 'once' : 'rework']++; }
210
+ if (Number.isFinite(days) && days >= 0) res.days.push(days);
211
+ res.items.push({ doc, kind, days: Number.isFinite(days) && days >= 0 ? days.toFixed(1) : '—' });
212
+ }
213
+ return res;
214
+ }
215
+
216
+ const ledgerRows = readLedgerRows();
217
+ const funnel = funnelFromLedger(ledgerRows, month);
218
+ const sortedDays = [...funnel.days].sort((a, b) => a - b);
219
+ const medianDays = sortedDays.length
220
+ ? (sortedDays.length % 2 ? sortedDays[(sortedDays.length - 1) / 2] : (sortedDays[sortedDays.length / 2 - 1] + sortedDays[sortedDays.length / 2]) / 2)
221
+ : null;
222
+ const funnelRow = `| ${month} | ${funnel.closed} | ${funnel.full} | ${funnel.legacy} | ${funnel.once} | ${funnel.rework} | ${medianDays === null ? '—' : medianDays.toFixed(1)} | ${funnel.ledgerRows} |`;
223
+
171
224
  const docs = scanDocs();
172
225
  const surf = scanSurface();
173
226
  const row = `| ${month} | ${docs.files}(活跃 ${docs.active} / 终态 ${docs.terminal}) | ${kb(docs.bytes)} | ${docs.withModule}/${docs.files} | ${kb(surf.total)}(峰值 ${surf.peak ? `${surf.peak.pct.toFixed(1)}% ${surf.peak.label}` : '—'}) | ${kb(idxBytes)}(${Number.isNaN(idxActive) ? '—' : idxActive} 行) |`;
@@ -179,9 +232,12 @@ for (const [t, v] of Object.entries(docs.perType)) console.log(` ${t.padEnd(1
179
232
  console.log(` 索引:workflow/INDEX.md ${kb(idxBytes)}(活跃 ${Number.isNaN(idxActive) ? '—' : idxActive} 行)`);
180
233
  for (const e of surf.entries) console.log(` ${e.label.padEnd(44)} ${String(e.max).padStart(6)} / ${String(e.lim).padStart(6)} B(${e.pct.toFixed(1)}%)`);
181
234
  console.log(` 常驻面合计:${kb(surf.total)}`);
235
+ console.log(` 闭环漏斗:收口 ${funnel.closed}(完整链 ${funnel.full} = 一次通过 ${funnel.once} + 返工 ${funnel.rework};协议前 ${funnel.legacy})|中位周期 ${medianDays === null ? '—' : `${medianDays.toFixed(1)} 天`}|台账行 ${funnel.ledgerRows}`);
236
+ for (const it of funnel.items) console.log(` ${String(it.doc).padEnd(58)} ${it.kind.padEnd(7)} ${it.days} 天`);
237
+ if (funnel.items.length) console.log(' ℹ️ 机器口径可与 workflow/delegations.md 自做表的人工自报交叉对照(差异即信号,不对账)');
182
238
  console.log(` ${DRY ? '(--dry-run,未写盘)' : ''}`);
183
239
 
184
- if (DRY) { console.log(`\n将写入行:\n${row}`); process.exit(0); }
240
+ if (DRY) { console.log(`\n将写入行:\n${row}\n${funnelRow}`); process.exit(0); }
185
241
 
186
242
  // ---- 组装:GENERATED 区内的月表按月份键 upsert(同月重跑覆盖,行序按月升序)----
187
243
  const HEADER = `# workflow 规模与规则面月度快照
@@ -193,17 +249,31 @@ const TABLE_HEAD = [
193
249
  '| 月份 | 文档(活跃/终态) | 文档字节 | 模块已填/总 | 常驻面字节(预算峰值) | INDEX(活跃行) |',
194
250
  '|---|---|---|---|---|---|',
195
251
  ];
252
+ // 漏斗表头(第二表;口径定义式单源 = workflow/specs/2026-10-02-ledger-funnel-metrics.md)
253
+ const FUNNEL_HEAD = [
254
+ '## 闭环漏斗(机器口径,源:.agents/confirmations.jsonl——定义式见 specs/2026-10-02-ledger-funnel-metrics.md)',
255
+ '',
256
+ '| 月份 | 收口 | 完整链 | 协议前 | 一次通过 | 返工件 | 中位周期(天) | 台账行数 |',
257
+ '|---|---|---|---|---|---|---|---|',
258
+ ];
196
259
  const existing = fs.existsSync(METRICS_P) ? fs.readFileSync(METRICS_P, 'utf8') : '';
197
260
  let rows = [];
261
+ let funnelRows = [];
198
262
  if (existing.includes(BEGIN) && existing.includes(END)) {
199
- rows = existing.slice(existing.indexOf(BEGIN), existing.indexOf(END))
200
- .split(/\r?\n/)
201
- .filter((l) => /^\|\s*\d{4}-\d{2}\s*\|/.test(l));
263
+ const section = existing.slice(existing.indexOf(BEGIN), existing.indexOf(END));
264
+ const marker = FUNNEL_HEAD[0];
265
+ const bodyPart = section.includes(marker) ? section.slice(0, section.indexOf(marker)) : section;
266
+ const funnelPart = section.includes(marker) ? section.slice(section.indexOf(marker)) : '';
267
+ rows = bodyPart.split(/\r?\n/).filter((l) => /^\|\s*\d{4}-\d{2}\s*\|/.test(l));
268
+ funnelRows = funnelPart.split(/\r?\n/).filter((l) => /^\|\s*\d{4}-\d{2}\s*\|/.test(l));
202
269
  }
203
270
  rows = rows.filter((l) => !l.startsWith(`| ${month} `));
204
271
  rows.push(row);
205
272
  rows.sort((a, b) => a.slice(2, 9).localeCompare(b.slice(2, 9)));
206
- const inner = `${BEGIN}\n\n${[...TABLE_HEAD, ...rows].join('\n')}\n\n${END}`;
273
+ funnelRows = funnelRows.filter((l) => !l.startsWith(`| ${month} `));
274
+ funnelRows.push(funnelRow);
275
+ funnelRows.sort((a, b) => a.slice(2, 9).localeCompare(b.slice(2, 9)));
276
+ const inner = `${BEGIN}\n\n${[...TABLE_HEAD, ...rows].join('\n')}\n\n${[...FUNNEL_HEAD, ...funnelRows].join('\n')}\n\n${END}`;
207
277
  let out;
208
278
  if (existing.includes(BEGIN) && existing.includes(END)) {
209
279
  out = existing.slice(0, existing.indexOf(BEGIN)) + inner + existing.slice(existing.indexOf(END) + END.length);