agentic-flow-kit 0.9.0 → 0.9.4

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 (61) hide show
  1. package/modules/hosts/claude/commands/wf-build.md +3 -3
  2. package/modules/hosts/claude/commands/wf-design.md +3 -3
  3. package/modules/hosts/claude/commands/wf-new-task.md +13 -2
  4. package/modules/hosts/claude/commands/wf-plan.md +3 -3
  5. package/modules/hosts/claude/commands/wf-sync-hosts.md +1 -1
  6. package/modules/hosts/claude/commands/wf-test.md +1 -1
  7. package/modules/hosts/codex/commands/wf-build.md +3 -3
  8. package/modules/hosts/codex/commands/wf-design.md +3 -3
  9. package/modules/hosts/codex/commands/wf-new-task.md +13 -2
  10. package/modules/hosts/codex/commands/wf-plan.md +3 -3
  11. package/modules/hosts/codex/commands/wf-sync-hosts.md +1 -1
  12. package/modules/hosts/codex/commands/wf-test.md +1 -1
  13. package/modules/hosts/cursor/commands/wf-build.md +3 -3
  14. package/modules/hosts/cursor/commands/wf-design.md +3 -3
  15. package/modules/hosts/cursor/commands/wf-new-task.md +13 -2
  16. package/modules/hosts/cursor/commands/wf-plan.md +3 -3
  17. package/modules/hosts/cursor/commands/wf-sync-hosts.md +1 -1
  18. package/modules/hosts/cursor/commands/wf-test.md +1 -1
  19. package/modules/hosts/opencode/commands/wf-build.md +3 -3
  20. package/modules/hosts/opencode/commands/wf-design.md +3 -3
  21. package/modules/hosts/opencode/commands/wf-new-task.md +13 -2
  22. package/modules/hosts/opencode/commands/wf-plan.md +3 -3
  23. package/modules/hosts/opencode/commands/wf-sync-hosts.md +1 -1
  24. package/modules/hosts/opencode/commands/wf-test.md +1 -1
  25. package/modules/hosts/trae/commands/wf-build.md +3 -3
  26. package/modules/hosts/trae/commands/wf-design.md +3 -3
  27. package/modules/hosts/trae/commands/wf-new-task.md +13 -2
  28. package/modules/hosts/trae/commands/wf-plan.md +3 -3
  29. package/modules/hosts/trae/commands/wf-sync-hosts.md +1 -1
  30. package/modules/hosts/trae/commands/wf-test.md +1 -1
  31. package/modules/hosts/trae/rules/project-workflow.md +1 -1
  32. package/package.json +1 -1
  33. package/templates/_agents/commands/build.md +2 -2
  34. package/templates/_agents/commands/design.md +1 -1
  35. package/templates/_agents/commands/new-task.md +1 -1
  36. package/templates/_agents/commands/plan.md +3 -3
  37. package/templates/_agents/commands/sync-hosts.md +1 -1
  38. package/templates/_agents/scripts/check-lane-surface.mjs +20 -7
  39. package/templates/_agents/scripts/check-lane-surface.test.mjs +24 -2
  40. package/templates/_agents/scripts/check-ledger-invariant.mjs +135 -0
  41. package/templates/_agents/scripts/check-ledger-invariant.test.mjs +161 -0
  42. package/templates/_agents/scripts/check-loop.mjs +58 -15
  43. package/templates/_agents/scripts/check-loop.test.mjs +100 -2
  44. package/templates/_agents/scripts/confirm-doc.mjs +19 -7
  45. package/templates/_agents/scripts/confirm-doc.test.mjs +31 -0
  46. package/templates/_agents/scripts/fill-intent.mjs +1 -1
  47. package/templates/_agents/scripts/fill-plan.test.mjs +4 -3
  48. package/templates/_agents/scripts/gate-checklist.mjs +1 -0
  49. package/templates/_agents/scripts/gen-workflow-metrics.mjs +75 -5
  50. package/templates/_agents/scripts/gen-workflow-metrics.test.mjs +104 -2
  51. package/templates/_agents/scripts/policy.mjs +1 -0
  52. package/templates/_agents/scripts/pre-push.test.mjs +89 -0
  53. package/templates/_agents/scripts/solidify-task.mjs +76 -70
  54. package/templates/_agents/scripts/solidify-task.test.mjs +129 -0
  55. package/templates/_agents/scripts/stage-gates.mjs +29 -6
  56. package/templates/_agents/scripts/trust-mode.test.mjs +110 -0
  57. package/templates/_agents/scripts-test-exempt.txt +10 -0
  58. package/templates/_githooks/pre-commit +12 -0
  59. package/templates/_githooks/pre-push +34 -30
  60. package/templates/workflow/README.md +4 -3
  61. 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
  }
