agentic-flow-kit 1.8.2 → 1.8.7

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 (47) hide show
  1. package/README.md +1 -1
  2. package/modules/hosts/claude/commands/wf-build.md +1 -1
  3. package/modules/hosts/claude/commands/wf-design.md +1 -1
  4. package/modules/hosts/claude/commands/wf-review.md +1 -1
  5. package/modules/hosts/codex/commands/wf-build.md +1 -1
  6. package/modules/hosts/codex/commands/wf-design.md +1 -1
  7. package/modules/hosts/codex/commands/wf-review.md +1 -1
  8. package/modules/hosts/cursor/commands/wf-build.md +1 -1
  9. package/modules/hosts/cursor/commands/wf-design.md +1 -1
  10. package/modules/hosts/cursor/commands/wf-review.md +1 -1
  11. package/modules/hosts/opencode/commands/wf-build.md +1 -1
  12. package/modules/hosts/opencode/commands/wf-design.md +1 -1
  13. package/modules/hosts/opencode/commands/wf-review.md +1 -1
  14. package/modules/hosts/trae/commands/wf-build.md +1 -1
  15. package/modules/hosts/trae/commands/wf-design.md +1 -1
  16. package/modules/hosts/trae/commands/wf-review.md +1 -1
  17. package/package.json +1 -1
  18. package/src/jscpd-drift.test.mjs +3 -2
  19. package/templates/AGENTS.md +1 -0
  20. package/templates/_agents/commands/build.md +1 -1
  21. package/templates/_agents/commands/design.md +1 -1
  22. package/templates/_agents/commands/review.md +1 -1
  23. package/templates/_agents/engine-lock.json +12 -12
  24. package/templates/_agents/mechanism-registry.md +8 -0
  25. package/templates/_agents/rule-budgets.txt +4 -2
  26. package/templates/_agents/scripts/check-lane-surface.test.mjs +1 -1
  27. package/templates/_agents/scripts/check-ledger-invariant.mjs +80 -21
  28. package/templates/_agents/scripts/check-ledger-invariant.test.mjs +1 -1
  29. package/templates/_agents/scripts/check-loop.mjs +32 -3
  30. package/templates/_agents/scripts/check-loop.test.mjs +37 -0
  31. package/templates/_agents/scripts/check-scope.test.mjs +4 -1
  32. package/templates/_agents/scripts/check-scripts-syntax.mjs +33 -10
  33. package/templates/_agents/scripts/confirm-doc.mjs +13 -6
  34. package/templates/_agents/scripts/confirm-doc.test.mjs +25 -9
  35. package/templates/_agents/scripts/gen-workflow-metrics.mjs +141 -9
  36. package/templates/_agents/scripts/gen-workflow-metrics.test.mjs +119 -0
  37. package/templates/_agents/scripts/lane-surface-audit.test.mjs +1 -1
  38. package/templates/_agents/scripts/pre-push.test.mjs +1 -1
  39. package/templates/_agents/scripts/solidify-task.mjs +3 -1
  40. package/templates/_agents/scripts/solidify-task.test.mjs +10 -10
  41. package/templates/_agents/scripts/trust-mode.test.mjs +1 -1
  42. package/templates/_agents/scripts/verify.mjs +209 -74
  43. package/templates/_agents/scripts/verify.test.mjs +15 -0
  44. package/templates/_agents/skills/review-method/SKILL.md +3 -0
  45. package/templates/workflow/README.md +2 -1
  46. package/templates/workflow/intents/_TEMPLATE.md +5 -0
  47. package/templates/workflow/specs/_TEMPLATE.md +1 -1
