agentic-flow-kit 1.3.0 → 1.4.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.
package/README.md CHANGED
@@ -32,6 +32,8 @@ node .agents/scripts/confirm-doc.mjs workflow/intents/<文件>.md # 终端里
32
32
 
33
33
  可选红线用 `add-gate`:`dotnet-ca`、`node-layer`、`py-import`、`generated-readonly`。约定见 `modules/gates/README.md`。
34
34
 
35
+ **关于「确认门」的诚实边界**:确认门是**留痕机制,不是防伪机制**。`confirm-doc.mjs` 把确认事件写进 `.agents/confirmations.jsonl`(append-only,入 git),check-loop 据此对账——但台账是本地可写文件,**刻意手改台账仍可伪造**。门禁的实际作用是把「顺手绕过」抬到「主动伪造」(两者性质不同、可事后对质),而非关闭通道。仓库自身的引擎注释对此有完整声明(见 `.agents/scripts/stage-gates.mjs` 头部信任边界段)。委托代录(`--delegated`)的台账行如实记 `source` 与用户原话供事后对质,永不伪装 TTY 确认。
36
+
35
37
  ## 快速开始
36
38
 
37
39
  ```bash
@@ -84,10 +86,14 @@ modules/gates/ 可选门禁模块(dotnet-ca:Clean Architecture 参考
84
86
  - 抽取自真实项目长期运转的引擎(某真实项目),dogfooding 是后续路线(sync 升级 / add-host / add-gate / npm 发布)的一部分。
85
87
  - **威胁模型(2026-09-27 init-p1-batch)**:勿在不可信仓库运行 `init` / `doctor` / `sync`——三命令在执行目标侧 `.agents/scripts/` 脚本(生成器 / 校验器)前有供应链防线:脚本内容与包源渲染值(LF 归一 sha)一致才执行,失配即显式跳过提示,不执行不下结论。**防线不覆盖 git 钩子面**:预植的 `.githooks/`、`.agents/hooks/` 仍会被保留挂载,并在你自己的首次 git 操作时执行——不可信仓库请先审查/清除预置件再 init。
86
88
 
87
- ## 当前能力(已发布 0.8.0)
89
+ ## 当前能力
90
+
91
+ **能力清单的单一真相源是 `CHANGELOG.md`**——每个已发布版本带什么、为什么这么带,都在那里。本节只讲当前语义,不重复枚举版本特性(此前列举导致 README 与实际发布内容长期漂移,2026-10-08 已收敛到单源)。
88
92
 
89
- 版本沿革写在 `CHANGELOG.md`。0.8.0 已包含:init / doctor / sync / add-host / add-gate、四套技术栈门禁初值、宿主薄适配、确认门、check-loop 16 项、看板端口上探、opencode 命令 `wf-` 前缀、pre-commit managed 台账快检。
93
+ 当前语义三点:
90
94
 
91
- 仓库里还没打进版本号的增量:`audit` 档(新装默认 false)、`policyVersion`、检查 17(发版树上的未收口文档)、检查 18(委派台账对账)、检查 19(逐阶段审计)、逐阶段前置门(起草门 / 确认门 / done 前置门)、claude / cursor / codex 薄适配、`node-layer` / `py-import` / `generated-readonly` 三个可选门禁、fresh init 冒烟。效果数字等有外部仓库跑过再记,不在这次。
95
+ - **门禁面**:check-loop 20 项检查 + `.githooks/` 五个钩子(pre-commit / pre-push / commit-msg / post-commit / pre-merge-commit),门禁逻辑不依赖 agent 客户端自觉。
96
+ - **闭环面**:intents → specs → plans → incidents 四态文档链 + 确认台账(`.agents/confirmations.jsonl`)+ 逐阶段前置门(起草门 / 确认门 / done 前置门)。
97
+ - **观测面**:`workflow/INDEX.md`(检索索引)、`workflow/DASHBOARD.md`(红绿灯仪表盘)、`workflow/metrics.md`(月度快照)——**观测面非门禁**,红灯是「看」的不是「拦」的。
92
98
 
93
- 活跃层里 2026-09-23 至 09-28 的 approved / open 文档没有在这次改成 done 或 closed。那些文件的确认指纹绑着当时的正文,关单要逐份 `confirm-doc`。
99
+ 版本号与已发布能力看 `package.json` 的 `version` 与 `CHANGELOG.md`;仓库当前工作流实况看 `workflow/INDEX.md`。
@@ -56,7 +56,7 @@ node bin/flow-kit.mjs doctor # §6.5 delegations + §6.6 o
56
56
  - 输出测试报告(通过项 / 失败项)
57
57
  - 失败项已修复并复测通过
58
58
  - **关单**(日常闭环点,不依赖是否上 prod):
59
- - 关单前一键过门:`node .agents/scripts/verify.mjs`(npm test + check-loop 固定编排,任一失败非零退出,非绿不关单);**装户仓**(根目录无 `package.json`,工程在子目录)默认 `npm test` 不可跑 → 脚本**显式跳过步骤 1 且不落测试绿凭证**(防假绿)并提示;此时按项目口径手工跑测试,或带 `--test-cmd "<项目测试命令>"` 指定(2026-10-07 papercut)
59
+ - 关单前一键过门:`node .agents/scripts/verify.mjs --doc workflow/intents/<本单>.md`(npm test + check-loop 固定编排,任一失败非零退出,非绿不关单);**`--doc` 把测试绿凭证绑到本单**——不带它落下的绿行不给任何单背书,confirm-doc 前置与检查 8 对账只认带本单 `--doc` 的绿行(仅 v6+ 生效);**装户仓**(根目录无 `package.json`,工程在子目录)默认 `npm test` 不可跑 → 脚本**显式跳过步骤 1 且不落测试绿凭证**(防假绿)并提示;此时按项目口径手工跑测试,或带 `--test-cmd "<项目测试命令>"` 指定(2026-10-07 papercut)
60
60
  - 新需求:逐条勾验入口 intent「验收标准」,每条补「证据:」(例如:粘贴 CI 运行结果、UI 截图路径、或具体的 commit SHA),intent $\rightarrow$ done;同名 spec/Quick-Plan(若有)仍 approved $\rightarrow$ done。**注意:勾选但缺证据会被 check-loop 拦截(hard-block)**
61
61
  - **approved 留痕提交**(2026-09-30 起):done 确认前确认**本次关单文档**的 approved 态已进 git 历史(越早越好——approved 后即应提交;若尚未,先补提交再 done;门为逐份校验,三件套均关单时逐份各自满足)——confirm-doc 的 done 前置门硬校验(历史未出现「状态: approved」→ 拒绝落账);done 后再提交 done 态与其余改动
62
62
  **确认落态唯一入口**(2026-09-27 起):用户在终端跑 `node .agents/scripts/confirm-doc.mjs <path>` 键入「可以」(draft→approved / approved→done;对话内明确确认后 AI 可 `--delegated "<原话>"` 代录,台账如实记 source/quote——**逐件调用 + 逐件原话**(一次一份,build.md「逐件确认」口径;多份并录被 confirm-doc 拒绝。--delegated 每次传当次实际放行的措辞,不复用同句——复用同句使台账 quote 失去分辨力))。check-loop 15 如实口径:缺记录/指纹不配对 → hard-block 拦截;伪造台账本地不可机器防,留痕供事后对质;done 内容绑定(2026-09-28 起)——**关单编辑(勾验/回填确认结果)先于 done 确认、confirm-doc 是最后一次写入**,此后修订走 superseded 或新 intent。
@@ -56,7 +56,7 @@ node bin/flow-kit.mjs doctor # §6.5 delegations + §6.6 o
56
56
  - 输出测试报告(通过项 / 失败项)
57
57
  - 失败项已修复并复测通过
58
58
  - **关单**(日常闭环点,不依赖是否上 prod):
59
- - 关单前一键过门:`node .agents/scripts/verify.mjs`(npm test + check-loop 固定编排,任一失败非零退出,非绿不关单);**装户仓**(根目录无 `package.json`,工程在子目录)默认 `npm test` 不可跑 → 脚本**显式跳过步骤 1 且不落测试绿凭证**(防假绿)并提示;此时按项目口径手工跑测试,或带 `--test-cmd "<项目测试命令>"` 指定(2026-10-07 papercut)
59
+ - 关单前一键过门:`node .agents/scripts/verify.mjs --doc workflow/intents/<本单>.md`(npm test + check-loop 固定编排,任一失败非零退出,非绿不关单);**`--doc` 把测试绿凭证绑到本单**——不带它落下的绿行不给任何单背书,confirm-doc 前置与检查 8 对账只认带本单 `--doc` 的绿行(仅 v6+ 生效);**装户仓**(根目录无 `package.json`,工程在子目录)默认 `npm test` 不可跑 → 脚本**显式跳过步骤 1 且不落测试绿凭证**(防假绿)并提示;此时按项目口径手工跑测试,或带 `--test-cmd "<项目测试命令>"` 指定(2026-10-07 papercut)
60
60
  - 新需求:逐条勾验入口 intent「验收标准」,每条补「证据:」(例如:粘贴 CI 运行结果、UI 截图路径、或具体的 commit SHA),intent $\rightarrow$ done;同名 spec/Quick-Plan(若有)仍 approved $\rightarrow$ done。**注意:勾选但缺证据会被 check-loop 拦截(hard-block)**
61
61
  - **approved 留痕提交**(2026-09-30 起):done 确认前确认**本次关单文档**的 approved 态已进 git 历史(越早越好——approved 后即应提交;若尚未,先补提交再 done;门为逐份校验,三件套均关单时逐份各自满足)——confirm-doc 的 done 前置门硬校验(历史未出现「状态: approved」→ 拒绝落账);done 后再提交 done 态与其余改动
62
62
  **确认落态唯一入口**(2026-09-27 起):用户在终端跑 `node .agents/scripts/confirm-doc.mjs <path>` 键入「可以」(draft→approved / approved→done;对话内明确确认后 AI 可 `--delegated "<原话>"` 代录,台账如实记 source/quote——**逐件调用 + 逐件原话**(一次一份,build.md「逐件确认」口径;多份并录被 confirm-doc 拒绝。--delegated 每次传当次实际放行的措辞,不复用同句——复用同句使台账 quote 失去分辨力))。check-loop 15 如实口径:缺记录/指纹不配对 → hard-block 拦截;伪造台账本地不可机器防,留痕供事后对质;done 内容绑定(2026-09-28 起)——**关单编辑(勾验/回填确认结果)先于 done 确认、confirm-doc 是最后一次写入**,此后修订走 superseded 或新 intent。
@@ -56,7 +56,7 @@ node bin/flow-kit.mjs doctor # §6.5 delegations + §6.6 o
56
56
  - 输出测试报告(通过项 / 失败项)
57
57
  - 失败项已修复并复测通过
58
58
  - **关单**(日常闭环点,不依赖是否上 prod):
59
- - 关单前一键过门:`node .agents/scripts/verify.mjs`(npm test + check-loop 固定编排,任一失败非零退出,非绿不关单);**装户仓**(根目录无 `package.json`,工程在子目录)默认 `npm test` 不可跑 → 脚本**显式跳过步骤 1 且不落测试绿凭证**(防假绿)并提示;此时按项目口径手工跑测试,或带 `--test-cmd "<项目测试命令>"` 指定(2026-10-07 papercut)
59
+ - 关单前一键过门:`node .agents/scripts/verify.mjs --doc workflow/intents/<本单>.md`(npm test + check-loop 固定编排,任一失败非零退出,非绿不关单);**`--doc` 把测试绿凭证绑到本单**——不带它落下的绿行不给任何单背书,confirm-doc 前置与检查 8 对账只认带本单 `--doc` 的绿行(仅 v6+ 生效);**装户仓**(根目录无 `package.json`,工程在子目录)默认 `npm test` 不可跑 → 脚本**显式跳过步骤 1 且不落测试绿凭证**(防假绿)并提示;此时按项目口径手工跑测试,或带 `--test-cmd "<项目测试命令>"` 指定(2026-10-07 papercut)
60
60
  - 新需求:逐条勾验入口 intent「验收标准」,每条补「证据:」(例如:粘贴 CI 运行结果、UI 截图路径、或具体的 commit SHA),intent $\rightarrow$ done;同名 spec/Quick-Plan(若有)仍 approved $\rightarrow$ done。**注意:勾选但缺证据会被 check-loop 拦截(hard-block)**
61
61
  - **approved 留痕提交**(2026-09-30 起):done 确认前确认**本次关单文档**的 approved 态已进 git 历史(越早越好——approved 后即应提交;若尚未,先补提交再 done;门为逐份校验,三件套均关单时逐份各自满足)——confirm-doc 的 done 前置门硬校验(历史未出现「状态: approved」→ 拒绝落账);done 后再提交 done 态与其余改动
62
62
  **确认落态唯一入口**(2026-09-27 起):用户在终端跑 `node .agents/scripts/confirm-doc.mjs <path>` 键入「可以」(draft→approved / approved→done;对话内明确确认后 AI 可 `--delegated "<原话>"` 代录,台账如实记 source/quote——**逐件调用 + 逐件原话**(一次一份,build.md「逐件确认」口径;多份并录被 confirm-doc 拒绝。--delegated 每次传当次实际放行的措辞,不复用同句——复用同句使台账 quote 失去分辨力))。check-loop 15 如实口径:缺记录/指纹不配对 → hard-block 拦截;伪造台账本地不可机器防,留痕供事后对质;done 内容绑定(2026-09-28 起)——**关单编辑(勾验/回填确认结果)先于 done 确认、confirm-doc 是最后一次写入**,此后修订走 superseded 或新 intent。
@@ -56,7 +56,7 @@ node bin/flow-kit.mjs doctor # §6.5 delegations + §6.6 o
56
56
  - 输出测试报告(通过项 / 失败项)
57
57
  - 失败项已修复并复测通过
58
58
  - **关单**(日常闭环点,不依赖是否上 prod):
59
- - 关单前一键过门:`node .agents/scripts/verify.mjs`(npm test + check-loop 固定编排,任一失败非零退出,非绿不关单);**装户仓**(根目录无 `package.json`,工程在子目录)默认 `npm test` 不可跑 → 脚本**显式跳过步骤 1 且不落测试绿凭证**(防假绿)并提示;此时按项目口径手工跑测试,或带 `--test-cmd "<项目测试命令>"` 指定(2026-10-07 papercut)
59
+ - 关单前一键过门:`node .agents/scripts/verify.mjs --doc workflow/intents/<本单>.md`(npm test + check-loop 固定编排,任一失败非零退出,非绿不关单);**`--doc` 把测试绿凭证绑到本单**——不带它落下的绿行不给任何单背书,confirm-doc 前置与检查 8 对账只认带本单 `--doc` 的绿行(仅 v6+ 生效);**装户仓**(根目录无 `package.json`,工程在子目录)默认 `npm test` 不可跑 → 脚本**显式跳过步骤 1 且不落测试绿凭证**(防假绿)并提示;此时按项目口径手工跑测试,或带 `--test-cmd "<项目测试命令>"` 指定(2026-10-07 papercut)
60
60
  - 新需求:逐条勾验入口 intent「验收标准」,每条补「证据:」(例如:粘贴 CI 运行结果、UI 截图路径、或具体的 commit SHA),intent $\rightarrow$ done;同名 spec/Quick-Plan(若有)仍 approved $\rightarrow$ done。**注意:勾选但缺证据会被 check-loop 拦截(hard-block)**
61
61
  - **approved 留痕提交**(2026-09-30 起):done 确认前确认**本次关单文档**的 approved 态已进 git 历史(越早越好——approved 后即应提交;若尚未,先补提交再 done;门为逐份校验,三件套均关单时逐份各自满足)——confirm-doc 的 done 前置门硬校验(历史未出现「状态: approved」→ 拒绝落账);done 后再提交 done 态与其余改动
62
62
  **确认落态唯一入口**(2026-09-27 起):用户在终端跑 `node .agents/scripts/confirm-doc.mjs <path>` 键入「可以」(draft→approved / approved→done;对话内明确确认后 AI 可 `--delegated "<原话>"` 代录,台账如实记 source/quote——**逐件调用 + 逐件原话**(一次一份,build.md「逐件确认」口径;多份并录被 confirm-doc 拒绝。--delegated 每次传当次实际放行的措辞,不复用同句——复用同句使台账 quote 失去分辨力))。check-loop 15 如实口径:缺记录/指纹不配对 → hard-block 拦截;伪造台账本地不可机器防,留痕供事后对质;done 内容绑定(2026-09-28 起)——**关单编辑(勾验/回填确认结果)先于 done 确认、confirm-doc 是最后一次写入**,此后修订走 superseded 或新 intent。
@@ -57,7 +57,7 @@ node bin/flow-kit.mjs doctor # §6.5 delegations + §6.6 o
57
57
  - 输出测试报告(通过项 / 失败项)
58
58
  - 失败项已修复并复测通过
59
59
  - **关单**(日常闭环点,不依赖是否上 prod):
60
- - 关单前一键过门:`node .agents/scripts/verify.mjs`(npm test + check-loop 固定编排,任一失败非零退出,非绿不关单);**装户仓**(根目录无 `package.json`,工程在子目录)默认 `npm test` 不可跑 → 脚本**显式跳过步骤 1 且不落测试绿凭证**(防假绿)并提示;此时按项目口径手工跑测试,或带 `--test-cmd "<项目测试命令>"` 指定(2026-10-07 papercut)
60
+ - 关单前一键过门:`node .agents/scripts/verify.mjs --doc workflow/intents/<本单>.md`(npm test + check-loop 固定编排,任一失败非零退出,非绿不关单);**`--doc` 把测试绿凭证绑到本单**——不带它落下的绿行不给任何单背书,confirm-doc 前置与检查 8 对账只认带本单 `--doc` 的绿行(仅 v6+ 生效);**装户仓**(根目录无 `package.json`,工程在子目录)默认 `npm test` 不可跑 → 脚本**显式跳过步骤 1 且不落测试绿凭证**(防假绿)并提示;此时按项目口径手工跑测试,或带 `--test-cmd "<项目测试命令>"` 指定(2026-10-07 papercut)
61
61
  - 新需求:逐条勾验入口 intent「验收标准」,每条补「证据:」(例如:粘贴 CI 运行结果、UI 截图路径、或具体的 commit SHA),intent $\rightarrow$ done;同名 spec/Quick-Plan(若有)仍 approved $\rightarrow$ done。**注意:勾选但缺证据会被 check-loop 拦截(hard-block)**
62
62
  - **approved 留痕提交**(2026-09-30 起):done 确认前确认**本次关单文档**的 approved 态已进 git 历史(越早越好——approved 后即应提交;若尚未,先补提交再 done;门为逐份校验,三件套均关单时逐份各自满足)——confirm-doc 的 done 前置门硬校验(历史未出现「状态: approved」→ 拒绝落账);done 后再提交 done 态与其余改动
63
63
  **确认落态唯一入口**(2026-09-27 起):用户在终端跑 `node .agents/scripts/confirm-doc.mjs <path>` 键入「可以」(draft→approved / approved→done;对话内明确确认后 AI 可 `--delegated "<原话>"` 代录,台账如实记 source/quote——**逐件调用 + 逐件原话**(一次一份,build.md「逐件确认」口径;多份并录被 confirm-doc 拒绝。--delegated 每次传当次实际放行的措辞,不复用同句——复用同句使台账 quote 失去分辨力))。check-loop 15 如实口径:缺记录/指纹不配对 → hard-block 拦截;伪造台账本地不可机器防,留痕供事后对质;done 内容绑定(2026-09-28 起)——**关单编辑(勾验/回填确认结果)先于 done 确认、confirm-doc 是最后一次写入**,此后修订走 superseded 或新 intent。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-flow-kit",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "AI-Native 闭环工作流 + wiki 知识层脚手架——flow-kit init 一条命令为新项目装好 .agents 工作流引擎(阶段命令/角色契约/门禁钩子/kb 检索/看板/规则面预算)+ workflow 文档闭环 + wiki 知识库,装完自包含、零运行时依赖",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -75,7 +75,7 @@ node bin/flow-kit.mjs doctor # §6.5 delegations + §6.6 o
75
75
  - 输出测试报告(通过项 / 失败项)
76
76
  - 失败项已修复并复测通过
77
77
  - **关单**(日常闭环点,不依赖是否上 prod):
78
- - 关单前一键过门:`node .agents/scripts/verify.mjs`(npm test + check-loop 固定编排,任一失败非零退出,非绿不关单);**装户仓**(根目录无 `package.json`,工程在子目录)默认 `npm test` 不可跑 → 脚本**显式跳过步骤 1 且不落测试绿凭证**(防假绿)并提示;此时按项目口径手工跑测试,或带 `--test-cmd "<项目测试命令>"` 指定(2026-10-07 papercut)
78
+ - 关单前一键过门:`node .agents/scripts/verify.mjs --doc workflow/intents/<本单>.md`(npm test + check-loop 固定编排,任一失败非零退出,非绿不关单);**`--doc` 把测试绿凭证绑到本单**——不带它落下的绿行不给任何单背书,confirm-doc 前置与检查 8 对账只认带本单 `--doc` 的绿行(仅 v6+ 生效);**装户仓**(根目录无 `package.json`,工程在子目录)默认 `npm test` 不可跑 → 脚本**显式跳过步骤 1 且不落测试绿凭证**(防假绿)并提示;此时按项目口径手工跑测试,或带 `--test-cmd "<项目测试命令>"` 指定(2026-10-07 papercut)
79
79
  - 新需求:逐条勾验入口 intent「验收标准」,每条补「证据:」(例如:粘贴 CI 运行结果、UI 截图路径、或具体的 commit SHA),intent $\rightarrow$ done;同名 spec/Quick-Plan(若有)仍 approved $\rightarrow$ done。**注意:勾选但缺证据会被 check-loop 拦截(hard-block)**
80
80
  - **approved 留痕提交**(2026-09-30 起):done 确认前确认**本次关单文档**的 approved 态已进 git 历史(越早越好——approved 后即应提交;若尚未,先补提交再 done;门为逐份校验,三件套均关单时逐份各自满足)——confirm-doc 的 done 前置门硬校验(历史未出现「状态: approved」→ 拒绝落账);done 后再提交 done 态与其余改动
81
81
  **确认落态唯一入口**(2026-09-27 起):用户在终端跑 `node .agents/scripts/confirm-doc.mjs <path>` 键入「可以」(draft→approved / approved→done;对话内明确确认后 AI 可 `--delegated "<原话>"` 代录,台账如实记 source/quote——**逐件调用 + 逐件原话**(一次一份,build.md「逐件确认」口径;多份并录被 confirm-doc 拒绝。--delegated 每次传当次实际放行的措辞,不复用同句——复用同句使台账 quote 失去分辨力))。check-loop 15 如实口径:缺记录/指纹不配对 → hard-block 拦截;伪造台账本地不可机器防,留痕供事后对质;done 内容绑定(2026-09-28 起)——**关单编辑(勾验/回填确认结果)先于 done 确认、confirm-doc 是最后一次写入**,此后修订走 superseded 或新 intent。
@@ -2,7 +2,10 @@
2
2
  /* agg-delegations: 量化证据聚合
3
3
  * 读 workflow/delegations.md 两张结果表(按表头签名识别:委派表含「被委派方」列、自做表=日期+任务一句话;不依赖 ## 节标题)+ 扫 workflow/incidents/ 按月计数,
4
4
  * 输出各月指标(一次通过率 / 平均返工次数 / 主兜底占比)、并发扩容门判定、可粘贴的月度快照行。
5
- * 结果取值:一次通过 | 返工×N | 主兜底 | 返工待修(未闭环,不计入率)。
5
+ * 结果取值:一次通过 | 返工×N | 返工×N(门禁噪声)| 主兜底 | 返工待修(未闭环,不计入率)。
6
+ * 门禁噪声档(2026-10-08 selfmeasure-and-modularize):剥离「返工由门禁面而非设计面触发」的形态——
7
+ * 扩容门第 3 项只对设计返工判 =0,门禁噪声返工单列观测。存量行零回填(旧四档解析逐条不变)。
8
+ * 取值定义与边界(何为门禁噪声 / 不得用于给设计返工贴标签)以 workflow/delegations.md 头部记法为准。
6
9
  * 用法:node .agents/scripts/agg-delegations.cjs [--month=YYYY-MM](默认输出全部月份 + 全量累计)
7
10
  * 门槛定义见 workflow/delegations.md §并发扩容门槛(doc 为权威,脚本向文档对齐——2026-09-27 board-kb-p1)。
8
11
  */
@@ -29,14 +32,31 @@ function parseTableRows(sectionText) {
29
32
  .filter((cols) => !/^\s*$/.test(cols[0]) && /^\d{4}-\d{2}-\d{2}$/.test(cols[0])); // 只留数据行(首列是日期;表头/分隔行随之滤除)
30
33
  }
31
34
 
35
+ // parseResult:结果列解析。返回值统一为 { kind, rework, reworkNoise } 两个分量。
36
+ // 三个返工格式(2026-10-08 rework-attribution-split 补第三种):
37
+ // 返工×N → 纯设计返工 { kind:'rework', rework:N, reworkNoise:0 }
38
+ // 返工×N(门禁噪声) → 纯门禁面噪声 { kind:'rework-noise', rework:0, reworkNoise:N }
39
+ // 返工×N + 门禁噪声×M → 行内双值(混合) { kind:'rework-mixed', rework:N, reworkNoise:M }
40
+ // 动机:存量逐行审计(29 行含返工)证实前两种单值格式**无法表达混合归因**——一行只记一个返工
41
+ // 总数时,「1 次设计 + 1 次噪声」只能整行倒向某一侧。行内双值是作者写入时确定已知的归因事实,
42
+ // 不从返工点数量反推(batch-ledger-audit 结论:判据读写入时事实,不猜)。
43
+ // ⚠️ 顺序约束:新分支须排在两个单值分支之前(`$` 锚定使三者互斥,此处按语义就近声明)。
32
44
  function parseResult(raw) {
33
45
  const r = (raw || '').trim();
34
- if (/^一次通过$/.test(r)) return { kind: 'pass', rework: 0 };
46
+ // 行内双值(混合归因):宽容空格(`返工×1+门禁噪声×1` 亦可解析)
47
+ const mx = r.match(/^返工×(\d+)\s*\+\s*门禁噪声×(\d+)$/);
48
+ if (mx) return { kind: 'rework-mixed', rework: parseInt(mx[1], 10), reworkNoise: parseInt(mx[2], 10) };
49
+ if (/^一次通过$/.test(r)) return { kind: 'pass', rework: 0, reworkNoise: 0 };
50
+ // 门禁噪声档(2026-10-08 selfmeasure-and-modularize):剥离「返工由门禁面(预算超限 / 双源漏刷 /
51
+ // 节名不一致 / 门禁自身误报)触发、而非设计面」的形态。动机:本仓 DASHBOARD 自报质量门连续两月红
52
+ // (一次通过率 55% / 61%,门槛 ≥90%),根因是两档混列——指标永远红且不指示该改什么。
53
+ const mn = r.match(/^返工×(\d+)(门禁噪声)$/);
54
+ if (mn) return { kind: 'rework-noise', rework: 0, reworkNoise: parseInt(mn[1], 10) };
35
55
  const m = r.match(/^返工×(\d+)$/);
36
- if (m) return { kind: 'rework', rework: parseInt(m[1], 10) };
37
- if (/^主兜底$/.test(r)) return { kind: 'fallback', rework: 1 };
38
- if (/^返工待修$/.test(r)) return { kind: 'pending', rework: 0 };
39
- return { kind: 'unknown', rework: 0 };
56
+ if (m) return { kind: 'rework', rework: parseInt(m[1], 10), reworkNoise: 0 };
57
+ if (/^主兜底$/.test(r)) return { kind: 'fallback', rework: 1, reworkNoise: 0 };
58
+ if (/^返工待修$/.test(r)) return { kind: 'pending', rework: 0, reworkNoise: 0 };
59
+ return { kind: 'unknown', rework: 0, reworkNoise: 0 };
40
60
  }
41
61
 
42
62
  // splitTables:按表头签名识别两张结果表——节标题只服务人类阅读,表头才是数据边界
@@ -99,7 +119,7 @@ function countIncidents() {
99
119
  }
100
120
 
101
121
  // 门槛(2026-09-27 board-kb-p1 对齐 workflow/delegations.md §并发扩容门槛声明——doc 为权威):
102
- // 月度口径项:一次完成率 ≥90% | 月度返工次数 = 0 | 无主兜底信号(=0)(旧「平均返工≤0.3 / 兜底≤10%」为宽松分叉,退役)
122
+ // 月度口径项:一次完成率 ≥90% | 月度**设计**返工次数 = 0(2026-10-08 起门禁噪声返工剥离单列)| 无主兜底信号(=0)(旧「平均返工≤0.3 / 兜底≤10%」为宽松分叉,退役)
103
123
  // 样本护栏项(脚本侧统计可信度门槛,非口径分叉):月样本量 ≥20 | 委派样本 ≥5
104
124
  // 连续性:扩容需「本月 + 上一自然月」连续两月月度口径全达标(delegations.md「连续两个月」)
105
125
  const GATE = { minSample: 20, minPassRate: 0.9, minDelegated: 5 };
@@ -110,12 +130,17 @@ function metrics(rows) {
110
130
  const valid = rows.filter((_, i) => kinds[i] !== 'pending' && kinds[i] !== 'unknown');
111
131
  const pending = kinds.filter((k) => k === 'pending').length;
112
132
  const unknown = kinds.filter((k) => k === 'unknown').length;
113
- let pass = 0, reworkSum = 0, fallback = 0;
133
+ let pass = 0, reworkSum = 0, reworkNoiseSum = 0, fallback = 0;
114
134
  for (const r of valid) {
115
135
  const p = parseResult(r.result);
116
136
  if (p.kind === 'pass') pass += 1;
117
137
  if (p.kind === 'fallback') fallback += 1;
118
- reworkSum += p.rework;
138
+ // 双列累计:设计返工与门禁噪声返工分列(2026-10-08 selfmeasure-and-modularize 引入分列,
139
+ // 2026-10-08 rework-attribution-split 加行内混合档)。
140
+ // 显式列 kind 而非 `reworkSum += p.rework`——否则后续新增 kind 会被静默并入设计返工,
141
+ // 且混合档的两个分量必须各走各的累加器(只加 rework 会漏掉噪声)。
142
+ if (p.kind === 'rework' || p.kind === 'rework-mixed') reworkSum += p.rework;
143
+ if (p.kind === 'rework-noise' || p.kind === 'rework-mixed') reworkNoiseSum += p.reworkNoise;
119
144
  }
120
145
  const delegatedValid = valid.filter((r) => r.scope === '委派');
121
146
  return {
@@ -125,7 +150,8 @@ function metrics(rows) {
125
150
  pass,
126
151
  passRate: valid.length ? pass / valid.length : null,
127
152
  reworkSum,
128
- avgRework: valid.length ? reworkSum / valid.length : null,
153
+ reworkNoiseSum,
154
+ avgRework: valid.length ? (reworkSum + reworkNoiseSum) / valid.length : null,
129
155
  delegatedValid: delegatedValid.length,
130
156
  fallback,
131
157
  fallbackRate: delegatedValid.length ? fallback / delegatedValid.length : null,
@@ -144,7 +170,10 @@ function gateMonth(m) {
144
170
  const items = [];
145
171
  items.push({ no: 1, ok: m.total >= GATE.minSample, desc: `样本量 ${m.total}/${GATE.minSample}`, kind: '样本护栏' });
146
172
  items.push({ no: 2, ok: m.passRate != null && m.passRate >= GATE.minPassRate, desc: `一次完成率 ${fmtRate(m.passRate)}≥90%`, kind: '口径' });
147
- items.push({ no: 3, ok: m.reworkSum === 0, desc: `月度返工次数 ${m.reworkSum}=0`, kind: '口径' });
173
+ // 第 3 项判据 2026-10-08 语义变更:只对**设计返工**判 =0;门禁噪声返工单列在描述里(数字仍可见不隐藏,
174
+ // 但不参与 ok 判定——两类混判是本仓质量门连续两月红的根因)。⚠️ 不可据此给设计返工贴噪声标签洗白指标:
175
+ // 该取值由作者手写、机器无法判真伪(同 --delegated 信任边界),口径约束见 delegations.md 头部。
176
+ items.push({ no: 3, ok: m.reworkSum === 0, desc: `月度设计返工 ${m.reworkSum}=0(门禁噪声返工 ${m.reworkNoiseSum || 0} 次已剥离单列)`, kind: '口径' });
148
177
  items.push({ no: 4, ok: m.fallback === 0, desc: `主兜底 ${m.fallback}=0(无主兜底信号)`, kind: '口径' });
149
178
  items.push({ no: 5, ok: m.delegatedValid >= GATE.minDelegated, desc: `委派样本 ${m.delegatedValid}≥5`, kind: '样本护栏' });
150
179
  items.push({ no: 6, ok: null, desc: '门禁不失守(人工对照当月 incident 定性)', kind: '人工' });
@@ -183,7 +212,7 @@ function main() {
183
212
  const prevYm = (() => { const [y, mo] = ym.split('-').map(Number); const d = new Date(Date.UTC(y, mo - 2, 1)); return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`; })();