@@ -1034,7 +1050,7 @@ runCheck16({ ROOT, ENUMS, docFiles, fmGet, inSet, isTracked, linesOf, readdirOrN
1034
1050
  const body = (linesOf(iabs) || []).join('\n');
1035
1051
  const d19 = fmGet(iabs, '日期') || fmGet(iabs, '发现');
1036
1052
  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 };
1053
+ return { ...r, level: lvl19, risk: fmGet(iabs, 'risk_level'), date: d19, entryDraft: st19 === 'draft', storageLegacy: storLegacy };
1038
1054
  }
1039
1055
  const crel = `${WF}/incidents/${base}.md`;
1040
1056
  const cabs = path.join(ROOT, crel);
@@ -1058,10 +1074,11 @@ runCheck16({ ROOT, ENUMS, docFiles, fmGet, inSet, isTracked, linesOf, readdirOrN
1058
1074
  const rel = path.relative(ROOT, abs).split(path.sep).join('/');
1059
1075
  const ent = entryConfirmed19(base);
1060
1076
  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;
1077
+ // 协作道异步审计豁免(2026-09-30 hybrid-governance-risk-lanes;2026-10-02 caliber-convergence
1078
+ // 起单源 laneOfEntry·方案 C):入口 intent 仍为 draft 且泳道 low(两字段一致 L0/L1)→「先动手后
1079
+ // 确认」是泳道语义,不告警;suspect(缺失/非法/分歧)不豁免;approved/done 却无台账行仍照报
1080
+ // (防手改状态冒充确认,与交叉一致性口径同);incident 入口不豁免(过目留痕本就轻量)。
1081
+ if (ent.entryDraft && laneOfEntry(ent.level, ent.risk, kitPolicy.riskLevelSince, ent.date) === 'low') continue;
1065
1082
  warnings.push(`- [WARN 逐阶段] ${rel} 起草先于入口确认(${ent.why})`); continue;
1066
1083
  }
1067
1084
  // 库存量形态(标记 / 日期早于生效日)短路 level 校验与 spec 档(与起草门同口径,N1)
@@ -1120,7 +1137,8 @@ if (HARDENING) {
1120
1137
  const st = fmGet(intent, '状态');
1121
1138
  if (st === 'superseded' || st === 'cancelled') continue; // 放弃态:探索作废留档,不加固
1122
1139
  const lvl = fmGet(intent, '级别');
1123
- const lane = /^L[0-3]$/.test(lvl) ? lvl : fmGet(intent, 'risk_level');
1140
+ // 泳道单源(2026-10-02 caliber-convergence 方案 C):suspect(缺失/非法/分歧)按 high——L2/L3 另须 spec
1141
+ const lane = laneOfEntry(lvl, fmGet(intent, 'risk_level'), kitPolicy.riskLevelSince, fmGet(intent, '日期'));
1124
1142
  if (st !== 'approved' && st !== 'done') {
1125
1143
  blockers.push(`- [加固未过] exploring 任务入 main 须先收口:${base} intent 状态仍为『${st || '缺失'}』——先确认至 approved/done 并补齐同名 spec+plan,或显式放弃(superseded/cancelled)`);
1126
1144
  continue;
@@ -1132,11 +1150,13 @@ if (HARDENING) {
1132
1150
  } else if (!stOkDoc(planSt)) {
1133
1151
  blockers.push(`- [加固未过] exploring 任务入 main 的 plan 未收口:${base}(plan 状态『${planSt || '缺失'}』须为 approved/done)`);
1134
1152
  }
1135
- if (lane === 'L2' || lane === 'L3') {
1153
+ // 泳道单源(2026-10-02 caliber-convergence 方案 C;复核 P1-1 修正):suspect(缺失/非法/分歧)与
1154
+ // high 同样另须同名 spec——仅 low(两字段一致 L0/L1)免 spec,只增不松。
1155
+ if (lane !== 'low') {
1136
1156
  const specAbs = path.join(ROOT, WF, 'specs', base);
1137
1157
  const specSt = fs.existsSync(specAbs) ? fmGet(specAbs, '状态') : '';
1138
1158
  if (!fs.existsSync(specAbs)) {
1139
- blockers.push(`- [加固未过] exploring 任务入 main 缺同名 spec:${base}(级别 ${lane},应在 ${WF}/specs/ 下同名)`);
1159
+ blockers.push(`- [加固未过] exploring 任务入 main 缺同名 spec:${base}(级别 ${lvl}/risk ${fmGet(intent, 'risk_level') || '缺失'},应在 ${WF}/specs/ 下同名)`);
1140
1160
  } else if (!stOkDoc(specSt)) {
1141
1161
  blockers.push(`- [加固未过] exploring 任务入 main 的 spec 未收口:${base}(spec 状态『${specSt || '缺失'}』须为 approved/done)`);
1142
1162
  }
@@ -1189,6 +1209,29 @@ function fmStatus(text) {
1189
1209
  return '';
1190
1210
  }
1191
1211
 
1212
+ // --- 20. 引擎脚本测试覆盖 [warning](2026-10-01 gate-script-test-coverage;仅包源环境) ---
1213
+ // 包源开发纪律:templates/_agents/scripts/ 下每个非 *.test.mjs 引擎脚本须有同名兄弟 .test.mjs,
1214
+ // 或在 .agents/scripts-test-exempt.txt 登记豁免(managed 下发)。装户(无 templates/)整体跳过。
1215
+ {
1216
+ const scriptsDir = path.join(ROOT, 'templates', '_agents', 'scripts');
1217
+ if (fs.existsSync(scriptsDir)) {
1218
+ const exemptFile = path.join(ROOT, '.agents', 'scripts-test-exempt.txt');
1219
+ const exempt = fs.existsSync(exemptFile)
1220
+ ? fs.readFileSync(exemptFile, 'utf8').split(/\r?\n/)
1221
+ .map((l) => l.trim())
1222
+ .filter((l) => l && !l.startsWith('#'))
1223
+ .map((l) => l.split('|')[0].trim())
1224
+ .filter(Boolean)
1225
+ : [];
1226
+ const orphans = fs.readdirSync(scriptsDir)
1227
+ .filter((f) => f.endsWith('.mjs') && !f.endsWith('.test.mjs') && !exempt.includes(f))
1228
+ .filter((f) => !fs.existsSync(path.join(scriptsDir, f.replace(/\.mjs$/, '.test.mjs'))));
1229
+ for (const f of orphans.sort()) {
1230
+ warnings.push(`- [WARN 脚本测试缺失] ${f} 缺同名 .test.mjs——新增引擎脚本默认必须带测试;库件/工具可在 .agents/scripts-test-exempt.txt 登记豁免`);
1231
+ }
1232
+ }
1233
+ }
1234
+
1192
1235
  // --- 输出(banner 沿 sh 版字样与格式 = 稳定输出契约:banner 后空行、条目 '- ' 前缀)---
1193
1236
  if (blockers.length) {
1194
1237
  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');
@@ -2179,7 +2179,7 @@ const mkConfirmedDoc = (root, rel, fmBody) => {
2179
2179
  rmfix(T);
2180
2180
  }
2181
2181
 
2182
- // ---- 加固门(2026-09-30 hybrid-governance-explore-hardening;仅 --hardening 启用,不占 1-19 编号)----
2182
+ // ---- 加固门(2026-09-30 hybrid-governance-explore-hardening;仅 --hardening 启用,不占 1-20 编号)----
2183
2183
  {
2184
2184
  // H1:未标记 exploring 的 draft 件 → 带/不带 --hardening 均不拦(本门只管「已标记未收口」,
2185
2185
  // 常规流程的 draft 中间态不受影响——intent approved 后 plan 起草属正常在途)
@@ -2253,5 +2253,103 @@ const mkConfirmedDoc = (root, rel, fmBody) => {
2253
2253
  rmfix(T);
2254
2254
  }
2255
2255
 
2256
+ // --- 20. 引擎脚本测试覆盖 [warning](2026-10-01 gate-script-test-coverage) ---
2257
+ {
2258
+ // C20-1:缺测试报 warning、有测试与豁免登记不报(包源环境)
2259
+ const T = mkfix();
2260
+ const sd = path.join(T, 'templates', '_agents', 'scripts');
2261
+ fs.mkdirSync(sd, { recursive: true });
2262
+ fs.writeFileSync(path.join(sd, 'foo.mjs'), 'export {};\n');
2263
+ fs.writeFileSync(path.join(sd, 'foo.test.mjs'), '// ok\n');
2264
+ fs.writeFileSync(path.join(sd, 'bar.mjs'), 'export {};\n');
2265
+ fs.writeFileSync(path.join(sd, 'baz.mjs'), 'export {};\n');
2266
+ fs.writeFileSync(path.join(T, '.agents', 'scripts-test-exempt.txt'), 'baz.mjs | 库件 | 无\n');
2267
+ const r1 = run(T);
2268
+ const o1 = outOf(r1);
2269
+ check('检查20 C20-1 缺测试报 warning、有测试与豁免不报',
2270
+ r1.status === 0 && o1.includes('[WARN 脚本测试缺失] bar.mjs') && !o1.includes('foo.mjs') && !o1.includes('baz.mjs'), o1);
2271
+ rmfix(T);
2272
+ }
2273
+ {
2274
+ // C20-2:装户环境(无 templates/)整体跳过
2275
+ const T = mkfix();
2276
+ const r2 = run(T);
2277
+ check('检查20 C20-2 装户(无 templates/)跳过', !outOf(r2).includes('[WARN 脚本测试缺失]'), outOf(r2));
2278
+ rmfix(T);
2279
+ }
2280
+ {
2281
+ // C20-3:豁免文件缺失时全量列出(无豁免兜底)
2282
+ const T = mkfix();
2283
+ const sd = path.join(T, 'templates', '_agents', 'scripts');
2284
+ fs.mkdirSync(sd, { recursive: true });
2285
+ fs.writeFileSync(path.join(sd, 'zeta.mjs'), 'export {};\n');
2286
+ const r3 = run(T);
2287
+ check('检查20 C20-3 无豁免文件时缺测试脚本被列出', outOf(r3).includes('[WARN 脚本测试缺失] zeta.mjs'), outOf(r3));
2288
+ rmfix(T);
2289
+ }
2290
+
2291
+ // --- 方案 C:laneOfEntry 单源三态 + 检查 1 suspect 红线 + 检查 2 样例豁免(2026-10-02 caliber-convergence) ---
2292
+ {
2293
+ check('laneOfEntry 一致 low / 一致 high',
2294
+ laneOfEntry('L1', 'L1', '2026-10-01', '2026-10-02') === 'low' && laneOfEntry('L2', 'L2', '2026-10-01', '2026-10-02') === 'high');
2295
+ check('laneOfEntry 协议期:分歧 / risk 缺失 / 级别缺失 → suspect',
2296
+ laneOfEntry('L2', 'L1', '2026-10-01', '2026-10-02') === 'suspect'
2297
+ && laneOfEntry('L2', '', '2026-10-01', '2026-10-02') === 'suspect'
2298
+ && laneOfEntry('', 'L2', '2026-10-01', '2026-10-02') === 'suspect');
2299
+ check('laneOfEntry 协议前:单字段按级别 / 两值俱在分歧仍 suspect',
2300
+ laneOfEntry('L2', '', '2026-10-01', '2026-09-28') === 'high'
2301
+ && laneOfEntry('L2', 'L1', '2026-10-01', '2026-09-28') === 'suspect');
2302
+ check('laneOfDoc 单字段三态', laneOfDoc('L1') === 'low' && laneOfDoc('L3') === 'high' && laneOfDoc('') === 'suspect');
2303
+ }
2304
+ {
2305
+ // 检查 1:协议期分歧件(L2/L1)勾红线 → suspect 按 low 拦红线(hard)
2306
+ const T = mkfix();
2307
+ fs.writeFileSync(path.join(T, 'workflow', 'intents', '2026-10-02-x.md'),
2308
+ INTENT('x', '状态: approved\n级别: L2\nrisk_level: L1\n日期: 2026-10-02\n模块: pipeline',
2309
+ '\n## 触达红线\n- [x] 规则 / 契约变更(→ 级别至少 L2)\n'));
2310
+ fs.writeFileSync(path.join(T, 'workflow', 'plans', '2026-10-02-x.md'), '# PLAN\n');
2311
+ const r = run(T);
2312
+ check('检查1 分歧件勾红线 → hard-block(suspect 拦红线)', r.status === 1 && outOf(r).includes('[红线判低]') && outOf(r).includes('两字段对齐'), outOf(r));
2313
+ rmfix(T);
2314
+ }
2315
+ {
2316
+ // 检查 1 对照:协议前存量(级别 L2 无 risk_level)勾红线 → 不拦(单字段 high)
2317
+ const T = mkfix();
2318
+ fs.writeFileSync(path.join(T, 'workflow', 'intents', '2026-09-28-y.md'),
2319
+ INTENT('y', '状态: approved\n级别: L2\n日期: 2026-09-28\n模块: pipeline',
2320
+ '\n## 触达红线\n- [x] 规则 / 契约变更(→ 级别至少 L2)\n'));
2321
+ fs.writeFileSync(path.join(T, 'workflow', 'plans', '2026-09-28-y.md'), '# PLAN\n');
2322
+ const r = run(T);
2323
+ check('检查1 协议前 L2 单字段勾红线 → 不拦(存量零新增损害)', !outOf(r).includes('[红线判低]'), outOf(r));
2324
+ rmfix(T);
2325
+ }
2326
+ {
2327
+ // 检查 2:样例豁免三态——反引号/围栏块内不报,正文裸占位符照报
2328
+ const T = mkfix();
2329
+ fs.writeFileSync(path.join(T, 'workflow', 'intents', '2026-10-02-z.md'),
2330
+ INTENT('z', '状态: approved\n级别: L2\nrisk_level: L2\n日期: 2026-10-02\n模块: pipeline',
2331
+ '\n行内样例 `YYYY-MM-DD` 与 `<主题>` 不报。\n\n```\n日期: YYYY-MM-DD\n```\n\n裸占位:日期: YYYY-MM-DD\n'));
2332
+ fs.writeFileSync(path.join(T, 'workflow', 'plans', '2026-10-02-z.md'), '# PLAN\n');
2333
+ const r = run(T);
2334
+ const o = outOf(r);
2335
+ const zWarn = (o.match(/2026-10-02-z\.md 含模板占位符:[\s\S]*?(?=\n- |\n\n|$)/) || [''])[0];
2336
+ check('检查2 反引号/围栏样例不报、裸占位符照报',
2337
+ zWarn.includes('裸占位') && !zWarn.includes('行内样例') && !zWarn.includes('```'), o);
2338
+ rmfix(T);
2339
+ }
2340
+
2341
+ {
2342
+ // H7(2026-10-02 caliber-convergence 复核 P1-1):suspect(级别 L2/risk_level L1 分歧,协议期)转正
2343
+ // → 与 high 同样另须同名 spec——缺 spec 即拦(只增不松:suspect 不得逃 spec 前置)。
2344
+ const T = mkfix();
2345
+ w(T, 'workflow/intents/2026-10-02-h7.md',
2346
+ INTENT('h7', '状态: approved\n级别: L2\nrisk_level: L1\n日期: 2026-10-02\n模块: pipeline\n阶段: exploring'));
2347
+ w(T, 'workflow/plans/2026-10-02-h7.md', PLAN('h7', '状态: approved\n级别: L2'));
2348
+ const r = run(T, { args: ['--hardening'] });
2349
+ check('加固门 H7:suspect(L2/L1 分歧)转正 → 缺同名 spec 即拦',
2350
+ r.status === 1 && outOf(r).includes('缺同名 spec'), outOf(r));
2351
+ rmfix(T);
2352
+ }
2353
+
2256
2354
  console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
2257
2355
  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);