agentic-flow-kit 1.7.15 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/README.md +50 -8
  2. package/modules/hosts/claude/commands/wf-build.md +1 -1
  3. package/modules/hosts/claude/commands/wf-design.md +5 -4
  4. package/modules/hosts/claude/commands/wf-maintain.md +1 -1
  5. package/modules/hosts/claude/commands/wf-new-task.md +47 -25
  6. package/modules/hosts/claude/commands/wf-plan.md +1 -1
  7. package/modules/hosts/claude/commands/wf-review.md +2 -2
  8. package/modules/hosts/claude/commands/wf-sync-hosts.md +7 -13
  9. package/modules/hosts/claude/commands/wf-test.md +2 -2
  10. package/modules/hosts/codex/commands/wf-build.md +1 -1
  11. package/modules/hosts/codex/commands/wf-design.md +5 -4
  12. package/modules/hosts/codex/commands/wf-maintain.md +1 -1
  13. package/modules/hosts/codex/commands/wf-new-task.md +47 -25
  14. package/modules/hosts/codex/commands/wf-plan.md +1 -1
  15. package/modules/hosts/codex/commands/wf-review.md +2 -2
  16. package/modules/hosts/codex/commands/wf-sync-hosts.md +7 -13
  17. package/modules/hosts/codex/commands/wf-test.md +2 -2
  18. package/modules/hosts/cursor/commands/wf-build.md +1 -1
  19. package/modules/hosts/cursor/commands/wf-design.md +5 -4
  20. package/modules/hosts/cursor/commands/wf-maintain.md +1 -1
  21. package/modules/hosts/cursor/commands/wf-new-task.md +47 -25
  22. package/modules/hosts/cursor/commands/wf-plan.md +1 -1
  23. package/modules/hosts/cursor/commands/wf-review.md +2 -2
  24. package/modules/hosts/cursor/commands/wf-sync-hosts.md +7 -13
  25. package/modules/hosts/cursor/commands/wf-test.md +2 -2
  26. package/modules/hosts/opencode/commands/wf-build.md +1 -1
  27. package/modules/hosts/opencode/commands/wf-design.md +5 -4
  28. package/modules/hosts/opencode/commands/wf-maintain.md +1 -1
  29. package/modules/hosts/opencode/commands/wf-new-task.md +47 -25
  30. package/modules/hosts/opencode/commands/wf-plan.md +1 -1
  31. package/modules/hosts/opencode/commands/wf-review.md +2 -2
  32. package/modules/hosts/opencode/commands/wf-sync-hosts.md +7 -13
  33. package/modules/hosts/opencode/commands/wf-test.md +2 -2
  34. package/modules/hosts/trae/commands/wf-build.md +1 -1
  35. package/modules/hosts/trae/commands/wf-design.md +5 -4
  36. package/modules/hosts/trae/commands/wf-maintain.md +1 -1
  37. package/modules/hosts/trae/commands/wf-new-task.md +47 -25
  38. package/modules/hosts/trae/commands/wf-plan.md +1 -1
  39. package/modules/hosts/trae/commands/wf-review.md +2 -2
  40. package/modules/hosts/trae/commands/wf-sync-hosts.md +7 -13
  41. package/modules/hosts/trae/commands/wf-test.md +2 -2
  42. package/package.json +1 -1
  43. package/src/cli.mjs +47 -4
  44. package/src/doctor.mjs +48 -3
  45. package/src/executor.mjs +262 -0
  46. package/src/executor.test.mjs +295 -0
  47. package/src/fresh-init.test.mjs +1 -1
  48. package/src/init.mjs +27 -3
  49. package/src/profiles.mjs +10 -3
  50. package/src/render.mjs +3 -3
  51. package/src/rule-budget.test.mjs +2 -2
  52. package/src/stack-profile.test.mjs +6 -0
  53. package/src/sync-hosts.mjs +37 -43
  54. package/src/sync-hosts.test.mjs +67 -56
  55. package/src/validate.mjs +6 -1
  56. package/src/validate.test.mjs +1 -1
  57. package/templates/.gitattributes +12 -1
  58. package/templates/AGENTS.md +3 -3
  59. package/templates/_agents/commands/build.md +1 -1
  60. package/templates/_agents/commands/design.md +5 -4
  61. package/templates/_agents/commands/maintain.md +1 -1
  62. package/templates/_agents/commands/new-task.md +47 -25
  63. package/templates/_agents/commands/plan.md +1 -1
  64. package/templates/_agents/commands/review.md +2 -2
  65. package/templates/_agents/commands/sync-hosts.md +7 -13
  66. package/templates/_agents/commands/test.md +2 -2
  67. package/templates/_agents/control-surfaces.txt +1 -1
  68. package/templates/_agents/engine-lock.json +334 -0
  69. package/templates/_agents/hooks/check-index-incremental.mjs +48 -0
  70. package/templates/_agents/hooks/check-pairing-incremental.mjs +66 -0
  71. package/templates/_agents/hooks/check-wiki-ledger.mjs +30 -0
  72. package/templates/_agents/lane-surfaces.txt +5 -2
  73. package/templates/_agents/level-hints.txt +30 -0
  74. package/templates/_agents/loop/papercut-debt/DESIGN.md +112 -0
  75. package/templates/_agents/mechanism-registry.md +27 -0
  76. package/templates/_agents/rule-budgets.txt +2 -2
  77. package/templates/_agents/scripts/agg-delegations.cjs +21 -3
  78. package/templates/_agents/scripts/agg-delegations.test.mjs +5 -3
  79. package/templates/_agents/scripts/agg-gate-stats.mjs +3 -3
  80. package/templates/_agents/scripts/agg-judgments.mjs +80 -0
  81. package/templates/_agents/scripts/agg-judgments.test.mjs +45 -0
  82. package/templates/_agents/scripts/archive-sweep.mjs +233 -0
  83. package/templates/_agents/scripts/archive-sweep.test.mjs +129 -0
  84. package/templates/_agents/scripts/check-attribution.mjs +37 -0
  85. package/templates/_agents/scripts/check-convergence.mjs +119 -0
  86. package/templates/_agents/scripts/check-engine-integrity.mjs +59 -127
  87. package/templates/_agents/scripts/check-engine-integrity.test.mjs +33 -45
  88. package/templates/_agents/scripts/check-funnel.mjs +56 -0
  89. package/templates/_agents/scripts/check-hygiene.mjs +5 -5
  90. package/templates/_agents/scripts/check-index-incremental.test.mjs +73 -0
  91. package/templates/_agents/scripts/check-lane-surface.mjs +74 -25
  92. package/templates/_agents/scripts/check-lane-surface.test.mjs +71 -5
  93. package/templates/_agents/scripts/check-ledger-invariant.mjs +427 -78
  94. package/templates/_agents/scripts/check-ledger-invariant.test.mjs +438 -4
  95. package/templates/_agents/scripts/check-loop-rev.test.mjs +13 -8
  96. package/templates/_agents/scripts/check-loop-unit.test.mjs +8 -0
  97. package/templates/_agents/scripts/check-loop.mjs +264 -75
  98. package/templates/_agents/scripts/check-loop.test.mjs +587 -61
  99. package/templates/_agents/scripts/check-metric-claims.mjs +4 -2
  100. package/templates/_agents/scripts/check-prejudgment.mjs +59 -0
  101. package/templates/_agents/scripts/check-registry.mjs +45 -0
  102. package/templates/_agents/scripts/check-scope.mjs +96 -0
  103. package/templates/_agents/scripts/check-scope.test.mjs +96 -0
  104. package/templates/_agents/scripts/check-scripts-syntax.mjs +61 -0
  105. package/templates/_agents/scripts/check-scripts-syntax.test.mjs +72 -0
  106. package/templates/_agents/scripts/check-spec-verif.mjs +47 -0
  107. package/templates/_agents/scripts/compact-ledgers.mjs +97 -0
  108. package/templates/_agents/scripts/compact-ledgers.test.mjs +162 -0
  109. package/templates/_agents/scripts/confirm-doc.mjs +136 -31
  110. package/templates/_agents/scripts/confirm-doc.test.mjs +180 -4
  111. package/templates/_agents/scripts/doctor.test.mjs +44 -1
  112. package/templates/_agents/scripts/edit-face-check.mjs +5 -3
  113. package/templates/_agents/scripts/edit-face-check.test.mjs +5 -4
  114. package/templates/_agents/scripts/fill-intent.mjs +7 -5
  115. package/templates/_agents/scripts/fill-intent.test.mjs +19 -1
  116. package/templates/_agents/scripts/fill-plan.mjs +10 -6
  117. package/templates/_agents/scripts/fill-plan.test.mjs +19 -3
  118. package/templates/_agents/scripts/fill-spec.mjs +6 -3
  119. package/templates/_agents/scripts/fill-spec.test.mjs +12 -4
  120. package/templates/_agents/scripts/fixture-scripts.mjs +153 -0
  121. package/templates/_agents/scripts/fixture-scripts.test.mjs +112 -0
  122. package/templates/_agents/scripts/gate-checklist.mjs +10 -9
  123. package/templates/_agents/scripts/gate-checklist.test.mjs +2 -11
  124. package/templates/_agents/scripts/gate-seg.mjs +1 -1
  125. package/templates/_agents/scripts/gen-effective-policy.mjs +107 -0
  126. package/templates/_agents/scripts/gen-effective-policy.test.mjs +100 -0
  127. package/templates/_agents/scripts/gen-verify-evidence.mjs +107 -0
  128. package/templates/_agents/scripts/gen-verify-evidence.test.mjs +84 -0
  129. package/templates/_agents/scripts/gen-workflow-dashboard.mjs +16 -14
  130. package/templates/_agents/scripts/gen-workflow-dashboard.test.mjs +2 -1
  131. package/templates/_agents/scripts/gen-workflow-metrics.mjs +12 -36
  132. package/templates/_agents/scripts/git-core.mjs +45 -0
  133. package/templates/_agents/scripts/git-merge-jsonl.mjs +63 -0
  134. package/templates/_agents/scripts/git-merge-jsonl.test.mjs +125 -0
  135. package/templates/_agents/scripts/judgment-ledger.mjs +65 -0
  136. package/templates/_agents/scripts/judgment-ledger.test.mjs +129 -0
  137. package/templates/_agents/scripts/kb-search.mjs +19 -4
  138. package/templates/_agents/scripts/kb-search.test.mjs +33 -0
  139. package/templates/_agents/scripts/lane-surface-audit.mjs +143 -0
  140. package/templates/_agents/scripts/lane-surface-audit.test.mjs +93 -0
  141. package/templates/_agents/scripts/ledger-doc-chain.test.mjs +166 -0
  142. package/templates/_agents/scripts/ledger-loader.mjs +108 -0
  143. package/templates/_agents/scripts/ledger-loader.test.mjs +131 -0
  144. package/templates/_agents/scripts/ledger-prune.mjs +96 -0
  145. package/templates/_agents/scripts/ledger-prune.test.mjs +120 -0
  146. package/templates/_agents/scripts/ledger-shards.mjs +307 -0
  147. package/templates/_agents/scripts/ledger-shards.test.mjs +339 -0
  148. package/templates/_agents/scripts/ledger-ts-monotonic.test.mjs +135 -0
  149. package/templates/_agents/scripts/level-criteria.mjs +231 -0
  150. package/templates/_agents/scripts/level-criteria.test.mjs +281 -0
  151. package/templates/_agents/scripts/papercut-debt-controller.mjs +141 -0
  152. package/templates/_agents/scripts/papercut-debt-controller.test.mjs +127 -0
  153. package/templates/_agents/scripts/papercut-debt-sensor.mjs +170 -0
  154. package/templates/_agents/scripts/papercut-debt-sensor.test.mjs +146 -0
  155. package/templates/_agents/scripts/pipeline-run.mjs +10 -24
  156. package/templates/_agents/scripts/pipeline-run.test.mjs +22 -42
  157. package/templates/_agents/scripts/policy.mjs +75 -0
  158. package/templates/_agents/scripts/resolve-merge-conflicts.mjs +110 -0
  159. package/templates/_agents/scripts/resolve-merge-conflicts.test.mjs +87 -0
  160. package/templates/_agents/scripts/rewrite-file.mjs +133 -0
  161. package/templates/_agents/scripts/rewrite-file.test.mjs +148 -0
  162. package/templates/_agents/scripts/rule-budget.mjs +83 -0
  163. package/templates/_agents/scripts/run-tests.mjs +99 -0
  164. package/templates/_agents/scripts/run-tests.test.mjs +105 -0
  165. package/templates/_agents/scripts/solidify-task.test.mjs +1 -1
  166. package/templates/_agents/scripts/stage-gates.mjs +38 -21
  167. package/templates/_agents/scripts/trust-mode.mjs +4 -4
  168. package/templates/_agents/scripts/trust-mode.test.mjs +1 -1
  169. package/templates/_agents/scripts/verify.mjs +135 -58
  170. package/templates/_agents/scripts/verify.test.mjs +156 -47
  171. package/templates/_agents/scripts/wf-journal.mjs +8 -4
  172. package/templates/_agents/scripts/wf-journal.test.mjs +1 -1
  173. package/templates/_agents/scripts/wiki-search.test.mjs +5 -6
  174. package/templates/_agents/scripts/workflow-board-server.test.mjs +2 -2
  175. package/templates/_agents/scripts/workflow-enums.test.mjs +14 -5
  176. package/templates/_agents/scripts/workflows-check.mjs +1 -1
  177. package/templates/_agents/scripts-test-exempt.txt +1 -0
  178. package/templates/_agents/skills/papercut-fix/SKILL.md +60 -0
  179. package/templates/_agents/skills/papercut-fix/references/response-template.md +81 -0
  180. package/templates/_agents/workflow-enums.txt +8 -1
  181. package/templates/_agents/workflows/pipeline-closing.md +4 -5
  182. package/templates/_githooks/pre-commit +109 -120
  183. package/templates/_githooks/pre-merge-commit +27 -23
  184. package/templates/_githooks/pre-push +7 -6
  185. package/templates/_github/workflows/kit-ci.yml +4 -3
  186. package/templates/workflow/README.md +23 -5
  187. package/templates/workflow/incidents/_TEMPLATE.md +4 -1
  188. package/templates/workflow/intents/_TEMPLATE.md +4 -2
  189. package/templates/workflow/papercuts.md +2 -0
  190. package/templates/workflow/plans/_TEMPLATE.md +10 -5
  191. package/templates/workflow/regression-checklist.md +1 -0
  192. package/templates/workflow/specs/_TEMPLATE.md +17 -1
  193. package/modules/hosts/claude/commands/wf-source-sync-check.md +0 -89
  194. package/modules/hosts/codex/commands/wf-source-sync-check.md +0 -89
  195. package/modules/hosts/cursor/commands/wf-source-sync-check.md +0 -89
  196. package/modules/hosts/opencode/commands/wf-source-sync-check.md +0 -89
  197. package/modules/hosts/trae/commands/wf-source-sync-check.md +0 -90
  198. package/templates/_agents/commands/source-sync-check.md +0 -103
  199. package/templates/_agents/hooks/check-pairing-incremental.sh +0 -43
  200. package/templates/_agents/hooks/check-wiki-ledger.sh +0 -18
  201. package/templates/_agents/scripts/check-ledger.mjs +0 -109
  202. package/templates/_agents/scripts/check-ledger.test.mjs +0 -199
  203. package/templates/_agents/scripts/rule-budget.sh +0 -74
  204. package/templates/_agents/scripts/source-sync-check.mjs +0 -156
  205. package/templates/_agents/scripts/source-sync-check.test.mjs +0 -286
  206. package/templates/_agents/workflows/source-sync-repair.md +0 -30