@@ -928,6 +928,12 @@ export function runCheckLoop(opts = {}) {
928
928
  {
929
929
  const accCutoff = '2026-09-12';
930
930
  let legacyUnaccounted = 0;
931
+ // 测试绿缺凭证聚合面(2026-10-18 告警聚合口径批):**判据不变**(同一批文档/证据项照常检出),
932
+ // 只改输出形态——单文档多证据项只计一份;跨运行当日去重(同文档当日只逐份点名一次,已点名的
933
+ // 进一条聚合计数行,防静默被误读为已修)。背景:源仓 9 天遥测该 WARN 占全部告警 61%
934
+ //(1062/1738,文档密集期 14.8 条/运行)——复读刷屏稀释 WARN 信号价值。状态面放 os.tmpdir()
935
+ // 按 ROOT 派生键(本地机判、不进装户仓、无 gitignore 依赖、fail-open:读失败=全点名,落失败不影响裁决)。
936
+ const verifyMissingDocs = new Map(); // rel -> base(去重键 = 仓内相对路径)
931
937
  // 测试绿凭证状态(2026-10-07 verify-evidence):台账读一次循环外缓存;policy 缺 verifySince
932
938
  //(v1-v4)→ ctxBase null → 对账整体跳过(向后兼容)
933
939
  const verifySinceOf = kitPolicy.verifySince;
@@ -1029,9 +1035,7 @@ export function runCheckLoop(opts = {}) {
1029
1035
  const hasImplEvidence = results.some((r) => r.t.ok && (r.t.type === 'sha' || r.t.type === 'process'));
1030
1036
  for (const r of results) {
1031
1037
  if (r.t.ok && r.t.type === 'verify-missing') {
1032
- // ⚠️ 文案锚点硬约束(既有 check-loop.test.mjs:2743 锚定「测试绿缺凭证」四字连续,零改动前提):
1033
- // doc 与修复命令只能追加在整句之后,不得插进这四个字中间。
1034
- warnings.push(`- [WARN 测试绿缺凭证] ${base} 验收证据声明「测试绿」但 24h 内无 verify 凭证(verifications.jsonl${verifyDocBound ? `;本单须带 --doc 落账` : ''})——跑 node .agents/scripts/verify.mjs${verifyDocBound ? ` --doc ${rel}` : ''} 后重跑(advisory,灰度第一档)`);
1038
+ verifyMissingDocs.set(rel, base); // 出账统一在循环外(聚合/当日去重,见声明处注释)
1035
1039
  continue;
1036
1040
  }
1037
1041
  if (r.t.ok) continue;
@@ -1120,6 +1124,31 @@ export function runCheckLoop(opts = {}) {
1120
1124
  legacyUnaccounted++;
1121
1125
  }
1122
1126
  }
1127
+ // 测试绿缺凭证出账(判据见 collector;⚠️ 文案锚点硬约束:既有 check-loop.test.mjs 锚定
1128
+ // 「测试绿缺凭证」四字连续,doc 与修复命令只能追加在整句之后,不得插进四字中间):
1129
+ // 当日首次命中的文档逐份点名(可操作明细);当日已点名的聚合成一条计数行。
1130
+ if (verifyMissingDocs.size) {
1131
+ const today = new Date().toISOString().slice(0, 10);
1132
+ const statePath = path.join(os.tmpdir(), `agentic-flow-kit-check8-warn-${ROOT.replace(/[^A-Za-z0-9]/g, '-').slice(-60)}.json`);
1133
+ let warnedToday = {};
1134
+ try { warnedToday = JSON.parse(fs.readFileSync(statePath, 'utf8')); } catch { /* 无状态/坏状态 = 空集 → 全部点名 */ }
1135
+ const silent = [];
1136
+ for (const [rel, base] of verifyMissingDocs) {
1137
+ if (warnedToday[rel] === today) silent.push({ rel, base });
1138
+ else {
1139
+ warnings.push(`- [WARN 测试绿缺凭证] ${base} 验收证据声明「测试绿」但 24h 内无 verify 凭证(verifications.jsonl${verifyDocBound ? `;本单须带 --doc 落账` : ''})——跑 node .agents/scripts/verify.mjs${verifyDocBound ? ` --doc ${rel}` : ''} 后重跑(advisory,灰度第一档)`);
1140
+ }
1141
+ }
1142
+ if (silent.length) {
1143
+ warnings.push(`- [WARN 测试绿缺凭证] 另有 ${silent.length} 份在飞文档同日已点名、去重静默(${silent.slice(0, 5).map((d) => d.base).join('、')}${silent.length > 5 ? '…' : ''})——修法同上:跑 node .agents/scripts/verify.mjs${verifyDocBound ? ' --doc <本单>' : ''} 后重跑(advisory,灰度第一档)`);
1144
+ }
1145
+ try {
1146
+ // 只留当日命中(隔日自动失效,状态面自洁;本机各 ROOT 独立键,互不串扰)
1147
+ const next = {};
1148
+ for (const [rel] of verifyMissingDocs) next[rel] = today;
1149
+ fs.writeFileSync(statePath, JSON.stringify(next));
1150
+ } catch { /* 落状态失败不影响裁决与输出(fail-open,advisory 层不因状态面故障改行为面) */ }
1151
+ }
1123
1152
  if (legacyUnaccounted > 0) {
1124
1153
  warnings.push(`- [WARN 验收对账存量] ${legacyUnaccounted} 个存量 done intent 验收标准未对账(生效日锚之前加入仓库,豁免 hard,不回填)`);
1125
1154
  }
@@ -2792,6 +2792,43 @@ w(T, 'workflow/intents/2026-09-12-deep.md', INTENT('deep', '状态: draft\n级
2792
2792
  rmfix(T2);
2793
2793
  }
2794
2794
 
2795
+ // ---- 检查 8 测试绿缺凭证聚合口径(2026-10-18 告警聚合批):判据不变、只改输出形态 ----
2796
+ // ① 单文档多「测试绿」证据项 → 首跑只出一份明细(per-doc 去重,不再逐项刷屏);
2797
+ // ② 同一夹具当日第二次运行 → 明细静默 + 聚合计数行点名(跨运行当日去重——不是不报,是不复读;
2798
+ // 静默必须被计数行点名,防「没输出」被误读为「已修」)。
2799
+ {
2800
+ const mkVeAgg = () => {
2801
+ const T = mkfix();
2802
+ gitInit(T);
2803
+ w(T, '.agents/kit.json', JSON.stringify({ policyVersion: 5 }));
2804
+ const iBody = '# INTENT — vea\n\n## 验收标准(可测试)\n- [x] 用例A通过(证据:测试全绿)\n- [x] 用例B通过(证据:测试全绿)\n';
2805
+ const pBody = '# PLAN — vea\n';
2806
+ const fpI = computeFingerprint(`---\n状态: approved\n级别: L1\n日期: 2026-10-18\n---\n${iBody}`);
2807
+ const fpP = computeFingerprint(`---\n状态: approved\n级别: L1\n---\n${pBody}`);
2808
+ w(T, 'workflow/intents/2026-10-18-vea.md', `---\n状态: done\n级别: L1\n日期: 2026-10-18\n确认指纹: ${fpI.slice(0, 16)}\n---\n${iBody}`);
2809
+ w(T, 'workflow/plans/2026-10-18-vea.md', `---\n状态: done\n级别: L1\n确认指纹: ${fpP.slice(0, 16)}\n---\n${pBody}`);
2810
+ const now = new Date().toISOString();
2811
+ writeLedger(T, [
2812
+ { ts: now, doc: 'workflow/intents/2026-10-18-vea.md', stage: 'done', fingerprint: fpI, prev: 'approved' },
2813
+ { ts: now, doc: 'workflow/plans/2026-10-18-vea.md', stage: 'done', fingerprint: fpP, prev: 'approved' },
2814
+ ]);
2815
+ gitCommitAll(T, 'feat: vea fixture');
2816
+ return T;
2817
+ };
2818
+ const T = mkVeAgg();
2819
+ let r = run(T);
2820
+ const first = outOf(r).split('\n').filter((l) => l.includes('测试绿缺凭证'));
2821
+ check('检查 8 聚合口径:单文档多「测试绿」项 → 首跑只出一份明细',
2822
+ r.status === 0 && first.length === 1 && first[0].includes('2026-10-18-vea.md'),
2823
+ `exit=${r.status}\n${outOf(r)}`);
2824
+ r = run(T);
2825
+ const second = outOf(r).split('\n').filter((l) => l.includes('测试绿缺凭证'));
2826
+ check('检查 8 聚合口径:同日二跑 → 明细静默 + 聚合计数行(去重不吞债)',
2827
+ r.status === 0 && second.length === 1 && second[0].includes('另有 1 份在飞文档同日已点名') && second[0].includes('2026-10-18-vea.md'),
2828
+ `exit=${r.status}\n${outOf(r)}`);
2829
+ rmfix(T);
2830
+ }
2831
+
2795
2832
  // ---- 检查 8 凭证按 doc 绑定(2026-10-08 verify-doc-binding):v6 收紧 / v5 不变 ----
2796
2833
  // 漏洞本体:凭证行不带 doc 字段 + 判定只看「全仓任意一份 24h 内绿行」→ 跑一次 verify 就能给全仓
2797
2834
  // 任意一单的「测试绿」声明背书。本组三条:① v6 + 只有他单绿行 → 仍出 WARN(漏洞已堵);
@@ -4,8 +4,11 @@ import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { spawnSync } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
7
8
 
8
- const CLI = path.join(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')), 'check-scope.mjs');
9
+ // fileURLToPath 而非手搓 pathname 解码(源仓 fdded128):URL pathname 对非 ASCII 路径百分号编码,
10
+ // Windows 中文 worktree 路径下子进程「Cannot find module」静默红。
11
+ const CLI = path.join(path.dirname(fileURLToPath(import.meta.url)), 'check-scope.mjs');
9
12
  let pass = 0, fail = 0;
10
13
  const check = (name, cond, detail = '') => {
11
14
  if (cond) { pass++; console.log(`PASS ${name}`); }
@@ -4,13 +4,16 @@
4
4
  // 任何文件不可解析即判红(先例教训:verify.mjs:31 SyntaxError 带病进 CI,全链门静默失效)。
5
5
  // 语义与红线:只读裁决,不改任何状态;检查目录缺失或可检文件数为 0 一律判红(fail-closed,
6
6
  // 防「改名/删除脚本即去门」);不 import 任何引擎件,零依赖可独立调度。
7
+ // 性能(2026-10-16 自源仓 scripts-syntax-parallel 回灌):node --check 无原地解析 ESM 的 API,
8
+ // 只能逐文件起子进程(单次 ~70ms × 全量脚本);改工作池并发(min(8, cpus-2)),判红集合不变。
7
9
  // 测试:node .agents/scripts/check-scripts-syntax.test.mjs
8
10
  // 用法:node .agents/scripts/check-scripts-syntax.mjs [目录...](相对仓库根;缺省 .agents/scripts .agents/hooks;
9
11
  // CHECK_LOOP_ROOT 可覆写仓库根,与 check-loop/verify 同口径)
10
12
  // 沿革:2026-10-09 guardian-selfcheck(审计 P0-1 立项,verify.mjs GATES 首门消费)。
11
13
 
12
14
  import fs from 'node:fs';
13
- import { spawnSync } from 'node:child_process';
15
+ import os from 'node:os';
16
+ import { spawn } from 'node:child_process';
14
17
  import path from 'node:path';
15
18
  import { fileURLToPath } from 'node:url';
16
19
 
@@ -21,8 +24,8 @@ const DEFAULT_DIRS = ['.agents/scripts', '.agents/hooks'];
21
24
  const dirs = process.argv.slice(2);
22
25
  const targets = dirs.length ? dirs : DEFAULT_DIRS;
23
26
 
24
- let total = 0;
25
- const reds = [];
27
+ // 守卫文件清单(目录缺失 fail-closed;可检文件数 0 fail-closed——防「改名/删除脚本即去门」)
28
+ const files = [];
26
29
  const missing = [];
27
30
  for (const d of targets) {
28
31
  const abs = path.resolve(ROOT, d);
@@ -34,14 +37,34 @@ for (const d of targets) {
34
37
  missing.push(d);
35
38
  continue;
36
39
  }
37
- for (const f of names.filter((n) => /\.(mjs|cjs)$/.test(n)).sort()) {
38
- total++;
39
- const r = spawnSync(process.execPath, ['--check', path.join(abs, f)], { encoding: 'utf8' });
40
- if (r.status !== 0) {
41
- const first = (r.stderr || '').split(/\r?\n/).find((l) => l.trim()) || `exit ${r.status}`;
42
- reds.push(`[syntax] ❌ ${d}/${f}: ${first}`);
40
+ for (const f of names.filter((n) => /\.(mjs|cjs)$/.test(n)).sort()) files.push({ rel: `${d}/${f}`, abs: path.join(abs, f) });
41
+ }
42
+ const total = files.length;
43
+
44
+ // node --check 逐文件起子进程(无 API 可原地解析 ESM 而不执行),工作池并发后墙钟 ≈ 单次 spawn ×
45
+ // ceil(文件数/并发)。裁决口径不变:任一文件不可解析即判红,判红文件按路径排序输出(并发下不回放乱序)。
46
+ const reds = [];
47
+ {
48
+ const conc = Math.max(2, Math.min(8, os.cpus().length - 2));
49
+ let cursor = 0;
50
+ const worker = async () => {
51
+ while (cursor < files.length) {
52
+ const t = files[cursor++];
53
+ const r = await new Promise((resolve) => {
54
+ const child = spawn(process.execPath, ['--check', t.abs]);
55
+ let err = '';
56
+ child.stderr.on('data', (d) => { err += d; });
57
+ child.on('error', (e) => resolve({ status: null, stderr: String(e.message) }));
58
+ child.on('close', (status) => resolve({ status, stderr: err }));
59
+ });
60
+ if (r.status !== 0) {
61
+ const first = (r.stderr || '').split(/\r?\n/).find((l) => l.trim()) || `exit ${r.status}`;
62
+ reds.push(`[syntax] ❌ ${t.rel}: ${first}`);
63
+ }
43
64
  }
44
- }
65
+ };
66
+ await Promise.all(Array.from({ length: Math.min(conc, files.length) }, worker));
67
+ reds.sort();
45
68
  }
46
69
 
47
70
  if (missing.length) {
@@ -406,12 +406,19 @@ if (isMain) {
406
406
  refused++;
407
407
  continue;
408
408
  }
409
- // 待澄清项门(2026-10-12 needs-clarification):起草期挂「待澄清」标记**不阻断**(零阻塞),
410
- // 但 draft→approved 确认时若仍挂着未澄清项则拒绝——确认即「认可这份文档」,文档里还写着
411
- // 「这里我不知道」,确认就没有意义。与上面的前置门分开出账:拒绝原因必须指向真正的原因
412
- // (合并会让作者去查「前置」,而前置其实是好的)。
413
- // ⚠️ 文案锚点硬约束:本段禁止并入上方 gate 分支的输出契约(既有 confirm-doc.test.mjs 断言
414
- // 锚定「未过逐阶段前置门(stage-gates)」子串),故单列独立分支。
409
+ }
410
+ // 待澄清项门(2026-10-12 needs-clarification;2026-10-18 立项跳对称化扩到 intents):起草期挂
411
+ // 「待澄清」标记**不阻断**(零阻塞),但 draft→approved 确认时若仍挂着未澄清项则拒绝——确认即
412
+ // 「认可这份文档」,文档里还写着「这里我不知道」,确认就没有意义。intents 原不挂本门(旧判例
413
+ // 「澄清天然发生在需求单处」,S19c 旧断言),2026-10-18 推翻:intent 是后链全部文档的种子,
414
+ // 立项时被含混带过的不确定项到 spec/plan 跳已无人能补标——与 specs/plans 同口径挂门。判据与
415
+ // 文档类型无关(clarifyGateFor 只扫正文标记行),存量 intent 无标记零回溯;incidents 生命周期
416
+ // 无 approved 态,不在本门面。与上面的前置门分开出账:拒绝原因必须指向真正的原因(合并会让
417
+ // 作者去查「前置」,而前置其实是好的;前置门只适用于 specs/plans——入口文档无「前置」概念)。
418
+ // ⚠️ 文案锚点硬约束:本段禁止并入上方 gate 分支的输出契约(既有 confirm-doc.test.mjs 断言
419
+ // 锚定「未过逐阶段前置门(stage-gates)」子串),故单列独立分支。
420
+ if (target === 'approved' && (doc.startsWith('workflow/specs/') || doc.startsWith('workflow/plans/')
421
+ || doc.startsWith('workflow/intents/'))) {
415
422
  const cl = clarifyGateFor(abs);
416
423
  if (!cl.ok) {
417
424
  console.error(`❌ ${doc} 含未澄清项(needs-clarification):${cl.reason}`);
@@ -436,15 +436,31 @@ const readLedgerOf = (root) => {
436
436
  JSON.stringify({ status: r.status, stderr: r.stderr }));
437
437
  fs.rmSync(root, { recursive: true, force: true });
438
438
  }
439
- // intents 不受本门约束(歧义标记的落点是 spec/plan 的设计判据;intent 是需求单,澄清天然发生在此处)
440
- const root3 = fs.mkdtempSync(path.join(os.tmpdir(), 'confirm-cl3-'));
441
- fs.mkdirSync(path.join(root3, 'workflow', 'intents'), { recursive: true });
442
- fs.writeFileSync(path.join(root3, 'workflow', 'intents', '2026-10-12-p.md'), '---\n状态: draft\n级别: L1\n---\n# I\n\n- 待澄清:范围含不含移动端?\n');
443
- const r3 = spawnSync(process.execPath, [CLI, 'workflow/intents/2026-10-12-p.md', '--delegated', '可以'], { cwd: root3, encoding: 'utf8' });
444
- check('S19c 待澄清门:intent 不受本门约束(澄清天然发生在需求单处)',
445
- r3.status === 0 && fs.readFileSync(path.join(root3, 'workflow', 'intents', '2026-10-12-p.md'), 'utf8').includes('状态: approved'),
446
- JSON.stringify({ status: r3.status, stderr: r3.stderr }));
447
- fs.rmSync(root3, { recursive: true, force: true });
439
+ // intents 挂门(2026-10-18 立项跳对称化,推翻旧判例「澄清天然发生在需求单处」——intent 是后链
440
+ // 全部文档的种子,立项时被含混带过的不确定项到 spec/plan 跳已无人能补标;存量无标记零回溯)
441
+ {
442
+ const root3 = fs.mkdtempSync(path.join(os.tmpdir(), 'confirm-cl3-'));
443
+ fs.mkdirSync(path.join(root3, 'workflow', 'intents'), { recursive: true });
444
+ const ip = path.join(root3, 'workflow', 'intents', '2026-10-12-p.md');
445
+ fs.writeFileSync(ip, '---\n状态: draft\n级别: L1\n---\n# I\n\n- 待澄清:范围含不含移动端?\n');
446
+ const r3 = spawnSync(process.execPath, [CLI, 'workflow/intents/2026-10-12-p.md', '--delegated', '可以'], { cwd: root3, encoding: 'utf8' });
447
+ check('S19c 待澄清门:intent 挂未澄清项 → exit 2 + 点名内容 + 未落盘(立项跳对称化)',
448
+ r3.status === 2 && /needs-clarification/.test(r3.stderr) && /移动端/.test(r3.stderr)
449
+ && !/逐阶段前置门/.test(r3.stderr)
450
+ && fs.readFileSync(ip, 'utf8').includes('状态: draft'),
451
+ JSON.stringify({ status: r3.status, stderr: r3.stderr }));
452
+ fs.writeFileSync(ip, '---\n状态: draft\n级别: L1\n---\n# I\n\n- 已澄清:范围仅桌面端(用户 10-12 拍板)\n');
453
+ const r4 = spawnSync(process.execPath, [CLI, 'workflow/intents/2026-10-12-p.md', '--delegated', '可以'], { cwd: root3, encoding: 'utf8' });
454
+ check('S19c 待澄清门:intent 已澄清 → 放行落态',
455
+ r4.status === 0 && fs.readFileSync(ip, 'utf8').includes('状态: approved'),
456
+ JSON.stringify({ status: r4.status, stderr: r4.stderr }));
457
+ fs.writeFileSync(ip, '---\n状态: draft\n级别: L1\n---\n# I\n\n## 目标\n- 无标记存量件\n');
458
+ const r5 = spawnSync(process.execPath, [CLI, 'workflow/intents/2026-10-12-p.md', '--delegated', '可以'], { cwd: root3, encoding: 'utf8' });
459
+ check('S19c 待澄清门:intent 无标记(存量口径)→ 放行零回溯',
460
+ r5.status === 0 && fs.readFileSync(ip, 'utf8').includes('状态: approved'),
461
+ JSON.stringify({ status: r5.status, stderr: r5.stderr }));
462
+ fs.rmSync(root3, { recursive: true, force: true });
463
+ }
448
464
  }
449
465
 
450
466
  // ---- S20【逐阶段门】入口存在但未确认(draft)→ 拒绝 ----
@@ -19,6 +19,7 @@ import { analyze, buildDocList, loadLastTouch, loadLedgerTs, parseStatus, ACTIVE
19
19
  const USAGE = `用法:node .agents/scripts/gen-workflow-metrics.mjs [--month YYYY-MM] [--dry-run] [--help]
20
20
 
21
21
  事实源:workflow/{intents,specs,plans,incidents}/*.md(排除 _TEMPLATE.md)+ workflow/INDEX.md + .agents/rule-budgets.txt
22
+ + workflow/reviews.d/*.md(独立复核效果台账)+ workflow/specs/*.md 与 workflow/archive/*/specs/*.md(spec 体积观察)
22
23
  产物:workflow/metrics.md —— 每月一行(同月重跑即更新该行,不重复追加);明细打印在 stdout,不落表。
23
24
  选项:
24
25
  --month YYYY-MM 指定月份键(默认取当前月;用于补记历史)
@@ -202,12 +203,134 @@ const sortedDays = [...funnel.days].sort((a, b) => a - b);
202
203
  const medianDays = sortedDays.length
203
204
  ? (sortedDays.length % 2 ? sortedDays[(sortedDays.length - 1) / 2] : (sortedDays[sortedDays.length / 2 - 1] + sortedDays[sortedDays.length / 2]) / 2)
204
205
  : null;
205
- const funnelRow = `| ${month} | ${funnel.closed} | ${funnel.full} | ${funnel.legacy} | ${funnel.once} | ${funnel.rework} | ${medianDays === null ? '—' : medianDays.toFixed(1)} | ${funnel.ledgerRows} |`;
206
+ // 中位周期渲染:**真实的极小值不得被印成 `0.0`**。建件与确认常在同一会话内完成时,真实周期可 < 0.05 天,
207
+ // `toFixed(1)` 会把它印成 `0.0`——读者无法区分「真实的亚小时周期」与「口径失效」。
208
+ // 修法(最小):仅当四舍五入会得到 `0.0` 且值 > 0 时改写 `<0.1`;null 写 `—`;恰好为 0 仍写 `0.0`。
209
+ function fmtMedianDays(v) {
210
+ if (v == null) return '—';
211
+ const s = v.toFixed(1);
212
+ return s === '0.0' && v > 0 ? '<0.1' : s;
213
+ }
214
+ const funnelRow = `| ${month} | ${funnel.closed} | ${funnel.full} | ${funnel.legacy} | ${funnel.once} | ${funnel.rework} | ${fmtMedianDays(medianDays)} | ${funnel.ledgerRows} |`;
206
215
 
207
216
  const docs = scanDocs();
208
217
  const surf = scanSurface();
209
218
  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} 行) |`;
210
219
 
220
+ // ---- 复核效果(独立复核效果台账):workflow/reviews.d/ 意见级处置分片按月聚合 ----
221
+ // 口径:一行 = 一次独立复核事件;自洽不变式 = 采纳+拒绝+误报 = 意见数。台账是「门禁制造返工 vs 拦下问题」
222
+ // 的意见级信号源,脏行比缺行更有害——字段非法 / 自洽破坏一律 throw(文案点名分片与行),不做静默跳过
223
+ // (同本脚本 fail-loud 纪律)。目录不存在 = 合法零数据(机制先于数据建账,出占位行)。
224
+ function scanReviews() {
225
+ const dir = path.join(WORKFLOW, 'reviews.d');
226
+ if (!fs.existsSync(dir)) return {};
227
+ const byMonth = {};
228
+ for (const f of fs.readdirSync(dir)) {
229
+ if (!f.endsWith('.md')) continue;
230
+ const p = path.join(dir, f);
231
+ let text;
232
+ try {
233
+ text = fs.readFileSync(p, 'utf8');
234
+ } catch (e) {
235
+ throw new Error(`复核效果台账分片不可读:${p}(${e.message})——坏分片不静默跳过`);
236
+ }
237
+ for (const line of text.split(/\r?\n/)) {
238
+ if (!/^\|\s*\d{4}-\d{2}-\d{2}\s*\|/.test(line)) continue; // 表头/分隔线/注记行跳过
239
+ const cells = line.split('|').slice(1, -1).map((c) => c.trim());
240
+ const bad = cells.length < 8 || [3, 4, 5, 6].some((i) => !/^\d+$/.test(cells[i]));
241
+ if (bad) throw new Error(`复核效果台账行字段非法(8 列起,意见数/采纳/拒绝/误报须为非负整数):${p}\n 行:${line.trim()}`);
242
+ const [total, adopted, rejected, misreport] = [3, 4, 5, 6].map((i) => Number(cells[i]));
243
+ if (adopted + rejected + misreport !== total) {
244
+ throw new Error(`复核效果台账自洽破坏(采纳 ${adopted} + 拒绝 ${rejected} + 误报 ${misreport} = ${adopted + rejected + misreport} ≠ 意见数 ${total}):${p}\n 行:${line.trim()}`);
245
+ }
246
+ const b = (byMonth[cells[0].slice(0, 7)] ||= { events: 0, total: 0, adopted: 0, rejected: 0, misreport: 0 });
247
+ b.events++;
248
+ b.total += total;
249
+ b.adopted += adopted;
250
+ b.rejected += rejected;
251
+ b.misreport += misreport;
252
+ }
253
+ }
254
+ return byMonth;
255
+ }
256
+ const reviews = scanReviews();
257
+ // 复核效果表头(第三表)。源 = workflow/reviews.d/ 意见级处置分片(一行 = 一次独立复核事件,
258
+ // 采纳+拒绝+误报 = 意见数),整表确定性再生、历史行不落盘保留;区分「门禁制造返工 vs 拦下问题」。
259
+ // 声明须在 DRY 出口之前(--dry-run 也打印该表)。
260
+ const REVIEW_HEAD = [
261
+ '## 复核效果(源:workflow/reviews.d/ 意见级处置台账;存量未建账期无数据)',
262
+ '',
263
+ '| 月份 | 复核事件 | 意见数 | 采纳 | 拒绝 | 误报 | 采纳率 | 误报率 |',
264
+ '|---|---|---|---|---|---|---|---|',
265
+ ];
266
+ // 整表由分片确定性再生(历史行不落盘保留,分片即真源);率列 = 采纳(误报)÷ 意见数,意见数为 0 写 —。
267
+ const reviewRows = Object.keys(reviews).sort().map((ym) => {
268
+ const b = reviews[ym];
269
+ const rate = (n) => (b.total ? `${((n / b.total) * 100).toFixed(0)}%` : '—');
270
+ return `| ${ym} | ${b.events} | ${b.total} | ${b.adopted} | ${b.rejected} | ${b.misreport} | ${rate(b.adopted)} | ${rate(b.misreport)} |`;
271
+ });
272
+ if (!reviewRows.length) reviewRows.push('| (无记录) | 0 | 0 | 0 | 0 | — | — |'); // 零数据占位——表始终输出,机制可见
273
+
274
+ // ---- spec 体积观察(spec 生命周期口径):活跃 specs + 归档 specs 分开统计,先观察后设限 ----
275
+ // advisory 上限显式延后(先让体积可见):本表只出数、不判限。
276
+ // 口径:份数 = .md 数(排除 _TEMPLATE.md,与 scanDocs 一致);行数 = 换行数(尾换行不生幻影行,对齐 wc -l);
277
+ // 验收条数 = `- [ ]`/`- [x]` checkbox 行总数(不区分勾选);中位 = 升序取中,偶数取中间两数均值(可为 x.5);
278
+ // 归档 = workflow/archive/<月>/specs/ 各月合并。目录不存在 = 合法(该行出全 0,不报错)。
279
+ const medianOf = (arr) => {
280
+ if (!arr.length) return 0;
281
+ const s = [...arr].sort((x, y) => x - y);
282
+ return s.length % 2 ? s[(s.length - 1) / 2] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2;
283
+ };
284
+ function collectSpecMetrics(dir) {
285
+ if (!fs.existsSync(dir)) return null;
286
+ const lines = [];
287
+ const checks = [];
288
+ for (const f of fs.readdirSync(dir)) {
289
+ if (!f.endsWith('.md') || f === '_TEMPLATE.md') continue;
290
+ const text = fs.readFileSync(path.join(dir, f), 'utf8');
291
+ const ls = text.split(/\r?\n/);
292
+ if (ls[ls.length - 1] === '') ls.pop();
293
+ lines.push(ls.length);
294
+ checks.push((text.match(/^\s*- \[( |x)\]/gm) || []).length);
295
+ }
296
+ return { lines, checks };
297
+ }
298
+ const specStats = (m) => (m ? {
299
+ n: m.lines.length,
300
+ medLines: medianOf(m.lines),
301
+ maxLines: m.lines.length ? Math.max(...m.lines) : 0,
302
+ medChecks: medianOf(m.checks),
303
+ maxChecks: m.checks.length ? Math.max(...m.checks) : 0,
304
+ } : null);
305
+ const specActiveStats = specStats(collectSpecMetrics(path.join(WORKFLOW, 'specs')));
306
+ const specArchiveMetrics = { lines: [], checks: [] };
307
+ const archiveRoot = path.join(WORKFLOW, 'archive');
308
+ if (fs.existsSync(archiveRoot)) {
309
+ for (const ent of fs.readdirSync(archiveRoot, { withFileTypes: true })) {
310
+ if (!ent.isDirectory()) continue;
311
+ const sub = collectSpecMetrics(path.join(archiveRoot, ent.name, 'specs'));
312
+ if (sub) {
313
+ specArchiveMetrics.lines.push(...sub.lines);
314
+ specArchiveMetrics.checks.push(...sub.checks);
315
+ }
316
+ }
317
+ }
318
+ const specArchiveStats = specStats(specArchiveMetrics);
319
+ const fmtSpecNum = (v) => (Number.isInteger(v) ? String(v) : v.toFixed(1));
320
+ const specRow = (label, st) => st
321
+ ? `| ${label} | ${st.n} | ${fmtSpecNum(st.medLines)} | ${st.maxLines} | ${fmtSpecNum(st.medChecks)} | ${st.maxChecks} |`
322
+ : `| ${label} | 0 | 0 | 0 | 0 | 0 |`;
323
+ // spec 体积表头(第四表。先观察后设限:advisory 上限延后、不设门——口径单源 = workflow/README.md
324
+ // 「spec 生命周期口径」条目)。整表每次确定性再生。
325
+ const SPEC_HEAD = [
326
+ '## spec 体积观察(源:specs 活跃+归档全量扫描;advisory 上限延后——口径见 workflow/README.md「spec 生命周期口径」)',
327
+ '',
328
+ '| 范围 | 份数 | 行数中位 | 行数最大 | 验收条数中位 | 验收条数最大 |',
329
+ '|---|---|---|---|---|---|',
330
+ ];
331
+ const specRows = [specRow('活跃', specActiveStats), specRow('归档', specArchiveStats)];
332
+
333
+
211
334
  // ---- 明细(stdout,不落表):类型分布 + 逐条预算占用 ----
212
335
  console.log(`📅 ${month} 快照`);
213
336
  console.log(` 文档:${docs.files} 篇 / ${kb(docs.bytes)}(活跃 ${docs.active} / 终态 ${docs.terminal}${docs.invalid ? ` / 状态缺失或非法 ${docs.invalid}` : ''});模块已填 ${docs.withModule}/${docs.files}`);
@@ -215,7 +338,7 @@ for (const [t, v] of Object.entries(docs.perType)) console.log(` ${t.padEnd(1
215
338
  console.log(` 索引:workflow/INDEX.md ${kb(idxBytes)}(活跃 ${Number.isNaN(idxActive) ? '—' : idxActive} 行)`);
216
339
  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)}%)`);
217
340
  console.log(` 常驻面合计:${kb(surf.total)}`);
218
- console.log(` 闭环漏斗:收口 ${funnel.closed}(完整链 ${funnel.full} = 一次通过 ${funnel.once} + 返工 ${funnel.rework};协议前 ${funnel.legacy})|中位周期 ${medianDays === null ? '—' : `${medianDays.toFixed(1)} 天`}|台账行 ${funnel.ledgerRows}`);
341
+ console.log(` 闭环漏斗:收口 ${funnel.closed}(完整链 ${funnel.full} = 一次通过 ${funnel.once} + 返工 ${funnel.rework};协议前 ${funnel.legacy})|中位周期 ${fmtMedianDays(medianDays)} 天|台账行 ${funnel.ledgerRows}`);
219
342
  for (const it of funnel.items) console.log(` ${String(it.doc).padEnd(58)} ${it.kind.padEnd(7)} ${it.days} 天`);
220
343
  if (funnel.items.length) console.log(' ℹ️ 机器口径可与 workflow/delegations.md 自做表的人工自报交叉对照(差异即信号,不对账)');
221
344
  // 归档积压提醒(2026-10-15 减重手段⑥ 自源仓回灌,回路别等人喊):滚动归档已随确认门自动跑
@@ -236,7 +359,12 @@ try {
236
359
  } catch { /* fail-open */ }
237
360
  console.log(` ${DRY ? '(--dry-run,未写盘)' : ''}`);
238
361
 
239
- if (DRY) { console.log(`\n将写入行:\n${row}\n${funnelRow}`); process.exit(0); }
362
+ if (DRY) {
363
+ console.log(`\n将写入行:\n${row}\n${funnelRow}`);
364
+ console.log(`\n复核效果表(整表再生):\n${[...REVIEW_HEAD, ...reviewRows].join('\n')}`);
365
+ console.log(`\nspec 体积观察表(整表再生):\n${[...SPEC_HEAD, ...specRows].join('\n')}`);
366
+ process.exit(0);
367
+ }
240
368
 
241
369
  // ---- 组装:GENERATED 区内的月表按月份键 upsert(同月重跑覆盖,行序按月升序)----
242
370
  const HEADER = `# workflow 规模与规则面月度快照
@@ -248,9 +376,10 @@ const TABLE_HEAD = [
248
376
  '| 月份 | 文档(活跃/终态) | 文档字节 | 模块已填/总 | 常驻面字节(预算峰值) | INDEX(活跃行) |',
249
377
  '|---|---|---|---|---|---|',
250
378
  ];
251
- // 漏斗表头(第二表)
379
+ // 漏斗表头(第二表)。中位渲染契约:取不到写 —;恰好 0 写 0.0;四舍五入会得 0.0 的真实极小值写 <0.1
380
+ //(建件与确认常在同会话内完成时该列常态即亚小时级,勿读作口径失效)。
252
381
  const FUNNEL_HEAD = [
253
- '## 闭环漏斗(机器口径,源:.agents/confirmations.jsonl)',
382
+ '## 闭环漏斗(机器口径,源:.agents/confirmations.jsonl。中位周期 = 该文件首次进入 git 到终态确认;取不到写 —,不写 0.0;真实极小值写 <0.1)',
254
383
  '',
255
384
  '| 月份 | 收口 | 完整链 | 协议前 | 一次通过 | 返工件 | 中位周期(天) | 台账行数 |',
256
385
  '|---|---|---|---|---|---|---|---|',
@@ -260,9 +389,12 @@ let rows = [];
260
389
  let funnelRows = [];
261
390
  if (existing.includes(BEGIN) && existing.includes(END)) {
262
391
  const section = existing.slice(existing.indexOf(BEGIN), existing.indexOf(END));
263
- const marker = FUNNEL_HEAD[0];
264
- const bodyPart = section.includes(marker) ? section.slice(0, section.indexOf(marker)) : section;
265
- const funnelPart = section.includes(marker) ? section.slice(section.indexOf(marker)) : '';
392
+ // 分段标记用短语而非表头全文——表头措辞会随口径演进,回吸判据须与之解耦(旧文件的旧表头也能正确分段)
393
+ const funnelMarker = '## 闭环漏斗';
394
+ const reviewMarker = '## 复核效果'; // 复核行同为 `| YYYY-MM |` 形态,不切出会被误吸进漏斗行
395
+ const bodyPart = section.includes(funnelMarker) ? section.slice(0, section.indexOf(funnelMarker)) : section;
396
+ const restPart = section.includes(funnelMarker) ? section.slice(section.indexOf(funnelMarker)) : '';
397
+ const funnelPart = restPart.includes(reviewMarker) ? restPart.slice(0, restPart.indexOf(reviewMarker)) : restPart;
266
398
  rows = bodyPart.split(/\r?\n/).filter((l) => /^\|\s*\d{4}-\d{2}\s*\|/.test(l));
267
399
  funnelRows = funnelPart.split(/\r?\n/).filter((l) => /^\|\s*\d{4}-\d{2}\s*\|/.test(l));
268
400
  }
@@ -272,7 +404,7 @@ rows.sort((a, b) => a.slice(2, 9).localeCompare(b.slice(2, 9)));
272
404
  funnelRows = funnelRows.filter((l) => !l.startsWith(`| ${month} `));
273
405
  funnelRows.push(funnelRow);
274
406
  funnelRows.sort((a, b) => a.slice(2, 9).localeCompare(b.slice(2, 9)));
275
- const inner = `${BEGIN}\n\n${[...TABLE_HEAD, ...rows].join('\n')}\n\n${[...FUNNEL_HEAD, ...funnelRows].join('\n')}\n\n${END}`;
407
+ const inner = `${BEGIN}\n\n${[...TABLE_HEAD, ...rows].join('\n')}\n\n${[...FUNNEL_HEAD, ...funnelRows].join('\n')}\n\n${[...REVIEW_HEAD, ...reviewRows].join('\n')}\n\n${[...SPEC_HEAD, ...specRows].join('\n')}\n\n${END}`;
276
408
  let out;
277
409
  if (existing.includes(BEGIN) && existing.includes(END)) {
278
410
  out = existing.slice(0, existing.indexOf(BEGIN)) + inner + existing.slice(existing.indexOf(END) + END.length);
@@ -216,6 +216,125 @@ const mkfixLedger = (lines) => {
216
216
  fs.rmSync(root, { recursive: true, force: true });
217
217
  }
218
218
 
219
+ {
220
+ // L8:亚小时真实周期(同会话建件+确认)→ 渲染 <0.1,不印 0.0(「取不到」与「极快」不得同形)
221
+ const root = mkfixLedger([
222
+ LROW({ ts: '2026-10-01T00:30:00.000Z' }),
223
+ LROW({ ts: '2026-10-01T01:00:00.000Z', stage: 'done', prev: 'approved' }),
224
+ ]);
225
+ const r = run(root, ['--month', '2026-10']);
226
+ const md = readMetrics(root);
227
+ check('漏斗 L8 亚小时真实周期渲染 <0.1(表行与 stdout 同步,不印 0.0)',
228
+ r.status === 0 && /\| 2026-10 \| 1 \| 1 \| 0 \| 1 \| 0 \| <0\.1 \| 2 \|/.test(md) && /中位周期 <0\.1 天/.test(r.stdout),
229
+ `${r.stdout}${md}`);
230
+ fs.rmSync(root, { recursive: true, force: true });
231
+ }
232
+
233
+ // ---- 复核效果台账:workflow/reviews.d/ 意见级处置分片 → 第三表 ----
234
+ // mkfixReviews:在 mkfix 基础上写复核效果台账分片(shards: { 分片文件名: [数据行...] },含表头)
235
+ const mkfixReviews = (shards) => {
236
+ const root = mkfix();
237
+ fs.mkdirSync(path.join(root, 'workflow', 'reviews.d'), { recursive: true });
238
+ for (const [name, rows] of Object.entries(shards)) {
239
+ fs.writeFileSync(path.join(root, 'workflow', 'reviews.d', name), [
240
+ `# 独立复核效果台账 — ${name.replace(/\.md$/, '')}`,
241
+ '',
242
+ '| 日期 | 对象 | 复核员(宿主) | 意见数 | 采纳 | 拒绝 | 误报 | 备注 |',
243
+ '|------|------|--------------|--------|------|------|------|------|',
244
+ ...rows,
245
+ '',
246
+ ].join('\n'), 'utf8');
247
+ }
248
+ return root;
249
+ };
250
+ {
251
+ // R1:同月两行聚合 + 跨月分片各一行、按月升序(率列 = 采纳/误报 ÷ 意见数,toFixed(0)%)
252
+ const root = mkfixReviews({
253
+ '2026-10.md': [
254
+ '| 2026-10-14 | workflow/specs/2026-10-14-x.md | independent-reviewer(zcode) | 5 | 3 | 1 | 1 | P1 修 1;P2 采纳 3 拒 1 误 1 |',
255
+ '| 2026-10-16 | diff 318922a9..7c8d9eb7 | independent-reviewer(新会话) | 4 | 2 | 2 | 0 | P2×4 全采纳 |',
256
+ ],
257
+ '2026-09.md': [
258
+ '| 2026-09-30 | workflow/plans/2026-09-30-y.md | independent-reviewer(zcode) | 2 | 1 | 0 | 1 | 误报=指向已被覆盖的代码 |',
259
+ ],
260
+ });
261
+ const r = run(root, ['--month', '2026-10']);
262
+ const md = readMetrics(root);
263
+ check('复核 R1:同月两行聚合(事件 2 / 意见 9 / 采纳 5 / 拒绝 3 / 误报 1 / 采纳率 56% / 误报率 11%)',
264
+ r.status === 0 && /\| 2026-10 \| 2 \| 9 \| 5 \| 3 \| 1 \| 56% \| 11% \|/.test(md), `exit=${r.status}\n${r.stdout}${r.stderr}\n${md}`);
265
+ check('复核 R1:跨月分片各一行且按月升序(9 月 1/2/1/0/0 → 50%/50% 紧邻 10 月行)',
266
+ /## 复核效果[\s\S]*?\| 2026-09 \| 1 \| 2 \| 1 \| 0 \| 1 \| 50% \| 50% \|\n\| 2026-10 \|/.test(md), md);
267
+ check('复核 R1:复核行不被误吸进漏斗表(漏斗段零 2026-09 行——分段回吸切开了同形日期行)',
268
+ (() => { const seg = md.slice(md.indexOf('## 闭环漏斗'), md.indexOf('## 复核效果')); return !/\| 2026-09 \|/.test(seg); })(), md);
269
+ fs.rmSync(root, { recursive: true, force: true });
270
+ }
271
+ {
272
+ // R2:自洽破坏(采纳+拒绝+误报 ≠ 意见数)→ fail-loud(exit 1,报文点名分片与不变式)
273
+ const root = mkfixReviews({
274
+ '2026-10.md': [
275
+ '| 2026-10-14 | workflow/specs/2026-10-14-x.md | independent-reviewer(zcode) | 5 | 2 | 1 | 1 | 2+1+1=4 ≠ 5,应被拦 |',
276
+ ],
277
+ });
278
+ const r = run(root, ['--month', '2026-10']);
279
+ check('复核 R2:自洽破坏(5 ≠ 2+1+1)→ exit 1 且报文含「自洽破坏」并点名分片 2026-10.md',
280
+ r.status === 1 && /自洽破坏/.test(r.stderr) && /2026-10\.md/.test(r.stderr), `exit=${r.status}\n${r.stderr}`);
281
+ fs.rmSync(root, { recursive: true, force: true });
282
+ }
283
+ {
284
+ // R3:目录不存在 / 分片存在但空表 → 合法零数据,占位行不崩
285
+ const rootNone = mkfix(); // 不建 reviews.d
286
+ const rNone = run(rootNone, ['--month', '2026-10']);
287
+ const mdNone = readMetrics(rootNone);
288
+ check('复核 R3:目录不存在 → exit 0 且出「(无记录)」占位行',
289
+ rNone.status === 0 && /\| (无记录) \| 0 \| 0 \| 0 \| 0 \| — \| — \|/.test(mdNone), `exit=${rNone.status}\n${rNone.stderr}\n${mdNone}`);
290
+ fs.rmSync(rootNone, { recursive: true, force: true });
291
+ const rootEmpty = mkfixReviews({ '2026-10.md': [] }); // 分片存在、表为空
292
+ const rEmpty = run(rootEmpty, ['--month', '2026-10']);
293
+ const mdEmpty = readMetrics(rootEmpty);
294
+ check('复核 R3b:分片空表 → exit 0 且出「(无记录)」占位行',
295
+ rEmpty.status === 0 && /\| (无记录) \| 0 \| 0 \| 0 \| 0 \| — \| — \|/.test(mdEmpty), `exit=${rEmpty.status}\n${rEmpty.stderr}\n${mdEmpty}`);
296
+ fs.rmSync(rootEmpty, { recursive: true, force: true });
297
+ }
298
+
299
+ // ---- spec 体积观察(spec 生命周期口径):活跃 specs + 归档 specs 分开统计 ----
300
+ // mkSpec:生成恰好 lines 行(尾换行对齐 wc -l)、其中 checks 行为 checkbox 的 spec 文本
301
+ const mkSpec = (lines, checks) => {
302
+ const rows = [`# spec 夹具 ${lines}行${checks}验收`];
303
+ for (let i = 0; i < checks; i++) rows.push(`- [${i % 2 ? 'x' : ' '}] 验收 ${i + 1}`);
304
+ while (rows.length < lines) rows.push('填充');
305
+ return rows.join('\n') + '\n';
306
+ };
307
+ {
308
+ // S1:活跃 2 份(10 行/3 验收、12 行/5 验收 → 偶数中位取均值)+ 归档 3 份跨单月目录(6/8/10 行 → 奇数中位取中)
309
+ const root = mkfix();
310
+ fs.writeFileSync(path.join(root, 'workflow', 'specs', '2026-10-01-a1.md'), mkSpec(10, 3), 'utf8');
311
+ fs.writeFileSync(path.join(root, 'workflow', 'specs', '2026-10-02-a2.md'), mkSpec(12, 5), 'utf8');
312
+ fs.writeFileSync(path.join(root, 'workflow', 'specs', '_TEMPLATE.md'), mkSpec(999, 9), 'utf8'); // 模板不计入
313
+ fs.mkdirSync(path.join(root, 'workflow', 'archive', '2026-09', 'specs'), { recursive: true });
314
+ fs.writeFileSync(path.join(root, 'workflow', 'archive', '2026-09', 'specs', '2026-09-01-b1.md'), mkSpec(6, 2), 'utf8');
315
+ fs.writeFileSync(path.join(root, 'workflow', 'archive', '2026-09', 'specs', '2026-09-02-b2.md'), mkSpec(8, 4), 'utf8');
316
+ fs.writeFileSync(path.join(root, 'workflow', 'archive', '2026-09', 'specs', '2026-09-03-b3.md'), mkSpec(10, 1), 'utf8');
317
+ const r = run(root, ['--month', '2026-10']);
318
+ const md = readMetrics(root);
319
+ check('spec S1:活跃行(2 份 / 行数中位 11 = (10+12)/2 / 最大 12 / 验收中位 4 / 最大 5;_TEMPLATE 不计)',
320
+ r.status === 0 && /\| 活跃 \| 2 \| 11 \| 12 \| 4 \| 5 \|/.test(md), `exit=${r.status}\n${r.stderr}\n${md}`);
321
+ check('spec S1:归档行(3 份 / 行数中位 8 取中 / 最大 10 / 验收中位 2 / 最大 4)',
322
+ /\| 归档 \| 3 \| 8 \| 10 \| 2 \| 4 \|/.test(md), md);
323
+ check('spec S1:表序正确(复核效果表之后、GENERATED:END 之前)',
324
+ /## 复核效果[\s\S]*## spec 体积观察[\s\S]*<!-- GENERATED:END -->/.test(md), md);
325
+ fs.rmSync(root, { recursive: true, force: true });
326
+ }
327
+ {
328
+ // S2:specs 目录空 + 无 archive 目录 → 两行全 0 不崩(合法零数据)
329
+ const root = mkfix();
330
+ const r = run(root, ['--month', '2026-10']);
331
+ const md = readMetrics(root);
332
+ check('spec S2:specs 空目录 + 无 archive → 活跃/归档两行全 0,exit 0',
333
+ r.status === 0 && /\| 活跃 \| 0 \| 0 \| 0 \| 0 \| 0 \|/.test(md) && /\| 归档 \| 0 \| 0 \| 0 \| 0 \| 0 \|/.test(md),
334
+ `exit=${r.status}\n${r.stderr}\n${md}`);
335
+ fs.rmSync(root, { recursive: true, force: true });
336
+ }
337
+
219
338
  console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
220
339
  if (fail) {
221
340
  console.log(`\n${USAGE}`);
@@ -89,5 +89,5 @@ const run = (root, ...args) => {
89
89
  check('场景 4:incident(发现键)×3 → db/procs 候选', r.code === 0 && r.out.includes('db/procs'), r.out);
90
90
  }
91
91
 
92
- console.log(`\n${fail ? `❌ ${fail} failed` : '✅ all passed'}(${pass} passed)`);
92
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
93
93
  process.exit(fail ? 1 : 0);
@@ -101,5 +101,5 @@ check('experiment→experiment argv 不含 --hardening', !line3.includes('--hard
101
101
  check('experiment→experiment 构建门仍按范围跑(advisory 不阻断)',
102
102
  /^--range [0-9a-f]{40} /.test(ccArgs(root)[2] || ''), ccArgs(root).join('|'));
103
103
 
104
- console.log(`\n${fail ? `❌ ${fail} failed` : '✅ all passed'}(${pass} passed)`);
104
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
105
105
  process.exit(fail ? 1 : 0);
@@ -45,7 +45,9 @@ const classify = (content) => {
45
45
 
46
46
  function run() {
47
47
  const { topic, root, delegated, auto } = parseArgs();
48
- const draftsDir = path.join(root, '.zcode', 'drafts');
48
+ // 草稿暂存区 = .zcode/workflow-drafts(宿主保存工作流草稿的实际目录;
49
+ // 2026-10-16 自源仓 8deb032b 回灌纠正:原 .zcode/drafts 自引入起从未存在,脚本一跑即 fail)
50
+ const draftsDir = path.join(root, '.zcode', 'workflow-drafts');
49
51
  if (!fs.existsSync(draftsDir)) fail(`草稿目录 ${draftsDir} 不存在`);
50
52
  const files = fs.readdirSync(draftsDir).filter((f) => f.includes(topic) && f.endsWith('.md'));
51
53
  if (!files.length) fail(`在 ${draftsDir} 中未找到主题为 ${topic} 的草稿文件`);