184
213
  const verdict = expansionVerdict(monthOk, monthResults.get(prevYm), !months.includes(prevYm), items);
185
214
  console.log(`## ${ym}`);
186
- console.log(` 有效任务 ${m.total}(待修 ${m.pending}${m.unknown ? `、未知结果 ${m.unknown}` : ''})|一次通过 ${m.pass}(${fmtRate(m.passRate)})|返工总次数 ${m.reworkSum}(平均 ${fmtNum(m.avgRework)})|主兜底 ${m.fallback}/${m.delegatedValid}(${fmtRate(m.fallbackRate)})|incident ${incidents[ym] || 0} 起`);
215
+ console.log(` 有效任务 ${m.total}(待修 ${m.pending}${m.unknown ? `、未知结果 ${m.unknown}` : ''})|一次通过 ${m.pass}(${fmtRate(m.passRate)})|设计返工 ${m.reworkSum} 次 / 门禁噪声返工 ${m.reworkNoiseSum} 次(合计返工 ${m.reworkSum + m.reworkNoiseSum},平均 ${fmtNum(m.avgRework)})|主兜底 ${m.fallback}/${m.delegatedValid}(${fmtRate(m.fallbackRate)})|incident ${incidents[ym] || 0} 起`);
187
216
  for (const i of items) console.log(` 门${i.no}[${i.kind}] ${i.ok === null ? '[人工]' : i.ok ? '✅' : '❌'} ${i.desc}`);