@@ -0,0 +1,281 @@
1
+ #!/usr/bin/env node
2
+ // level-criteria.test.mjs — 级别判据机器单源套件(2026-10-13 flow-kit-route)
3
+ // 覆盖:档位闭集 / rankOf·maxLevel 边界 / hints 解析(注释·空行·CRLF·分隔)/ 非法 hints fail-loud /
4
+ // 路径下限与多命中取高 / assessLevel 就高不就低与 underHint / hints 缺失·为空两态 /
5
+ // routeAdvice 分档输出面(负例·触达面·结构面·未配置提示)。
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { fileURLToPath, pathToFileURL } from 'node:url';
10
+
11
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
12
+ const C = await import(pathToFileURL(path.join(SCRIPT_DIR, 'level-criteria.mjs')).href);
13
+
14
+ let pass = 0, fail = 0;
15
+ const check = (name, ok, detail = '') => {
16
+ if (ok) { pass++; console.log(` ok ${name}`); }
17
+ else { fail++; console.error(`FAIL ${name}${detail ? ' — ' + detail : ''}`); }
18
+ };
19
+
20
+ const mkRoot = () => fs.mkdtempSync(path.join(os.tmpdir(), 'level-criteria-'));
21
+ const wHints = (root, text) => {
22
+ const p = path.join(root, ...C.HINTS_REL.split('/'));
23
+ fs.mkdirSync(path.dirname(p), { recursive: true });
24
+ fs.writeFileSync(p, text, 'utf8');
25
+ };
26
+ const throwsCode = (fn, code) => {
27
+ try { fn(); return ''; } catch (e) { return e.code === code ? 'ok' : `code=${e.code}`; }
28
+ };
29
+
30
+ // ============ 场景 1:档位闭集与排序原语 ============
31
+ {
32
+ check('S1 档位闭集为 L1-L3(L0 已退役)', JSON.stringify(C.LEVELS) === JSON.stringify(['L1', 'L2', 'L3']));
33
+ check('S1 rankOf 大小写不敏感', C.rankOf('l2') === 1 && C.rankOf('L3') === 2);
34
+ check('S1 rankOf 非法档返回 null(含已退役的 L0)', C.rankOf('L9') === null && C.rankOf('L0') === null && C.rankOf('') === null && C.rankOf(undefined) === null);
35
+ check('S1 maxLevel 取高', C.maxLevel('L1', 'L3') === 'L3' && C.maxLevel('L3', 'L1') === 'L3');
36
+ check('S1 maxLevel 同档幂等', C.maxLevel('L2', 'L2') === 'L2');
37
+ check('S1 maxLevel 单边非法时取合法侧', C.maxLevel('L9', 'L2') === 'L2' && C.maxLevel('L1', 'L9') === 'L1');
38
+ check('S1 maxLevel 双边非法返回空串(不假装判得出)', C.maxLevel('L9', 'X') === '');
39
+ check('S1 maxLevel 小写输入归一为大写', C.maxLevel('l1', 'l2') === 'L2');
40
+ }
41
+
42
+ // ============ 场景 2:hints 解析 —— 注释/空行/CRLF/档位前缀 ============
43
+ {
44
+ const txt = [
45
+ '# 注释行',
46
+ '',
47
+ ' ',
48
+ 'L2 ^src/contracts/',
49
+ 'L3 ^src/db/migrations/.*\\.sql$',
50
+ '\tL2 \uFEFFsrc/gen/', // 前导空白 + BOM 残留(Windows 编辑器常见)
51
+ ].join('\r\n');
52
+ const hints = C.parseLevelHints(txt);
53
+ check('S2 解析出 3 条(注释/空行不计)', hints.length === 3, `实得 ${hints.length}`);
54
+ check('S2 首条档位与正则正确', hints[0].level === 'L2' && hints[0].re === '^src/contracts/');
55
+ check('S2 CRLF 不污染正则', hints[1].rx.test('src/db/migrations/001_init.sql'));
56
+ check('S2 空白/BOM 前缀被 trim 掉', hints[2].rx.test('src/gen/x.ts'), `re=${hints[2].re}`);
57
+ check('S2 无命中项不抛', C.parseLevelHints('# 全是注释\n\n').length === 0);
58
+ }
59
+
60
+ // ============ 场景 3:非法 hints fail-loud(写错配置比没配置危险) ============
61
+ {
62
+ check('S3 非法档位行报 LEVEL_HINTS_INVALID', throwsCode(() => C.parseLevelHints('L9 ^src/'), 'LEVEL_HINTS_INVALID') === 'ok');
63
+ check('S3 缺正发行报 LEVEL_HINTS_INVALID', throwsCode(() => C.parseLevelHints('L2'), 'LEVEL_HINTS_INVALID') === 'ok');
64
+ check('S3 正则语法错报 LEVEL_HINTS_INVALID', throwsCode(() => C.parseLevelHints('L2 ^src/(['), 'LEVEL_HINTS_INVALID') === 'ok');
65
+ let msg = '';
66
+ try { C.parseLevelHints('L9 ^src/\nL2 ^ok/'); } catch (e) { msg = e.message; }
67
+ check('S3 报文含清单路径与行号定位', msg.includes(C.HINTS_REL) && msg.includes('第 1 行'), msg);
68
+ let msg2 = '';
69
+ try { C.parseLevelHints('L2 ^ok/\n\nL2 ^bad/([\nL9 x'); } catch (e) { msg2 = e.message; }
70
+ check('S3 一次聚合全部坏行行号', msg2.includes('第 3 行') && msg2.includes('第 4 行'), msg2);
71
+ check('S3 合法行不报错(只聚合坏行)', !msg2.includes('第 2 行'), msg2);
72
+
73
+ // 2026-10-13 executor-review 两条实测 Major 的回归锁:
74
+ // ① 行内 `#` 注释会被编进 ERE → 含 $ 锚点时该条永不命中且**无任何报错**,
75
+ // 路径下限与提交面兜底同时静默失效(原随包模板示例就是这个写法)。
76
+ let c1 = '';
77
+ try { C.parseLevelHints('L3 ^src/db/migrations/.*\\.sql$ # 迁移 SQL'); } catch (e) { c1 = e.message; }
78
+ check('S3 行内 # 注释 → fail-loud(否则该条永不命中且静默)',
79
+ c1.includes('LEVEL_HINTS_INVALID') || c1.includes('行内注释'), c1);
80
+ check('S3 行内注释报文点名该行并给出正确写法', c1.includes('第 1 行') && c1.includes('单独成行'), c1);
81
+ check('S3 转义 \\# 视为路径字符而非注释', C.parseLevelHints('L2 ^src/\\#old/').length === 1);
82
+ check('S3 路径里的 #(前非空白)不算注释', C.parseLevelHints('L2 ^src/#old/').length === 1);
83
+
84
+ // ② L0/L1 档被原实现接受,而提交面判低门会当真:`L0 ^docs/` 让纯文档提交被硬拦,
85
+ // 修法还让人「升级至 L0 及以上」(L0 即最低档)。只收 L2/L3。
86
+ let c2 = '';
87
+ try { C.parseLevelHints('L0 ^docs/'); } catch (e) { c2 = e.message; }
88
+ check('S3 L0 档 → fail-loud(本清单只登记 L2/L3 下限)', c2.includes('只能是 L2 或 L3'), c2);
89
+ check('S3 L1 档同样被拒', throwsCode(() => C.parseLevelHints('L1 ^src/'), 'LEVEL_HINTS_INVALID') === 'ok');
90
+ check('S3 拒绝低档时说明理由(登记低档只会让提交面门出手)', c2.includes('噪声') || c2.includes('不构成门槛'), c2);
91
+ }
92
+
93
+ // ============ 场景 4:路径下限与多命中取高 ============
94
+ {
95
+ const hints = C.parseLevelHints(['L2 ^src/contracts/', 'L3 ^src/db/migrations/.*\\.sql$', 'L2 ^docs/api\\.md$'].join('\n'));
96
+ check('S4 单命中取该档', C.hintLevelFor(hints, ['src/contracts/types.ts']).level === 'L2');
97
+ check('S4 多文件多档取最高', C.hintLevelFor(hints, ['src/contracts/a.ts', 'src/db/migrations/002_x.sql']).level === 'L3');
98
+ check('S4 未命中返回空串', C.hintLevelFor(hints, ['src/ui/button.tsx']).level === '');
99
+ check('S4 反斜杠路径归一为 /', C.hintLevelFor(hints, ['src\\contracts\\a.ts']).level === 'L2');
100
+ check('S4 ./ 前缀剥离', C.hintLevelFor(hints, ['./src/contracts/a.ts']).level === 'L2');
101
+ // ERE 按「仓库相对路径」语义写,`^` 锚定即不命中绝对路径 —— 这是契约不是漏洞:
102
+ // 命中绝对路径说明清单写成了无锚 ERE,容易误伤同名目录。
103
+ check('S4 `^` 锚定规则不命中绝对路径(清单契约=仓库相对路径)',
104
+ C.hintLevelFor(hints, ['C:/repo/src/db/migrations/003_y.sql']).level === '');
105
+ check('S4 无锚 ERE 命中子串(清单作者自选口径)',
106
+ C.hintLevelFor(C.parseLevelHints('L3 migrations/.*\\.sql$'), ['C:/repo/src/db/migrations/003_y.sql']).level === 'L3');
107
+ const m = C.hintLevelFor(hints, ['src/contracts/a.ts', 'src/db/migrations/002_x.sql']);
108
+ check('S4 matched 给出命中证据', m.matched.length === 2 && m.matched.some((s) => s.includes('src/contracts/a.ts')), JSON.stringify(m.matched));
109
+ check('S4 matched 去重', new Set(m.matched).size === m.matched.length);
110
+ check('S4 空输入不抛', C.hintLevelFor(hints, []).level === '' && C.hintLevelFor([], ['a']).level === '');
111
+ }
112
+
113
+ // ============ 场景 5:assessLevel —— 就高不就低 / underHint / 两态配置 ============
114
+ {
115
+ const r = mkRoot();
116
+ wHints(r, 'L3 ^src/db/migrations/.*\\.sql$\n');
117
+ const noProposed = C.assessLevel(r, { files: ['src/db/migrations/001_init.sql'] });
118
+ check('S5 无建议档时按 hint 抬档', noProposed.level === 'L3' && noProposed.hintLevel === 'L3');
119
+ check('S5 无建议档不算 underHint(无档可低)', noProposed.underHint === false);
120
+ check('S5 hintsConfigured=true', noProposed.hintsConfigured === true);
121
+
122
+ const up = C.assessLevel(r, { files: ['src/db/migrations/001_init.sql'], proposed: 'L3' });
123
+ check('S5 建议档≥下限 → 不提示', up.underHint === false && up.level === 'L3');
124
+
125
+ const down = C.assessLevel(r, { files: ['src/db/migrations/001_init.sql'], proposed: 'L1' });
126
+ check('S5 建议档低于下限 → underHint=true', down.underHint === true);
127
+ check('S5 就高不就低:仍取 L3(建议档只作提示不压档)', down.level === 'L3');
128
+ check('S5 proposed 归一为大写回显', down.proposed === 'L1');
129
+
130
+ const noHit = C.assessLevel(r, { files: ['src/ui/button.tsx'], proposed: 'L2' });
131
+ check('S5 无命中时尊重建议档', noHit.level === 'L2' && noHit.hintLevel === '' && noHit.underHint === false);
132
+ }
133
+
134
+ // ============ 场景 6:hints 缺失 vs 为空 —— 两态都算「未配置」但不崩 ============
135
+ {
136
+ const missing = C.assessLevel(mkRoot(), { files: ['src/a.ts'] });
137
+ check('S6 文件缺失 → hintsConfigured=false', missing.hintsConfigured === false);
138
+ check('S6 文件缺失 → 按 L1 起步(不假装判得出)', missing.level === '' && missing.hintLevel === '');
139
+
140
+ const empty = mkRoot();
141
+ wHints(empty, '# 只有注释\n');
142
+ const e = C.assessLevel(empty, { files: ['src/a.ts'], proposed: 'L2' });
143
+ check('S6 清单存在但为空 → hintsConfigured=false', e.hintsConfigured === false);
144
+ check('S6 空清单不压建议档', e.level === 'L2');
145
+ }
146
+
147
+ // ============ 场景 7:routeAdvice 分档输出面 ============
148
+ {
149
+ const l1 = C.routeAdvice('L1', { hintsConfigured: true });
150
+ check('S7 L1 输出负例清单', C.L1_NEGATIVES.every((s) => l1.includes(s)));
151
+ check('S7 L1 不输出 L2/L3 触达面', !l1.includes('共享契约') && !l1.includes('迁移 SQL'));
152
+ check('S7 一律输出自查句', l1.includes(C.SELF_CHECK));
153
+ check('S7 一律声明机器边界(语义面不由机器裁决)', l1.includes('语义'));
154
+
155
+ const l2 = C.routeAdvice('L2', { hintsConfigured: true });
156
+ check('S7 L2 输出四类触达面', C.L2_SURFACES.every((s) => l2.includes(s)));
157
+ check('S7 L2 不输出 L3 结构面', !l2.includes('DI 链:注册'));
158
+ check('S7 L2 不输出负例面', !l2.includes('【负例'));
159
+
160
+ const l3 = C.routeAdvice('L3', { hintsConfigured: true });
161
+ check('S7 L3 同时输出 L2 触达面与 L3 结构面', C.L2_SURFACES.every((s) => l3.includes(s)) && C.L3_SURFACES.every((s) => l3.includes(s)));
162
+
163
+ // L0 已退役(2026-10-13 自源仓收敛系列 1 回灌):不在 LEVELS 闭集 → routeAdvice 只报「未知档」,
164
+ // 不再有语义行、也不产生下溢(下溢概念随 L0 退役消失)。
165
+ const l0 = C.routeAdvice('L0', { hintsConfigured: true });
166
+ check('S7 已退役的 L0 → 无语义行(未知档),不产生下溢', l0.includes('建议级别:L0 —— (未知档)') && !l0.includes('【负例'));
167
+
168
+ // 2026-10-13 executor-review:hints 未配置 / 低于下限两条**状态**信息已移出 routeAdvice,
169
+ // 改由 route 的降级块(executor.mjs degradations)单一输出——原实现把同一事实在文本面打两遍。
170
+ // 判据面留在本函数,状态面归降级块;此处锁死「不再重复」。
171
+ const un = C.routeAdvice('L1', { hintsConfigured: false });
172
+ check('S7 routeAdvice 不再输出 hints 状态(降级块唯一出口)',
173
+ !un.includes('未配置') && !un.includes('机器面给不出档位提示'), un);
174
+ const under = C.routeAdvice('L3', { hintsConfigured: true, underHint: true, proposed: 'L1', hintLevel: 'L3', matched: ['L3←src/db/migrations/001_init.sql'] });
175
+ check('S7 routeAdvice 不再输出 underHint 状态(降级块唯一出口)',
176
+ !under.includes('低于路径提示下限') && !under.includes('src/db/migrations/001_init.sql'), under);
177
+ check('S7 但判据面照常输出(自查句 + 机器边界声明)',
178
+ under.includes(C.SELF_CHECK) && under.includes('语义'), under);
179
+
180
+ check('S7 未知档位标注未知而非假装', C.routeAdvice('L9', {}).includes('(未知档)'));
181
+ check('S7 空档位回落 L1', C.routeAdvice('', {}).includes('建议级别:L1'));
182
+ }
183
+
184
+ // ============ 场景 8:单源纪律 —— md 不再承载判据散文 ============
185
+ {
186
+ const cmdPath = path.join(SCRIPT_DIR, '..', 'commands', 'new-task.md');
187
+ const md = fs.readFileSync(cmdPath, 'utf8');
188
+ check('S8 new-task.md 指向判据单源', md.includes('level-criteria.mjs'));
189
+ check('S8 new-task.md 登记 level-hints.txt', md.includes('.agents/level-hints.txt'));
190
+ check('S8 new-task.md 不再内嵌 L2 四类触达面散文', !md.includes('聚合投影键'));
191
+ check('S8 new-task.md 不再内嵌判低负例散文', !md.includes('单模块内部重构且无对外契约变化'));
192
+ check('S8 new-task.md 不再内嵌自查句原文', !md.includes('能否列出 ≥2 个受影响的消费方'));
193
+
194
+ // 过期指针回归:§级别判断 已改为「引用单源」,别处再指向它就是指向一个不再承载判据的章节。
195
+ // (改写该节时同步发现 design.md 与 fill-intent.mjs 两处指针,测试锁死免得日后再长出来。)
196
+ const design = fs.readFileSync(path.join(SCRIPT_DIR, '..', 'commands', 'design.md'), 'utf8');
197
+ const fillIntent = fs.readFileSync(path.join(SCRIPT_DIR, 'fill-intent.mjs'), 'utf8');
198
+ const cmdsDir = path.join(SCRIPT_DIR, '..', 'commands');
199
+ const cmdHits = fs.readdirSync(cmdsDir)
200
+ .filter((f) => f.endsWith('.md'))
201
+ .filter((f) => fs.readFileSync(path.join(cmdsDir, f), 'utf8').match(/§\s*级别判断|new-task\.md[^))]*级别判断/));
202
+ check('S8 无 md 指令指向已改写的 §级别判断', cmdHits.length === 0, cmdHits.join(','));
203
+ check('S8 design.md 指向判据单源', design.includes('level-criteria.mjs'));
204
+ check('S8 fill-intent 不再指向 §级别判断', !fillIntent.includes('§级别判断'));
205
+
206
+ // 面扩到 md 之外:首轮改写漏掉的三处(pipeline-run 工单 / check-lane-surface 门禁报文 /
207
+ // lane-surfaces.txt 头注释)都在 .mjs 与 .txt 里,只扫 commands/*.mjs 会漏。扫描整个装户面。
208
+ const AGENTS_DIR = path.join(SCRIPT_DIR, '..');
209
+ const stale = [];
210
+ const walk = (dir) => {
211
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
212
+ if (e.name === 'scripts' && dir !== AGENTS_DIR) continue; // 避免重复扫
213
+ const abs = path.join(dir, e.name);
214
+ if (e.isDirectory()) { if (e.name !== 'node_modules' && e.name !== 'cache') walk(abs); continue; }
215
+ if (!/\.(mjs|txt|md|json)$/.test(e.name)) continue;
216
+ if (e.name.endsWith('.test.mjs')) continue; // 含本文件(顶层 await 环境下不能用 __filename)
217
+ const t = fs.readFileSync(abs, 'utf8');
218
+ if (/new-task\.md[^\n]{0,40}§\s*级别判断/.test(t)) stale.push(path.relative(AGENTS_DIR, abs).replace(/\\/g, '/'));
219
+ }
220
+ };
221
+ walk(AGENTS_DIR);
222
+ check('S8 装户面无指向 §级别判断的过期指针', stale.length === 0, stale.join(','));
223
+
224
+ // 判据**散文本身**的回归(2026-10-13 executor-review):原护栏只锁 new-task.md 里 3 个字面串,
225
+ // 于是判据换了措辞就能在别处重新长回来——实测 design.md 与 fill-intent.mjs 各存了一份
226
+ // (后者把 L2 列成五项,单源是四项)。故改为**按单源词条扫判级面**:单源与本测试之外,
227
+ // 凡列举/复述判据闭集的文件即判红。
228
+ // 扫描面 = 会指导判级的两类件(命令 md + 起草工具脚本);不含 level-hints.txt——那是路径
229
+ // 示例文件,其注释里提到「迁移 SQL」「DI 链」是路径举例,不是判据复述。
230
+ const CRITERIA_TERMS = ['编码权威', '共享契约', '既有接口语义', '全局横切口径', 'DI 链', '认证与中间件管线'];
231
+ const ALLOW = new Set(['level-criteria.mjs', 'level-criteria.test.mjs']);
232
+ const proseHits = [];
233
+ const scanProse = (dir) => {
234
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
235
+ const abs = path.join(dir, e.name);
236
+ const rel = path.relative(AGENTS_DIR, abs).replace(/\\/g, '/');
237
+ if (e.isDirectory()) { if (e.name !== 'node_modules' && e.name !== 'cache') scanProse(abs); continue; }
238
+ if (!/\.(mjs|md)$/.test(e.name)) continue;
239
+ if (e.name.endsWith('.test.mjs') || ALLOW.has(e.name)) continue;
240
+ // 只扫判级面:命令 md + 起草工具(fill-*)。其余脚本引用单源属正常。
241
+ if (!/^commands\//.test(rel) && !/^scripts\/fill-/.test(rel)) continue;
242
+ const t = fs.readFileSync(abs, 'utf8');
243
+ const hit = CRITERIA_TERMS.filter((w) => t.includes(w));
244
+ if (hit.length) proseHits.push(`${rel} → ${hit.join('/')}`);
245
+ }
246
+ };
247
+ scanProse(AGENTS_DIR);
248
+ check('S8 判级面不复述判据闭集(词条只在单源一处)', proseHits.length === 0, proseHits.join(' | '));
249
+ check('S8 design.md 只留流程映射 + 指向单源',
250
+ design.includes('L2_SURFACES') && design.includes('L3_SURFACES') && proseHits.every((h) => !h.startsWith('commands/design.md')),
251
+ proseHits.filter((h) => h.startsWith('commands/design.md')).join('|'));
252
+ check('S8 fill-intent 触达红线改为对照单源(不再自带五项/四项清单)',
253
+ fillIntent.includes('L2_SURFACES') && fillIntent.includes('L3_SURFACES'), '');
254
+
255
+ // B3:route 曾是「可绕旁路」——装户面唯一提及只是 §4 里一行 bash 提示,无任何必跑语义,
256
+ // 而机器又无法发现它没被调用(定级早于改动面确定,无可比对的记录面,见 ARCHITECTURE §5)。
257
+ // 故把接线强度锁在**流程文本**上:必跑标记 + 不给 --files 拿不到下限 + 跳过须写理由 + 兜底门。
258
+ const nt = fs.readFileSync(path.join(SCRIPT_DIR, '..', 'commands', 'new-task.md'), 'utf8');
259
+ check('S8 new-task.md 定级第 0 步标注必跑 route(非可选提示)', /必跑/.test(nt) && /flow-kit route --files/.test(nt));
260
+ check('S8 new-task.md 说明不给 --files 就拿不到路径下限', nt.includes('不给 `--files` 就拿不到路径下限'));
261
+ check('S8 new-task.md 要求跳过时写明理由', nt.includes('写明理由'));
262
+ check('S8 new-task.md 点名提交面兜底门', nt.includes('check-lane-surface'));
263
+ check('S8 new-task.md 声明机器无法发现跳过(诚实边界)', nt.includes('无法**发现'));
264
+ }
265
+
266
+ // ============ 场景 9:装户模板面 —— level-hints.txt 随模板下发且默认为空 ============
267
+ {
268
+ const tpl = path.join(SCRIPT_DIR, '..', 'level-hints.txt');
269
+ const ok = fs.existsSync(tpl);
270
+ check('S9 模板带 level-hints.txt', ok);
271
+ if (ok) {
272
+ const txt = fs.readFileSync(tpl, 'utf8');
273
+ check('S9 默认无有效条目(内容项目私有)', C.parseLevelHints(txt).length === 0);
274
+ check('S9 说明格式为 `<档位> <ERE>`', txt.includes('`<档位> <ERE>`'), txt.slice(0, 200));
275
+ check('S9 含被注释掉的 L2/L3 示例行', /#\s*L3\s+\^/.test(txt) && /#\s*L2\s+\^/.test(txt));
276
+ check('S9 说明默认留空与项目私有立场', txt.includes('默认留空') && txt.includes('项目私有'));
277
+ }
278
+ }
279
+
280
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
281
+ process.exit(fail ? 1 : 0);
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ // papercut-debt-controller.mjs — 回路控制器(2026-10-07 papercut-debt 回路 Phase D;B10a 自
3
+ // Shipyard.Material 回灌——排序键与 scope gate 逐字对齐,出处沿革留痕在包源仓 git 历史与 CHANGELOG)
4
+ // 用途:把传感器的测量变成"本轮修哪一条"。确定性选取,无随机、无 LLM 判断。
5
+ // 口径单源:.agents/loop/papercut-debt/DESIGN.md §3(改排序键先改设计文件)。
6
+ // 排序键(依次比较):① 命中次数降序(台账文件头自身规则:第 2 次命中须升级为 docs 修复)
7
+ // ② 日期降序(近期痛点更可能仍成立) ③ 原行序升序(完全平手时保可复现)
8
+ // Scope gate:拟修明确指向 backend/ frontend/ tests/ wiki/ sqlcmd/ db_tool,或含 数据库/DDL/DROP 字样的条目本轮跳过
9
+ // (执行器边界=文档面,见 DESIGN §4)。跳过的条目照列,供人工决定是否单独授权。
10
+ // 单轮只取 1 条:独立复核有成本(L2/L3 须独立复核),一次给 3 条审不动。
11
+ // 用法:node .agents/scripts/papercut-debt-controller.mjs [--json] [--top <n>] [--root <仓库根>]
12
+ // 退出码:0 正常(含"无目标"与"全部被 scope gate 拦");2 = 测量不可用(debt 未知)。
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
18
+ const argv = process.argv.slice(2);
19
+ const argOf = (k) => { const i = argv.indexOf(k); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : ''; };
20
+ const asJson = argv.includes('--json');
21
+ const TOP = Math.max(1, Number(argOf('--top')) || 3);
22
+ const ROOT = path.resolve(argOf('--root') || path.join(SCRIPT_DIR, '..', '..'));
23
+
24
+ // Scope gate 关键词:命中即视为「要改源码 / 连库」,超出文档面授权
25
+ const OUT_OF_SCOPE = /(^|[^\w/])(backend|frontend|tests|wiki)\/|sqlcmd|db_tool\.py|数据库|DDL|\bDROP\b/i;
26
+ // 「同上 X」承接上一行的路径,无法独立判定存在性——需人读后定性,不作为自动目标
27
+ const STALE_RE = /^同上两处|^同上/;
28
+
29
+ // 口径单源在传感器(settled = ✅ 或 ⏸带理由;stale 判定也在传感器)。
30
+ // 本脚本刻意**不复制**任何判定正则,而是 import 传感器复用其扫描面——两处各判一次必然漂移
31
+ // (首轮实证:传感器按 ✅ 判债、控制器也按 ✅ 选目标,⏸ 暂缓条目被反复选中导致空转——
32
+ // 留痕在包源仓 git 历史与 CHANGELOG)。
33
+ // 传感器 main() 仅在直接执行时运行,import 时只导出 scan()。
34
+ import { scan as sensorScan } from './papercut-debt-sensor.mjs';
35
+
36
+ function measure() {
37
+ const ledger = path.join(ROOT, 'workflow', 'papercuts.md');
38
+ if (!fs.existsSync(ledger)) throw new Error(`台账不存在: ${ledger}`);
39
+ // 复用传感器解析面(含 ✅ / ⏸带理由 / 命中次数 / stale 判定),此处只做排序与 scope gate。
40
+ const rows = sensorScan(ROOT).map((r) => ({
41
+ ...r,
42
+ // scope gate 需读全文:location + proposedFix(拟修列可能含管道符,传感器已并回)
43
+ fullText: `${r.location} ${r.proposedFix}`,
44
+ }));
45
+ return { ledger, rows };
46
+ }
47
+
48
+ function rank(a, b) {
49
+ if (a.hits !== b.hits) return b.hits - a.hits;
50
+ if (a.date !== b.date) return a.date < b.date ? 1 : -1; // 日期降序
51
+ return a.line - b.line; // 原行序升序
52
+ }
53
+
54
+ function gateReason(r) {
55
+ const m = r.fullText.match(OUT_OF_SCOPE);
56
+ if (!m) return null;
57
+ return `拟修触及源码/数据库面(命中「${(m[2] || m[1] || m[0]).trim()}」)`;
58
+ }
59
+
60
+ function main() {
61
+ let m;
62
+ try {
63
+ m = measure();
64
+ } catch (e) {
65
+ console.error(`[ERROR] papercut-debt-controller: 测量不可用——${e.message}`);
66
+ if (asJson) console.log(JSON.stringify({ error: e.message, target: null }, null, 2));
67
+ process.exit(2);
68
+ }
69
+
70
+ const unresolved = m.rows.filter((r) => !r.settled);
71
+ const debt = unresolved.length;
72
+ const inScope = [];
73
+ const skipped = [];
74
+ const needsHuman = [];
75
+ for (const r of unresolved) {
76
+ const why = gateReason(r);
77
+ if (why) { skipped.push({ ...r, skipReason: why }); continue; }
78
+ // stale 与「同上 X」承接行:需要人读后定性(已失效 / 或需重定位),不作为自动修复目标
79
+ const isStale = r.stale;
80
+ const isDangling = STALE_RE.test(r.location);
81
+ if (isStale || isDangling) {
82
+ needsHuman.push({ ...r, needReason: isStale ? '位置列路径已不存在,需确认是否已失效' : '「同上」承接行,路径需从上条解析后才能判定' });
83
+ continue;
84
+ }
85
+ inScope.push(r);
86
+ }
87
+ inScope.sort(rank);
88
+ const target = inScope[0] || null;
89
+ const queue = inScope.slice(0, TOP);
90
+
91
+ if (asJson) {
92
+ console.log(JSON.stringify({
93
+ ledger: path.relative(ROOT, m.ledger).split(path.sep).join('/'),
94
+ debt, total: m.rows.length,
95
+ target, queue,
96
+ skippedOutOfScope: skipped,
97
+ needsHuman,
98
+ achieved: debt === 0,
99
+ }, null, 2));
100
+ return;
101
+ }
102
+
103
+ console.log(`控制器 —— debt ${debt} · 在选 ${inScope.length} · scope-gate 拦下 ${skipped.length} · 待人工定性 ${needsHuman.length}`);
104
+ if (debt === 0) {
105
+ console.log('✅ debt = 0,设定点已达成——本轮无目标,执行器不应动手。');
106
+ return;
107
+ }
108
+ if (!target) {
109
+ console.log('⚠️ 所有未升级条目均被 scope gate 拦下(执行器边界=文档面)——需人工决定是否单独授权。');
110
+ for (const s of skipped) console.log(` L${s.line} [${s.date}] ${s.location} —— ${s.skipReason}`);
111
+ return;
112
+ }
113
+
114
+ console.log('');
115
+ console.log('本轮目标(top-1):');
116
+ console.log(` 行号 L${target.line}(workflow/papercuts.md)`);
117
+ console.log(` 日期 ${target.date}`);
118
+ console.log(` 命中次数 ${target.hits}`);
119
+ console.log(` 位置 ${target.location}`);
120
+ console.log(` 症状 ${target.symptom.slice(0, 200)}${target.symptom.length > 200 ? '…' : ''}`);
121
+ console.log(` 拟修 ${target.proposedFix.slice(0, 300)}${target.proposedFix.length > 300 ? '…' : ''}`);
122
+ console.log('');
123
+ console.log(`后续候选(本轮不做,供你改指令时直接点名):`);
124
+ for (const q of queue.slice(1)) {
125
+ console.log(` L${q.line} [${q.date}] hits=${q.hits} ${q.location}`);
126
+ }
127
+ if (skipped.length) {
128
+ console.log('');
129
+ console.log('已按 scope gate 跳过(要改源码/连库,需你单独授权):');
130
+ for (const s of skipped) console.log(` L${s.line} [${s.date}] ${s.location} —— ${s.skipReason}`);
131
+ }
132
+ if (needsHuman.length) {
133
+ console.log('');
134
+ console.log('待人工定性(不作为自动目标——请读后定性:判失效则标 ⏸ 暂缓+理由,判仍成立则补出可执行拟修):');
135
+ for (const h of needsHuman) console.log(` L${h.line} [${h.date}] ${h.location} —— ${h.needReason}`);
136
+ }
137
+ console.log('');
138
+ console.log('下一步:按 .agents/skills/papercut-fix/SKILL.md 执行(改完跑验证门,debt 应恰好 -1)。');
139
+ }
140
+
141
+ main();
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ // papercut-debt-controller.test.mjs — 回路控制器 fixture(B10a 自源仓回灌随件测试;源仓无直测,
3
+ // kit 侧按「新增引擎脚本默认必须带同名 .test.mjs」立——排序键与 scope gate 防再同步漂移)
4
+ // 覆盖:排序键三序(命中次数降序 > 日期降序 > 原行序升序)/ scope gate(源码/数据库面拦下照列)/
5
+ // stale 与「同上」承接行转待人工定性 / debt=0 达成态 / 全员被拦警示态 / 口径单源(不复制判定正则)/
6
+ // CLI 退出码(0 正常含无目标;2 测量不可用)与 --json 形状
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { spawnSync } from 'node:child_process';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
14
+ const CLI = path.join(SCRIPT_DIR, 'papercut-debt-controller.mjs');
15
+
16
+ let pass = 0;
17
+ let fail = 0;
18
+ const check = (desc, cond, detail = '') => {
19
+ if (cond) { pass++; console.log(`PASS ${desc}`); }
20
+ else { fail++; console.log(`FAIL ${desc}${detail ? '\n ' + detail : ''}`); }
21
+ };
22
+ const mkroot = () => {
23
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'papercut-ctrl-'));
24
+ // 位置列引用的文档面文件须真实存在——否则传感器 stale 判定(位置列路径全不存在)会把
25
+ // 条目转 needsHuman,排序键 / scope gate 场景测不到目标选取
26
+ for (const p of ['.agents/commands', '.agents/scripts']) {
27
+ fs.mkdirSync(path.join(root, ...p.split('/')), { recursive: true });
28
+ }
29
+ fs.writeFileSync(path.join(root, '.agents', 'commands', 'a.md'), '# a\n', 'utf8');
30
+ fs.writeFileSync(path.join(root, '.agents', 'commands', 'b.md'), '# b\n', 'utf8');
31
+ fs.writeFileSync(path.join(root, '.agents', 'commands', 'c.md'), '# c\n', 'utf8');
32
+ fs.mkdirSync(path.join(root, 'workflow'), { recursive: true });
33
+ return root;
34
+ };
35
+ const HEADER = '| 日期 | 位置 | 症状 | 拟修 |\n|------|------|------|------|\n';
36
+ const writeLedger = (root, text) => fs.writeFileSync(path.join(root, 'workflow', 'papercuts.md'), text, 'utf8');
37
+ const run = (root, ...extra) => spawnSync(process.execPath, [CLI, '--root', root, ...extra], { encoding: 'utf8' });
38
+
39
+ // ---- S1 排序键:命中次数降序 > 日期降序 > 原行序升序 ----
40
+ {
41
+ const root = mkroot();
42
+ writeLedger(root, HEADER
43
+ + '| 2026-10-01 | `.agents/commands/a.md` | 症状早 | 拟修早 |\n' // 行序 1,日期早,hits=1
44
+ + '| 2026-10-05 | `.agents/commands/b.md` | 症状新 | 拟修新 |\n' // 行序 2,日期新,hits=1 → 日期降序胜
45
+ + '| 2026-10-03 | `.agents/commands/c.md` | 第 2 次命中:症状复发 | 拟修复发 |\n'); // hits=2 → 恒胜
46
+ const r = run(root, '--json');
47
+ const j = JSON.parse(r.stdout || '{}');
48
+ check('S1a exit 0', r.status === 0, `status=${r.status} stderr=${String(r.stderr).slice(0, 120)}`);
49
+ check('S1b 主排序键 = 命中次数降序', j.target?.location === '`.agents/commands/c.md`', JSON.stringify(j.target?.location));
50
+ check('S1c 平手时日期降序(近期优先)', j.queue?.[1]?.location === '`.agents/commands/b.md`', JSON.stringify(j.queue?.map((q) => q.location)));
51
+ check('S1d 再平手原行序升序', j.queue?.[2]?.location === '`.agents/commands/a.md`');
52
+ fs.rmSync(root, { recursive: true, force: true });
53
+ }
54
+
55
+ // ---- S2 scope gate:拟修触及源码/数据库面 → 拦下照列(不选为目标)----
56
+ {
57
+ const root = mkroot();
58
+ writeLedger(root, HEADER
59
+ + '| 2026-10-01 | `.agents/commands/a.md` | 文档面症状 | 补一段说明 |\n'
60
+ + '| 2026-10-02 | `backend/api/x.py` | 源码面症状 | 修校验逻辑 |\n'
61
+ + '| 2026-10-03 | `tests/` | 测试面症状 | 补用例(涉及 DDL) |\n');
62
+ const r = run(root, '--json');
63
+ const j = JSON.parse(r.stdout || '{}');
64
+ check('S2a exit 0(含全部被拦态)', r.status === 0);
65
+ check('S2b scope gate 拦下 2 条(backend/ + tests/DDL)', j.skippedOutOfScope?.length === 2, JSON.stringify(j.skippedOutOfScope?.map((s) => s.location)));
66
+ check('S2c 在选目标只含文档面条目', j.target?.location === '`.agents/commands/a.md`', JSON.stringify(j.target?.location));
67
+ fs.rmSync(root, { recursive: true, force: true });
68
+ }
69
+
70
+ // ---- S3 stale 与「同上」承接行 → needsHuman(不作为自动目标)----
71
+ {
72
+ const root = mkroot();
73
+ writeLedger(root, HEADER
74
+ + '| 2026-10-01 | `.agents/commands/a.md` | 文档面症状甲 | 拟修甲 |\n'
75
+ + '| 2026-10-02 | `.agents/scripts/gone.mjs` | 路径已不存在 | 定性失效 |\n'
76
+ + '| 2026-10-03 | 同上两处 | 承接上行 | 定性重定位 |\n');
77
+ const r = run(root, '--json');
78
+ const j = JSON.parse(r.stdout || '{}');
79
+ check('S3a stale 与「同上」行转待人工', j.needsHuman?.length === 2, JSON.stringify(j.needsHuman?.map((h) => h.location)));
80
+ check('S3b 待人工条目不进 queue', !j.queue?.some((q) => q.location === '.agents/scripts/gone.mjs' || q.location === '同上两处'));
81
+ check('S3c 文档面条目照常在选', j.target?.location === '`.agents/commands/a.md`');
82
+ fs.rmSync(root, { recursive: true, force: true });
83
+ }
84
+
85
+ // ---- S4 debt = 0:achieved 态(本轮无目标)----
86
+ {
87
+ const root = mkroot();
88
+ writeLedger(root, HEADER
89
+ + '| 2026-10-01 | `.agents/commands/a.md` | 症状 | 拟修 ✅ 已升级修复(2026-10-02): 已改 |\n');
90
+ const r = run(root, '--json');
91
+ const j = JSON.parse(r.stdout || '{}');
92
+ check('S4a debt=0 → achieved true 且无目标', r.status === 0 && j.achieved === true && j.target === null && j.debt === 0,
93
+ JSON.stringify({ debt: j.debt, achieved: j.achieved }));
94
+ const r2 = run(root);
95
+ check('S4b 人读口径出「设定点已达成」', /设定点已达成/.test(r2.stdout));
96
+ fs.rmSync(root, { recursive: true, force: true });
97
+ }
98
+
99
+ // ---- S5 全员被 scope gate 拦 → 警示态、exit 0 ----
100
+ {
101
+ const root = mkroot();
102
+ writeLedger(root, HEADER + '| 2026-10-01 | `backend/api/x.py` | 源码面症状 | 修校验逻辑 |\n');
103
+ const r = run(root);
104
+ check('S5a 全员被拦:exit 0 + 需人工授权警示', r.status === 0 && /scope gate 拦下/.test(r.stdout) && /需人工决定是否单独授权/.test(r.stdout),
105
+ JSON.stringify({ status: r.status, stdout: String(r.stdout).slice(0, 200) }));
106
+ fs.rmSync(root, { recursive: true, force: true });
107
+ }
108
+
109
+ // ---- S6 测量不可用:exit 2(debt 未知)----
110
+ {
111
+ const root = mkroot();
112
+ const r = run(root, '--json');
113
+ const j = JSON.parse(r.stdout || '{}');
114
+ check('S6a 台账缺失 → exit 2 + error JSON', r.status === 2 && j.target === null && typeof j.error === 'string',
115
+ JSON.stringify({ status: r.status, error: j.error }));
116
+ fs.rmSync(root, { recursive: true, force: true });
117
+ }
118
+
119
+ // ---- S7 kit 适配钉子:口径单源(控制器不复制传感器判定正则)----
120
+ {
121
+ const src = fs.readFileSync(CLI, 'utf8');
122
+ check('S7a 控制器 import 传感器 scan(不复制判定面)', /import\s*\{\s*scan as sensorScan\s*\}\s*from\s*'\.\/papercut-debt-sensor\.mjs'/.test(src));
123
+ check('S7b 控制器不复制 ✅/⏸ 判定正则', !/'✅'|"✅"/.test(src.replace(/^\/\/.*$/gm, '')));
124
+ }
125
+
126
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
127
+ process.exit(fail ? 1 : 0);