188
217
  console.log(` 扩容门判定:${verdict}\n`);
189
218
  snapshotRows.push(`| ${ym} | ${m.total} | ${fmtRate(m.passRate)} | ${fmtNum(m.avgRework)} | ${fmtRate(m.fallbackRate)} | ${incidents[ym] || 0} | ${verdict} | 样本含待修${m.pending}${m.unknown ? `、未知${m.unknown}` : ''} |`);
@@ -91,7 +91,7 @@ const runAgg = (root) => spawnSync(process.execPath, [path.join(root, '.agents',
91
91
  // 纯函数经 module.exports require(isMain 守卫后 import 不执行 main)
92
92
  import { createRequire } from 'node:module';
93
93
  const requireCjs = createRequire(import.meta.url);
94
- const { metrics, gateMonth, expansionVerdict } = requireCjs(path.join(SCRIPT_DIR, 'agg-delegations.cjs'));
94
+ const { metrics, gateMonth, expansionVerdict, parseResult } = requireCjs(path.join(SCRIPT_DIR, 'agg-delegations.cjs'));
95
95
 
96
96
  // 场景 4:审查反例——30 任务、2 主兜底、平均返工 0.25(旧口径四门全过,声明口径下必须 ❌)
97
97
  {
@@ -119,6 +119,81 @@ const { metrics, gateMonth, expansionVerdict } = requireCjs(path.join(SCRIPT_DIR
119
119
  check('场景 5:本月未达标 → ❌ 且列出未达标项号', expansionVerdict(false, true, false, [{ no: 3, ok: false }, { no: 4, ok: false }]).match(/❌.*3\+4/));
120
120
  }
121
121
 
122
+ // 场景 6:门禁噪声返工拆分(2026-10-08 selfmeasure-and-modularize)
123
+ // 动机:本仓质量门连续两月红(55% / 61% < 90%),根因是门禁噪声与设计返工同判第 3 项 → 指标不指示改进方向。
124
+ {
125
+ check('场景 6:parseResult 识别门禁噪声档(kind=rework-noise;2026-10-08 rework-attribution-split 更正:rework 分量归 0——噪声不是设计返工;外部行为 metrics/第 3 项不变)',
126
+ parseResult('返工×2(门禁噪声)').kind === 'rework-noise' && parseResult('返工×2(门禁噪声)').reworkNoise === 2
127
+ && parseResult('返工×2(门禁噪声)').rework === 0, JSON.stringify(parseResult('返工×2(门禁噪声)')));
128
+ check('场景 6:存量四档解析逐条不变(向后兼容钉子)',
129
+ parseResult('一次通过').kind === 'pass' && parseResult('返工×2').kind === 'rework'
130
+ && parseResult('返工×2').rework === 2 && parseResult('主兜底').kind === 'fallback'
131
+ && parseResult('返工待修').kind === 'pending' && parseResult('乱填').kind === 'unknown');
132
+ check('场景 6:新旧形态互不误吞($ 锚定;半角括号不认)',
133
+ parseResult('返工×1(门禁噪声)').kind !== 'rework' && parseResult('返工×1(门禁噪声)').kind === 'unknown');
134
+
135
+ const rows = [
136
+ { scope: '自做', result: '返工×2(门禁噪声)' }, // 噪声
137
+ { scope: '自做', result: '返工×3(门禁噪声)' }, // 噪声
138
+ { scope: '自做', result: '一次通过' },
139
+ ];
140
+ const m = metrics(rows);
141
+ check('场景 6:双列累计分离(设计返工 0 / 噪声 5)',
142
+ m.reworkSum === 0 && m.reworkNoiseSum === 5, JSON.stringify({ reworkSum: m.reworkSum, noise: m.reworkNoiseSum }));
143
+ check('场景 6:平均返工仍含噪声(快照行口径不变,delegations.md 表结构零改动)',
144
+ Math.abs(m.avgRework - 5 / 3) < 1e-9, String(m.avgRework));
145
+ const g = gateMonth(m);
146
+ const i3 = g.items.find((i) => i.no === 3);
147
+ check('场景 6:扩容门第 3 项只对设计返工判 ok=true(噪声不计入)', i3.ok === true, JSON.stringify(i3));
148
+ check('场景 6:噪声数字在描述里显式可见(不隐藏)', /门禁噪声返工 5 次已剥离单列/.test(i3.desc), i3.desc);
149
+
150
+ // 反向:设计返工不得因存在噪声档而被放过
151
+ const mixed = metrics([{ scope: '自做', result: '返工×1' }, { scope: '自做', result: '返工×1(门禁噪声)' }]);
152
+ check('场景 6:反例——设计返工 1 + 噪声 1 → 门 3 ❌(噪声标签不能洗白设计返工)',
153
+ gateMonth(mixed).items.find((i) => i.no === 3).ok === false, JSON.stringify(gateMonth(mixed).items[2]));
154
+ }
155
+
156
+ // 场景 7:行内双值归因(2026-10-08 rework-attribution-split)
157
+ // 动机:存量逐行审计(29 行含返工)证实单值格式无法表达混合归因——一行只记一个返工总数时,
158
+ // 「1 次设计 + 1 次噪声」只能整行倒向某一侧。行内双值是作者写入时确定已知的归因事实,不反推。
159
+ {
160
+ const mixed = parseResult('返工×1 + 门禁噪声×1');
161
+ check('场景 7:行内双值解析出两个分量(kind=rework-mixed)',
162
+ mixed.kind === 'rework-mixed' && mixed.rework === 1 && mixed.reworkNoise === 1, JSON.stringify(mixed));
163
+ check('场景 7:宽容空格(无空格变体亦可解析)',
164
+ parseResult('返工×2+门禁噪声×3').rework === 2 && parseResult('返工×2+门禁噪声×3').reworkNoise === 3);
165
+ check('场景 7:四个返工格式互不误吞',
166
+ parseResult('返工×2').kind === 'rework' && parseResult('返工×2').reworkNoise === 0
167
+ && parseResult('返工×2(门禁噪声)').kind === 'rework-noise'
168
+ && parseResult('返工×2(门禁噪声)').rework === 0
169
+ && parseResult('一次通过').kind === 'pass' && parseResult('乱填').kind === 'unknown');
170
+ check('场景 7:非法数字落 unknown(记录本身有问题,不混进待修)',
171
+ parseResult('返工×a + 门禁噪声×1').kind === 'unknown');
172
+
173
+ // 不变量①:任务总数不虚增——一行仍是一个任务,两个数字不拆成两个任务
174
+ const rows = [
175
+ { scope: '自做', result: '返工×1 + 门禁噪声×2' },
176
+ { scope: '自做', result: '返工×1 + 门禁噪声×2' },
177
+ { scope: '自做', result: '一次通过' },
178
+ ];
179
+ const m = metrics(rows);
180
+ check('场景 7:不变量①——total 按行计 1(行内双值不虚增任务数 / 不抬高 passRate 分母)',
181
+ m.total === 3, `total=${m.total}`);
182
+ check('场景 7:双列准确累计(设计返工 2 / 门禁噪声 4)',
183
+ m.reworkSum === 2 && m.reworkNoiseSum === 4, JSON.stringify({ r: m.reworkSum, n: m.reworkNoiseSum }));
184
+ check('场景 7:一次通过率分母不变(1/3,与若拆成两行的结果不同——这是选行内双值而非拆行的理由)',
185
+ Math.abs(m.passRate - 1 / 3) < 1e-9, String(m.passRate));
186
+
187
+ // 混合行的门 3 判定:设计返工 0 + 噪声 4 → ok;设计返工 2 → ❌
188
+ const onlyNoise = metrics([{ scope: '自做', result: '返工×0 + 门禁噪声×3' }, { scope: '自做', result: '一次通过' }]);
189
+ const i3ok = gateMonth(onlyNoise).items.find((i) => i.no === 3);
190
+ check('场景 7:纯噪声混合行 → 门 3 ✅ 且描述带噪声数',
191
+ i3ok.ok === true && /门禁噪声返工 3 次已剥离单列/.test(i3ok.desc), JSON.stringify(i3ok));
192
+ const withDesign = gateMonth(m).items.find((i) => i.no === 3);
193
+ check('场景 7:含设计返工的混合行 → 门 3 ❌(噪声分量不能洗白设计分量)',
194
+ withDesign.ok === false, JSON.stringify(withDesign));
195
+ }
196
+
122
197
  console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
123
198
  if (fail) {
124
199
  console.log(`\n${USAGE}`);
@@ -0,0 +1,113 @@
1
+ // agg-gate-stats.mjs — 门禁 ROI 聚合(2026-10-08 gate-roi-metrics)
2
+ // 读 .agents/cache/gate-stats.jsonl(check-loop --gate-stats 落的运行时数据)→ 按检查 ID 聚合
3
+ // 运行次数 / 命中次数 / 拦截数 / 警告数 / 平均耗时 → 输出表 + 两类决策依据:
4
+ // ① 噪声率 top N(警告数 ÷ 命中次数,降序)——该收窄或该修的候选;
5
+ // ② 死检查(声明了但从未命中)——该拆或该降级的候选。
6
+ //
7
+ // **为什么需要它**(本件存在的理由):本仓门禁 16→20 项期间,2026-10 一次通过率 61%→56%——
8
+ // 门禁数与质量指标负相关,但此前无任何数据能回答「该加门还是该拆门」(metrics/DASHBOARD 全部指标
9
+ // 100% 指向产出物、0 条指向门禁自身)。加门是焦虑驱动的唯一理性反应,因为拆门没有依据。
10
+ //
11
+ // 耗时口径**含子进程时间**(检查 11 调两个生成器 --check、检查 13 调 sh rule-budget.sh)——
12
+ // 门禁真实成本本就要含子进程,不做剔除;输出中显式标注防误读为「纯计算耗时」。
13
+ // 坏行容忍跳过(与 confirmations.jsonl / delegations 台账同容错口径)。
14
+ // 用法:node .agents/scripts/agg-gate-stats.mjs [--days 30]
15
+ // 测试:node templates/_agents/scripts/agg-gate-stats.test.mjs
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
21
+ const ROOT = process.env.CHECK_LOOP_ROOT || path.resolve(SCRIPT_DIR, '..', '..');
22
+ const STATS_FILE = path.join(ROOT, '.agents', 'cache', 'gate-stats.jsonl');
23
+
24
+ // readRows:读 jsonl,坏行跳过不抛
25
+ export function readRows(file = STATS_FILE) {
26
+ let text = '';
27
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
28
+ const out = [];
29
+ for (const line of text.split('\n')) {
30
+ if (!line.trim()) continue;
31
+ try {
32
+ const e = JSON.parse(line);
33
+ if (e && Array.isArray(e.segs)) out.push(e);
34
+ } catch { /* 坏行跳过 */ }
35
+ }
36
+ return out;
37
+ }
38
+
39
+ // aggregate:按 id 聚合(pure,供测试直测)。days = 统计窗口(天),null = 全量。
40
+ // runs = 该段被记录到的运行次数;hits = 至少命中一次(warns+blocks>0)的运行次数;
41
+ // warns / blocks = 累计条数;msSum / avgMs = 累计与平均耗时。
42
+ export function aggregate(rows, now = Date.now(), days = null) {
43
+ const cutoff = days ? now - days * 86400000 : null;
44
+ const by = new Map();
45
+ let runs = 0;
46
+ for (const r of rows) {
47
+ if (cutoff) {
48
+ const t = Date.parse(r.ts || '');
49
+ if (!Number.isFinite(t) || t < cutoff) continue;
50
+ }
51
+ runs++;
52
+ for (const s of r.segs || []) {
53
+ if (!s || !s.id) continue;
54
+ if (!by.has(s.id)) by.set(s.id, { id: s.id, label: s.label || '', runs: 0, hits: 0, warns: 0, blocks: 0, msSum: 0 });
55
+ const a = by.get(s.id);
56
+ a.runs++;
57
+ a.label = s.label || a.label;
58
+ a.warns += Number(s.warns) || 0;
59
+ a.blocks += Number(s.blocks) || 0;
60
+ a.msSum += Number(s.ms) || 0;
61
+ if ((Number(s.warns) || 0) + (Number(s.blocks) || 0) > 0) a.hits++;
62
+ }
63
+ }
64
+ const list = [...by.values()].map((a) => ({
65
+ ...a,
66
+ avgMs: a.runs ? Math.round(a.msSum / a.runs) : 0,
67
+ noiseRate: a.hits ? a.warns / a.hits : 0, // 每次命中的平均警告条数(0 = 从不产噪声)
68
+ }));
69
+ list.sort((x, y) => y.msSum - x.msSum); // 默认按累计耗时降序——成本视角优先
70
+ const noiseTop = [...list].filter((a) => a.warns > 0).sort((x, y) => y.noiseRate - x.noiseRate);
71
+ const dead = list.filter((a) => a.hits === 0);
72
+ const costTotal = list.reduce((a, s) => a + s.msSum, 0);
73
+ return { runs, list, noiseTop, dead, costTotal };
74
+ }
75
+
76
+ function fmtMs(ms) {
77
+ return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
78
+ }
79
+
80
+ function main() {
81
+ const i = process.argv.indexOf('--days');
82
+ const days = i >= 0 ? Number(process.argv[i + 1]) : 30;
83
+ const rows = readRows();
84
+ const { runs, list, noiseTop, dead, costTotal } = aggregate(rows, Date.now(), Number.isFinite(days) ? days : null);
85
+
86
+ if (!runs || !list.length) {
87
+ console.log('门禁 ROI:未采集——跑 `node .agents/scripts/check-loop.mjs --gate-stats` 后重试');
88
+ console.log('(数据落 .agents/cache/gate-stats.jsonl,该目录已 gitignore;首次采集后本表才有内容)');
89
+ return;
90
+ }
91
+
92
+ console.log(`# 门禁 ROI 聚合(窗口 ${days} 天 / 有效样本 ${runs} 次运行)\n`);
93
+ console.log('耗时口径**含子进程时间**(检查 11 调生成器 --check、检查 13 调 sh rule-budget.sh)——门禁真实成本,不做剔除。\n');
94
+ console.log('| 检查 | 名称 | 命中/运行 | 拦截 | 警告 | 累计耗时 | 均耗时 | 每次命中警告数 |');
95
+ console.log('|------|------|-----------|------|------|----------|--------|----------------|');
96
+ for (const a of list) {
97
+ console.log(`| ${a.id} | ${a.label} | ${a.hits}/${a.runs} | ${a.blocks} | ${a.warns} | ${fmtMs(a.msSum)} | ${fmtMs(a.avgMs)} | ${a.noiseRate.toFixed(1)} |`);
98
+ }
99
+ console.log(`\n累计门禁耗时 ${fmtMs(costTotal)}(${runs} 次运行,均 ${fmtMs(Math.round(costTotal / runs))}/次)`);
100
+
101
+ console.log(`\n## ① 噪声率 top(警告数 ÷ 命中次数)——收窄或修的候选`);
102
+ if (noiseTop.length) for (const a of noiseTop) console.log(` 检查 ${a.id} ${a.label}:${a.noiseRate.toFixed(1)} 条/次命中(累计 ${a.warns} 条警告)`);
103
+ else console.log(' (无段产出过警告)');
104
+
105
+ console.log(`\n## ② 死检查(声明了但从未命中)——拆或降级的候选`);
106
+ if (dead.length) for (const a of dead) console.log(` 检查 ${a.id} ${a.label}:${a.runs} 次运行零命中,累计耗时 ${fmtMs(a.msSum)}`);
107
+ else console.log(' (每段都至少命中过一次)');
108
+
109
+ if (runs < 5) console.log(`\n⚠️ 样本仅 ${runs} 次运行——**结论待样本积累**(建议 ≥30 次 / ≥30 天后再据此改判据;此刻只作线索不作决策依据)`);
110
+ }
111
+
112
+ const isMain = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('agg-gate-stats.mjs');
113
+ if (isMain) main();
@@ -0,0 +1,95 @@
1
+ // agg-gate-stats.test.mjs — 门禁 ROI 聚合测试(2026-10-08 gate-roi-metrics)
2
+ // 覆盖:聚合数学 / 窗口过滤 / 坏行容错 / 死检查识别 / 噪声率排序 / 按耗时降序 / 空数据降级。
3
+ // 用法:node templates/_agents/scripts/agg-gate-stats.test.mjs
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { pathToFileURL, fileURLToPath } from 'node:url';
8
+
9
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
10
+ const { aggregate, readRows } =
11
+ await import(pathToFileURL(path.join(SCRIPT_DIR, 'agg-gate-stats.mjs')).href);
12
+
13
+ let pass = 0, fail = 0;
14
+ const check = (name, cond, detail = '') => {
15
+ if (cond) { pass++; console.log(`PASS ${name}`); }
16
+ else { fail++; console.log(`FAIL ${name}${detail ? `——${detail}` : ''}`); }
17
+ };
18
+
19
+ const NOW = Date.parse('2026-10-08T00:00:00.000Z');
20
+ const run = (ts, segs) => ({ ts, segs });
21
+ const seg = (id, warns, blocks, ms, label = `检查${id}`) => ({ id, label, warns, blocks, ms });
22
+
23
+ // ---- 1. 聚合数学 ----
24
+ {
25
+ const rows = [
26
+ run('2026-10-07T00:00:00.000Z', [seg('8', 0, 0, 10000, '验收'), seg('4', 2, 0, 20, '引用')]),
27
+ run('2026-10-08T00:00:00.000Z', [seg('8', 0, 0, 12000, '验收'), seg('4', 1, 0, 30, '引用'), seg('3', 0, 1, 5, 'incidents')]),
28
+ ];
29
+ const a = aggregate(rows, NOW, null);
30
+ check('聚合:runs 计 2', a.runs === 2, `runs=${a.runs}`);
31
+ const s8 = a.list.find((x) => x.id === '8');
32
+ check('聚合:按 id 累计(检查 8 = 2 次运行 / 均耗时 11000ms)',
33
+ s8.runs === 2 && s8.avgMs === 11000 && s8.msSum === 22000, JSON.stringify(s8));
34
+ const s4 = a.list.find((x) => x.id === '4');
35
+ check('聚合:警告累计与命中次数(检查 4 = 命中 2 / 警告 3 / 噪声率 1.5)',
36
+ s4.hits === 2 && s4.warns === 3 && s4.noiseRate === 1.5, JSON.stringify(s4));
37
+ const s3 = a.list.find((x) => x.id === '3');
38
+ check('聚合:blocks 计入命中(检查 3 拦截 1 次 → hits=1)', s3.hits === 1 && s3.blocks === 1, JSON.stringify(s3));
39
+ check('聚合:默认按累计耗时降序(成本视角优先)', a.list[0].id === '8', a.list.map((x) => x.id).join(','));
40
+ check('聚合:costTotal = 各段 ms 之和', a.costTotal === 22000 + 50 + 5, String(a.costTotal));
41
+ }
42
+
43
+ // ---- 2. 窗口过滤 ----
44
+ {
45
+ const rows = [
46
+ run('2026-01-01T00:00:00.000Z', [seg('8', 0, 0, 99999, '验收')]),
47
+ run('2026-10-07T00:00:00.000Z', [seg('8', 0, 0, 1000, '验收')]),
48
+ ];
49
+ const a = aggregate(rows, NOW, 30);
50
+ check('窗口:30 天外的运行被排除(runs=1,耗时不含 99999)',
51
+ a.runs === 1 && a.costTotal === 1000, `runs=${a.runs} cost=${a.costTotal}`);
52
+ const b = aggregate(rows, NOW, null);
53
+ check('窗口:days=null 取全量(runs=2)', b.runs === 2, `runs=${b.runs}`);
54
+ }
55
+
56
+ // ---- 3. 死检查与噪声率排序 ----
57
+ {
58
+ const rows = [
59
+ run('2026-10-07T00:00:00.000Z', [
60
+ seg('8', 0, 0, 5000, '验收'), // 贵但从不命中 → 死检查
61
+ seg('17', 0, 0, 4000, '发版树'), // 贵且从不命中 → 死检查
62
+ seg('11', 5, 0, 100, '漂移'), // 高噪声
63
+ seg('4', 1, 0, 10, '引用'), // 低噪声
64
+ ]),
65
+ ];
66
+ const a = aggregate(rows, NOW, null);
67
+ const deadIds = a.dead.map((x) => x.id).sort();
68
+ check('死检查:识别「声明了但从未命中」(8/17),且按耗时排序(8 在前)',
69
+ deadIds.join(',') === '17,8' || deadIds.join(',') === '8,17', deadIds.join(','));
70
+ check('噪声率 top:11(5.0)> 4(1.0)', a.noiseTop.map((x) => x.id).join(',') === '11,4', a.noiseTop.map((x) => x.id).join(','));
71
+ check('死检查不进 noiseTop(从不命中 ≠ 高噪声)', !a.noiseTop.some((x) => x.id === '8'));
72
+ }
73
+
74
+ // ---- 4. 坏行容错 + 空数据 ----
75
+ {
76
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-gatestats-'));
77
+ const f = path.join(root, 'gate-stats.jsonl');
78
+ fs.writeFileSync(f, [
79
+ 'not-json-bad-line',
80
+ JSON.stringify(run('2026-10-07T00:00:00.000Z', [seg('8', 0, 0, 100)])),
81
+ '',
82
+ JSON.stringify({ ts: '2026-10-07T00:00:00.000Z' }), // 无 segs
83
+ ].join('\n') + '\n');
84
+ const rows = readRows(f);
85
+ check('坏行容错:非 JSON / 无 segs 的行跳过不抛(其余 1 行入账)', rows.length === 1 && rows[0].segs.length === 1);
86
+ check('坏行容错:文件不存在 → 空数组不抛', readRows(path.join(root, 'nope.jsonl')).length === 0);
87
+ check('空数据降级:aggregate 返回零值而非抛', aggregate([], NOW, 30).runs === 0 && aggregate([], NOW, 30).list.length === 0);
88
+ // 段体缺字段容错
89
+ const r2 = aggregate([{ ts: '2026-10-07T00:00:00.000Z', segs: [{ id: '8' }, null, { warns: 1 }] }], NOW, null);
90
+ check('段体容错:缺 ms / null 段 / 缺 id 段不抛', r2.runs === 1 && r2.list.find((x) => x.id === '8').avgMs === 0);
91
+ fs.rmSync(root, { recursive: true, force: true });
92
+ }
93
+
94
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
95
+ process.exit(fail ? 1 : 0);