@event4u/agent-config 15.0.0 → 16.1.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 (283) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +301 -347
  5. package/CONTRIBUTING.md +9 -0
  6. package/README.md +2 -2
  7. package/dist/agent-src/commands/analyze/inbox.md +26 -0
  8. package/dist/agent-src/commands/analyze/roadmap-repos.md +27 -0
  9. package/dist/agent-src/commands/challenge-me/closure.md +178 -0
  10. package/dist/agent-src/commands/challenge-me.md +9 -2
  11. package/dist/agent-src/commands/council/default.md +25 -5
  12. package/dist/agent-src/commands/feature/plan.md +28 -1
  13. package/dist/agent-src/commands/feature/roadmap.md +28 -1
  14. package/dist/agent-src/commands/implement-ticket.md +27 -0
  15. package/dist/agent-src/commands/jira-ticket.md +27 -0
  16. package/dist/agent-src/commands/pr/create.md +29 -0
  17. package/dist/agent-src/commands/pr/merge.md +60 -9
  18. package/dist/agent-src/commands/roadmap/ai-council.md +7 -1
  19. package/dist/agent-src/commands/roadmap/create.md +77 -29
  20. package/dist/agent-src/commands/roadmap/materialize.md +27 -0
  21. package/dist/agent-src/commands/roadmap/process-full.md +123 -18
  22. package/dist/agent-src/commands/team.md +22 -0
  23. package/dist/agent-src/contexts/authority/scope-growth-ownership.md +80 -0
  24. package/dist/agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +97 -0
  25. package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +44 -3
  26. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +15 -15
  27. package/dist/agent-src/contexts/execution/decision-closure-execution.md +93 -0
  28. package/dist/agent-src/contexts/execution/fix-loop-ladder.md +108 -0
  29. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +4 -2
  30. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +75 -6
  31. package/dist/agent-src/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
  32. package/dist/agent-src/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
  33. package/dist/agent-src/guidelines/design-fidelity-routing.md +29 -0
  34. package/dist/agent-src/rules/autonomous-execution.md +15 -8
  35. package/dist/agent-src/rules/delegation-policy.md +23 -45
  36. package/dist/agent-src/rules/design-fidelity.md +32 -37
  37. package/dist/agent-src/rules/evaluator-independence.md +43 -49
  38. package/dist/agent-src/rules/test-first.md +41 -0
  39. package/dist/agent-src/rules/user-interaction.md +31 -30
  40. package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +3 -3
  41. package/dist/agent-src/skills/ai-council/SKILL.md +11 -3
  42. package/dist/agent-src/skills/api-design/data/manifest.json +3 -3
  43. package/dist/agent-src/skills/database/data/manifest.json +4 -4
  44. package/dist/agent-src/skills/database/data/query-tuning.csv +3 -3
  45. package/dist/agent-src/skills/design-review/SKILL.md +50 -0
  46. package/dist/agent-src/skills/error-handling-patterns/SKILL.md +9 -0
  47. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +14 -4
  48. package/dist/agent-src/skills/experiment-loop/SKILL.md +31 -1
  49. package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
  50. package/dist/agent-src/skills/storybook-workshop/SKILL.md +42 -3
  51. package/dist/agent-src/skills/threat-modeling/SKILL.md +9 -4
  52. package/dist/agent-src/skills/threat-modeling/data/manifest.json +4 -4
  53. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +37 -3
  54. package/dist/agent-src/templates/agent-settings.md +1 -1
  55. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  56. package/dist/agent-src/templates/roadmaps.md +188 -0
  57. package/dist/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
  58. package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
  59. package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
  60. package/dist/cli-delegate/check_update_banner.js +40 -2
  61. package/dist/cli-delegate/chunk-4E2W63DA.js +335 -0
  62. package/dist/cli-delegate/chunk-5R7HOLAF.js +99 -0
  63. package/dist/cli-delegate/{chunk-IVBPRCQO.js → chunk-6KFEC3G7.js} +1 -1
  64. package/dist/cli-delegate/{chunk-2TAQ2K4V.js → chunk-6KM6ORRY.js} +55 -386
  65. package/dist/cli-delegate/{chunk-2XY7ILBT.js → chunk-FC75PGJE.js} +42 -2
  66. package/dist/cli-delegate/{chunk-GYC3VRYN.js → chunk-FXYUYU4N.js} +57 -13
  67. package/dist/cli-delegate/{chunk-55ACLDUE.js → chunk-GJCBB2UG.js} +168 -10
  68. package/dist/cli-delegate/{chunk-AK6HODNQ.js → chunk-HDVVGPQW.js} +873 -539
  69. package/dist/cli-delegate/chunk-NUHLI2RX.js +80 -0
  70. package/dist/cli-delegate/{chunk-HZ52BRGG.js → chunk-P6ZHP5W2.js} +15 -90
  71. package/dist/cli-delegate/{chunk-TYSY33MD.js → chunk-QJWAUMJW.js} +12 -2
  72. package/dist/cli-delegate/{chunk-57PN4GYJ.js → chunk-RGNIWTEA.js} +1 -1
  73. package/dist/cli-delegate/chunk-ROYYTEZU.js +39 -0
  74. package/dist/cli-delegate/{chunk-2KKXJW25.js → chunk-S3VFXMTM.js} +1 -1
  75. package/dist/cli-delegate/{chunk-3FKR64R5.js → chunk-UNCRJBT3.js} +31 -7
  76. package/dist/cli-delegate/{chunk-2DK2VJWG.js → chunk-WGXTVKCQ.js} +20 -6
  77. package/dist/cli-delegate/chunk-YDPILCOL.js +152 -0
  78. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  79. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  80. package/dist/cli-delegate/cmd_conformance.js +918 -325
  81. package/dist/cli-delegate/cmd_doctor.js +7 -5
  82. package/dist/cli-delegate/cmd_explain.js +5 -3
  83. package/dist/cli-delegate/cmd_export.js +9 -7
  84. package/dist/cli-delegate/cmd_fleet.js +4 -2
  85. package/dist/cli-delegate/cmd_handoff.js +6 -6
  86. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  87. package/dist/cli-delegate/cmd_migrate.js +2 -2
  88. package/dist/cli-delegate/cmd_packs_active.js +3 -2
  89. package/dist/cli-delegate/cmd_preflight.js +4 -2
  90. package/dist/cli-delegate/cmd_prune.js +10 -8
  91. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  92. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  93. package/dist/cli-delegate/cmd_session_recycle.js +4 -4
  94. package/dist/cli-delegate/cmd_settings_get.js +28 -2
  95. package/dist/cli-delegate/cmd_settings_migrate.js +9 -7
  96. package/dist/cli-delegate/cmd_settings_set.js +14 -1
  97. package/dist/cli-delegate/cmd_sync.js +10 -8
  98. package/dist/cli-delegate/cmd_uninstall.js +16 -13
  99. package/dist/cli-delegate/cmd_update.js +1 -1
  100. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  101. package/dist/cli-delegate/cmd_validate.js +12 -10
  102. package/dist/cli-delegate/cmd_versions.js +1 -1
  103. package/dist/discovery/deprecation-report.md +1 -1
  104. package/dist/discovery/discovery-manifest.json +87 -40
  105. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  106. package/dist/discovery/discovery-manifest.summary.md +4 -4
  107. package/dist/discovery/dormancy-report.md +1 -1
  108. package/dist/discovery/orphan-report.md +1 -1
  109. package/dist/discovery/packs.json +10 -8
  110. package/dist/discovery/trust-report.md +3 -3
  111. package/dist/discovery/workspaces.json +8 -6
  112. package/dist/hooks/dispatch.js +2601 -1073
  113. package/dist/install/claudeRuleRewrite.js +14 -3
  114. package/dist/install/conflict.js +60 -10
  115. package/dist/install/conflictTracker.js +218 -0
  116. package/dist/install/fsPrimitives.js +54 -0
  117. package/dist/install/install.mjs +2008 -1481
  118. package/dist/install/manifestFiles.js +259 -0
  119. package/dist/install/preflight.js +14 -2
  120. package/dist/install/preserve.js +157 -0
  121. package/dist/install/recordedOwnership.js +132 -0
  122. package/dist/install/txlog.js +11 -2
  123. package/dist/mcp/registry-manifest.json +3 -3
  124. package/dist/mcp/server.json +2 -2
  125. package/dist/mcp/server.mjs +1612 -667
  126. package/dist/router.json +1 -1
  127. package/dist/scripts/_lib/agent_settings.js +3 -2
  128. package/dist/scripts/_lib/fs_atomic.js +167 -0
  129. package/dist/scripts/_lib/installed_lock.js +1 -1
  130. package/dist/scripts/_lib/installed_tools.js +460 -0
  131. package/dist/scripts/_lib/lean_projection_mode.js +3 -2
  132. package/dist/scripts/_lib/settings_renamed_keys.js +78 -0
  133. package/dist/server/io/yamlIO.js +50 -0
  134. package/dist/server/routes/install.js +8 -0
  135. package/dist/server/routes/wizard.js +9 -3
  136. package/dist/server/schemas/settings.js +17 -5
  137. package/dist/shared/missionExecution.js +97 -0
  138. package/dist/ui/assets/{index-D0jaCRPM.js → index-DH1xc62v.js} +17 -17
  139. package/dist/ui/index.html +1 -1
  140. package/docs/MIGRATION.md +103 -0
  141. package/docs/contracts/settings-classes.md +26 -7
  142. package/docs/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
  143. package/docs/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
  144. package/docs/guidelines/design-fidelity-routing.md +29 -0
  145. package/package.json +1 -1
  146. package/src/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
  147. package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
  148. package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
  149. package/src/config/agent-settings.template.yml +139 -27
  150. package/src/config/auto-rule-budget.json +16 -0
  151. package/src/config/ci-local-parity.yml +18 -5
  152. package/src/config/continuity-surface.json +28 -0
  153. package/src/config/gate-coverage.yml +146 -13
  154. package/src/config/gate-violation-baselines.json +6 -6
  155. package/src/config/hook-token-budget.json +2 -0
  156. package/src/config/host-injection-effect.json +1 -1
  157. package/src/config/loop-surfaces.yaml +215 -0
  158. package/src/config/platform-anchor.json +3 -2
  159. package/src/config/preamble-payload-budget.json +12 -7
  160. package/src/config/preamble-payload-exceptions.json +28 -0
  161. package/src/config/release-gate-locality.yml +11 -0
  162. package/src/config/routing-coverage-seed.json +18 -4
  163. package/src/config/rule-enforcement-baseline.json +0 -1
  164. package/src/install/claudeRuleRewrite.ts +16 -2
  165. package/src/install/conflict.ts +65 -9
  166. package/src/install/conflictTracker.ts +299 -0
  167. package/src/install/fsPrimitives.ts +54 -0
  168. package/src/install/manifestFiles.ts +312 -0
  169. package/src/install/preflight.ts +16 -2
  170. package/src/install/preserve.ts +205 -0
  171. package/src/install/recordedOwnership.ts +149 -0
  172. package/src/install/txlog.ts +11 -2
  173. package/src/install/types.ts +19 -4
  174. package/src/scripts/_cli/cmd_conformance.ts +88 -15
  175. package/src/scripts/_cli/cmd_doctor.ts +4 -3
  176. package/src/scripts/_cli/cmd_route_explain.ts +9 -2
  177. package/src/scripts/_cli/cmd_settings_get.ts +44 -0
  178. package/src/scripts/_cli/doctor_execution.ts +90 -0
  179. package/src/scripts/_dispatch.bash +3 -1
  180. package/src/scripts/_lib/activation_payload.ts +247 -0
  181. package/src/scripts/_lib/agent_settings.ts +4 -2
  182. package/src/scripts/_lib/authority_path.ts +102 -0
  183. package/src/scripts/_lib/base_ref_payload.ts +315 -0
  184. package/src/scripts/_lib/cascade_base.ts +94 -0
  185. package/src/scripts/_lib/changelog_eras.ts +7 -2
  186. package/src/scripts/_lib/continuation_ladder.ts +149 -2
  187. package/src/scripts/_lib/council_transport.ts +129 -0
  188. package/src/scripts/_lib/delivery_ready.ts +112 -0
  189. package/src/scripts/_lib/dropped_decision.ts +142 -0
  190. package/src/scripts/_lib/forge_protection.ts +267 -0
  191. package/src/scripts/_lib/hook_platform_keys.ts +27 -0
  192. package/src/scripts/_lib/installed_lock.ts +1 -1
  193. package/src/scripts/_lib/json_python_parity.ts +127 -0
  194. package/src/scripts/_lib/lean_projection_mode.ts +14 -3
  195. package/src/scripts/_lib/loop_surfaces.ts +382 -0
  196. package/src/scripts/_lib/measured_payload_ceiling.ts +424 -0
  197. package/src/scripts/_lib/mission_record.ts +190 -0
  198. package/src/scripts/_lib/obligation_frequency.ts +217 -0
  199. package/src/scripts/_lib/obligations.ts +402 -0
  200. package/src/scripts/_lib/orchestration_record.ts +11 -20
  201. package/src/scripts/_lib/payload_catalogue_completeness.ts +211 -0
  202. package/src/scripts/_lib/release_findings_ingest.ts +324 -0
  203. package/src/scripts/_lib/release_highlights.ts +199 -10
  204. package/src/scripts/_lib/release_holds.ts +493 -0
  205. package/src/scripts/_lib/release_migration_gate.ts +184 -0
  206. package/src/scripts/_lib/release_tag_dispatch.ts +63 -0
  207. package/src/scripts/_lib/review_baseline.ts +254 -0
  208. package/src/scripts/_lib/review_skipped_record.ts +61 -1
  209. package/src/scripts/_lib/rides_along.ts +102 -0
  210. package/src/scripts/_lib/settings_renamed_keys.ts +82 -0
  211. package/src/scripts/_lib/standing_bound_ratchet.ts +216 -103
  212. package/src/scripts/_lib/test_delta.ts +166 -0
  213. package/src/scripts/_lib/test_provenance.ts +171 -0
  214. package/src/scripts/_lib/turn_end_refusals.ts +51 -4
  215. package/src/scripts/_lib/typed_op_grant.ts +246 -0
  216. package/src/scripts/_lib/typed_op_watch.ts +185 -0
  217. package/src/scripts/ai_council/config.ts +8 -11
  218. package/src/scripts/ai_council/fallback_config.ts +5 -3
  219. package/src/scripts/ai_council/ownership_classes.ts +118 -0
  220. package/src/scripts/ask_block_census.ts +285 -15
  221. package/src/scripts/check_beta_review_markers.ts +35 -4
  222. package/src/scripts/check_enforcement_matrix.ts +624 -0
  223. package/src/scripts/check_finding_dispositions.ts +167 -16
  224. package/src/scripts/check_gate_reachability.ts +173 -10
  225. package/src/scripts/check_generator_sync.ts +954 -0
  226. package/src/scripts/check_held_object_arrivals.ts +381 -0
  227. package/src/scripts/check_preamble_payload_budget.ts +474 -124
  228. package/src/scripts/check_release_highlights.ts +33 -1
  229. package/src/scripts/check_release_holds.ts +567 -0
  230. package/src/scripts/check_release_published.ts +1 -1
  231. package/src/scripts/check_reply_consistency.ts +86 -17
  232. package/src/scripts/check_rule_projection_integrity.ts +22 -12
  233. package/src/scripts/check_test_delta.ts +294 -0
  234. package/src/scripts/check_test_weakening.ts +299 -0
  235. package/src/scripts/closure_scan.ts +333 -0
  236. package/src/scripts/compile_hook_manifest.ts +43 -3
  237. package/src/scripts/council_record_shape.ts +209 -0
  238. package/src/scripts/hook_manifest.json +1 -1
  239. package/src/scripts/hook_manifest.yaml +127 -12
  240. package/src/scripts/hooks/block_kernel_rule_writes.ts +12 -2
  241. package/src/scripts/hooks/concern_registry.ts +6 -0
  242. package/src/scripts/hooks/design_pass_hook.ts +128 -3
  243. package/src/scripts/hooks/end_review_nudge_hook.ts +101 -21
  244. package/src/scripts/hooks/evidence_independence.ts +96 -0
  245. package/src/scripts/hooks/memory_recall_hook.ts +540 -0
  246. package/src/scripts/hooks/obligation_settle_hook.ts +182 -0
  247. package/src/scripts/hooks/review_baseline_hook.ts +130 -0
  248. package/src/scripts/hooks/rule_inject_hook.ts +49 -1
  249. package/src/scripts/hooks/run_continuation_hook.ts +17 -20
  250. package/src/scripts/hooks/turn_end_gate_hook.ts +232 -14
  251. package/src/scripts/hooks_doctor.ts +89 -1
  252. package/src/scripts/hooks_status.ts +19 -0
  253. package/src/scripts/install.ts +107 -207
  254. package/src/scripts/interruption_report.ts +8 -2
  255. package/src/scripts/janitor.ts +15 -1
  256. package/src/scripts/lint_decision_classes.ts +464 -0
  257. package/src/scripts/lint_hook_manifest.ts +11 -0
  258. package/src/scripts/lint_major_migration_sections.ts +450 -0
  259. package/src/scripts/lint_plan_risk_register.ts +47 -3
  260. package/src/scripts/lint_roadmap_blockers.ts +61 -0
  261. package/src/scripts/lint_roadmap_producers.ts +202 -0
  262. package/src/scripts/measure_rule_budget.ts +101 -5
  263. package/src/scripts/measure_turn_end_gate.ts +302 -5
  264. package/src/scripts/new_roadmap.ts +37 -0
  265. package/src/scripts/orchestration_record.ts +0 -5
  266. package/src/scripts/release.ts +66 -162
  267. package/src/scripts/release_drill.ts +460 -15
  268. package/src/scripts/release_env.ts +1 -1
  269. package/src/scripts/release_publication.ts +52 -1
  270. package/src/scripts/report_enforcement_drift.ts +566 -0
  271. package/src/scripts/report_held_object_arrivals.ts +456 -0
  272. package/src/scripts/report_host_injection_effect.ts +3 -11
  273. package/src/scripts/report_standing_payload_by_host.ts +26 -152
  274. package/src/scripts/schemas/agent-settings.schema.json +23 -0
  275. package/src/scripts/schemas/command.schema.json +4 -0
  276. package/src/scripts/scope_growth.ts +219 -0
  277. package/src/scripts/sync_agent_settings.ts +140 -18
  278. package/src/scripts/trigger_eval_grandfather.json +0 -1
  279. package/src/scripts/ui_conformance_probe.ts +622 -0
  280. package/src/server/io/yamlIO.ts +48 -0
  281. package/src/server/schemas/settings.ts +31 -7
  282. package/src/shared/missionExecution.ts +126 -0
  283. package/dist/cli-delegate/chunk-523HCFYO.js +0 -90
@@ -0,0 +1,299 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * check_test_weakening — a test that asserts less than it did needs a verdict.
4
+ *
5
+ * `road-to-adversarial-verification-and-long-runs` Phase 2.4, second of two.
6
+ * `evaluator-independence` § Tests are evaluators forbids an implementer from
7
+ * silently weakening an assertion, deleting a failing test, skipping it, or
8
+ * loosening a threshold. This gate sees the shape of that in a diff.
9
+ *
10
+ * **NET counts, never raw removals — the whole gate turns on this.** Editing an
11
+ * assertion removes one line and adds another, so a gate counting removals reds
12
+ * every legitimate test edit. A gate that reds every PR gets its exemption
13
+ * widened until it finds nothing, which is Risk 4 of the roadmap that
14
+ * commissioned it and the allowlist-growth antipattern `autonomous-execution`
15
+ * already names. A NET loss is a different, much narrower claim: after this
16
+ * diff, the file asserts less than before.
17
+ *
18
+ * **What makes a weakening legitimate is an independent verdict**, and this gate
19
+ * checks only that one was COMMITTED in the same diff. Whether it actually
20
+ * contains an independent verdict is a judgement no gate can make, so it is not
21
+ * claimed here.
22
+ *
23
+ * Usage:
24
+ * check_test_weakening [--diff-file F] [--files F …] [--base-ref REF]
25
+ * [--root DIR] [--quiet] [--self-test]
26
+ *
27
+ * Exit codes: 0 = pass (or discharged) · 1 = fail · 2 = usage error.
28
+ */
29
+
30
+ import { spawnSync } from 'node:child_process';
31
+ import * as fs from 'node:fs';
32
+ import * as os from 'node:os';
33
+ import * as path from 'node:path';
34
+ import { fileURLToPath, pathToFileURL } from 'node:url';
35
+
36
+ import { GateLedger } from './_lib/gate_ledger.js';
37
+ import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
38
+ import { reportScanned } from './_lib/scan_scope.js';
39
+ import {
40
+ isTestPath,
41
+ isVerdictArtefact,
42
+ isWeakening,
43
+ parseUnifiedDiff,
44
+ weakeningSignal,
45
+ type WeakeningSignal,
46
+ } from './_lib/test_delta.js';
47
+
48
+ const _HERE = fileURLToPath(import.meta.url);
49
+ const REPO = path.resolve(path.dirname(_HERE), '..', '..');
50
+
51
+ export interface WeakeningReport {
52
+ readonly signals: readonly WeakeningSignal[];
53
+ readonly discharged: boolean;
54
+ readonly scanned: number;
55
+ }
56
+
57
+ /** The decision, pure: which test files weakened, and was a verdict committed. */
58
+ export function analyse(diff: string, changedFiles: readonly string[]): WeakeningReport {
59
+ const files = parseUnifiedDiff(diff).filter((f) => isTestPath(f.path));
60
+ const signals = files.map(weakeningSignal).filter(isWeakening);
61
+ return {
62
+ signals,
63
+ discharged: changedFiles.some(isVerdictArtefact),
64
+ scanned: files.length,
65
+ };
66
+ }
67
+
68
+ /** One human-readable line per weakened file. */
69
+ export function renderSignal(s: WeakeningSignal): string {
70
+ const parts: string[] = [];
71
+ if (s.assertionsLost > 0) parts.push(`${String(s.assertionsLost)} assertion(s) net-removed`);
72
+ if (s.suppressionsGained > 0) {
73
+ parts.push(`${String(s.suppressionsGained)} suppression(s) net-added`);
74
+ }
75
+ return `${s.path}: ${parts.join(' · ')}`;
76
+ }
77
+
78
+ function git(args: readonly string[], root: string): string | null {
79
+ const r = spawnSync('git', [...args], { cwd: root, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
80
+ return r.status === 0 ? r.stdout : null;
81
+ }
82
+
83
+ function resolveBaseRef(explicit: string | null, root: string): string {
84
+ if (explicit !== null) return explicit;
85
+ for (const ref of ['origin/main', 'origin/master', 'main', 'master']) {
86
+ if (git(['rev-parse', '--verify', '--quiet', ref], root) !== null) return ref;
87
+ }
88
+ return 'HEAD~1';
89
+ }
90
+
91
+ function usage(): string {
92
+ return (
93
+ 'usage: check_test_weakening [--diff-file F] [--files F …] [--base-ref REF]\n' +
94
+ ' [--root DIR] [--quiet] [--self-test]\n'
95
+ );
96
+ }
97
+
98
+ export function main(argv: readonly string[] = process.argv.slice(2)): number {
99
+ if (argv.includes('--help') || argv.includes('-h')) {
100
+ process.stdout.write(usage());
101
+ return 0;
102
+ }
103
+ if (argv.includes('--self-test')) return selfTest();
104
+
105
+ const quiet = argv.includes('--quiet');
106
+ const at = (flag: string): string | null => {
107
+ const i = argv.indexOf(flag);
108
+ return i >= 0 ? (argv[i + 1] ?? null) : null;
109
+ };
110
+ const root = at('--root') ?? REPO;
111
+
112
+ let diff: string;
113
+ let changed: string[];
114
+ const diffFile = at('--diff-file');
115
+ const fi = argv.indexOf('--files');
116
+ if (diffFile !== null) {
117
+ try {
118
+ diff = fs.readFileSync(diffFile, 'utf8');
119
+ } catch {
120
+ process.stderr.write(`check_test_weakening: cannot read ${diffFile}\n`);
121
+ return 2;
122
+ }
123
+ changed = fi >= 0 ? argv.slice(fi + 1).filter((a) => !a.startsWith('--')) : [];
124
+ } else {
125
+ const base = resolveBaseRef(at('--base-ref'), root);
126
+ const d = git(['diff', '--unified=0', `${base}...HEAD`], root);
127
+ const names = git(['diff', '--name-only', `${base}...HEAD`], root);
128
+ if (d === null || names === null) {
129
+ process.stderr.write('check_test_weakening: git diff failed\n');
130
+ return 2;
131
+ }
132
+ diff = d;
133
+ changed = names.split('\n').map((s) => s.trim()).filter((s) => s !== '');
134
+ }
135
+
136
+ // Per-file accounting: a changed test file either measured clean, measured
137
+ // weakened, or was discharged by a committed verdict. Without it, "no test
138
+ // file changed" and "every test file was skipped" print the same green line.
139
+ const parsed = parseUnifiedDiff(diff);
140
+ const ledger = new GateLedger('check_test_weakening');
141
+ ledger.plan(parsed.map((f) => `file:${f.path}`));
142
+ const report = analyse(diff, changed);
143
+ const weakened = new Set(report.signals.map((s) => s.path));
144
+ for (const f of parsed) {
145
+ const target = `file:${f.path}`;
146
+ if (!isTestPath(f.path)) {
147
+ ledger.outOfScope(target, 'not_applicable_kind');
148
+ continue;
149
+ }
150
+ if (!weakened.has(f.path)) ledger.complete(target);
151
+ else if (report.discharged) ledger.outOfScope(target, 'declared_exemption');
152
+ else ledger.fail(target, renderSignal(weakeningSignal(f)));
153
+ }
154
+ ledger.report();
155
+
156
+ reportScanned({
157
+ gate: 'check_test_weakening',
158
+ scanned: report.scanned,
159
+ units: 'changed test file(s)',
160
+ roots: ['<the diff against the base ref>'],
161
+ // A diff touching no test file is the normal case for a docs or config
162
+ // change, and reporting it as a dead scope would make the gate cry wolf
163
+ // on most of the PRs it runs against.
164
+ allowEmpty: 'EMPTY_VALID: a diff that changes no test file has nothing to weaken',
165
+ });
166
+
167
+ if (report.signals.length === 0) {
168
+ if (!quiet) {
169
+ process.stdout.write(
170
+ `✅ check_test_weakening: ${String(report.scanned)} test file(s), no net weakening\n`,
171
+ );
172
+ }
173
+ return 0;
174
+ }
175
+
176
+ if (report.discharged) {
177
+ process.stdout.write(
178
+ `✅ check_test_weakening: ${String(report.signals.length)} weakened file(s), ` +
179
+ 'discharged by an independent-verdict artefact under agents/evidence/reviews/\n',
180
+ );
181
+ return 0;
182
+ }
183
+
184
+ process.stderr.write(
185
+ `❌ check_test_weakening: ${String(report.signals.length)} test file(s) assert less ` +
186
+ 'than before, with no independent-verdict artefact in this diff:\n' +
187
+ report.signals.map((s) => ` - ${renderSignal(s)}\n`).join('') +
188
+ ' An implementer may not weaken a test silently (src/rules/evaluator-independence.md\n' +
189
+ ' § Tests are evaluators). Where the test is genuinely wrong: evidence → independent\n' +
190
+ ' test review → council or team, then commit the verdict under agents/evidence/reviews/.\n',
191
+ );
192
+ return 1;
193
+ }
194
+
195
+ function selfTest(): number {
196
+ const withDiff = (diff: string, files: readonly string[] = []): number => {
197
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ctw-'));
198
+ const f = path.join(dir, 'd.diff');
199
+ fs.writeFileSync(f, diff);
200
+ try {
201
+ const args = ['--diff-file', f, '--quiet'];
202
+ if (files.length > 0) args.push('--files', ...files);
203
+ return runGateCli(REPO, 'src/scripts/check_test_weakening.ts', args, dir);
204
+ } finally {
205
+ fs.rmSync(dir, { recursive: true, force: true });
206
+ }
207
+ };
208
+ const header = (p: string): string => `diff --git a/${p} b/${p}\n--- a/${p}\n+++ b/${p}\n@@\n`;
209
+
210
+ return runSelfTest({
211
+ gate: 'check_test_weakening',
212
+ minCases: 6,
213
+ minRejectCases: 3,
214
+ cases: [
215
+ {
216
+ name: 'a net-removed assertion is REJECTED',
217
+ expect: 'reject',
218
+ run: () =>
219
+ withDiff(header('tests/a.test.ts') + "- expect(x).toBe(1);\n"),
220
+ },
221
+ {
222
+ name: 'an added it.skip is REJECTED',
223
+ expect: 'reject',
224
+ run: () => withDiff(header('tests/a.test.ts') + "+ it.skip('x', () => {});\n"),
225
+ },
226
+ {
227
+ name: 'an added @pytest.mark.skip is REJECTED — not a one-framework gate',
228
+ expect: 'reject',
229
+ run: () => withDiff(header('tests/a_test.py') + '+@pytest.mark.skip\n'),
230
+ },
231
+ {
232
+ name: 'EDITING an assertion is not weakening — the net is zero',
233
+ expect: 'accept',
234
+ run: () =>
235
+ withDiff(
236
+ header('tests/a.test.ts') +
237
+ "- expect(x).toBe(1);\n+ expect(x).toBe(2);\n",
238
+ ),
239
+ },
240
+ {
241
+ name: 'ADDING assertions is never weakening',
242
+ expect: 'accept',
243
+ run: () =>
244
+ withDiff(
245
+ header('tests/a.test.ts') +
246
+ "+ expect(x).toBe(1);\n+ expect(y).toBe(2);\n",
247
+ ),
248
+ },
249
+ {
250
+ name: 'DELETING a whole test file is the maximal weakening, and REDS',
251
+ expect: 'reject',
252
+ run: () =>
253
+ withDiff(
254
+ 'diff --git a/tests/a.test.ts b/tests/a.test.ts\n' +
255
+ '--- a/tests/a.test.ts\n+++ /dev/null\n@@\n' +
256
+ '-expect(x).toBe(1);\n',
257
+ ),
258
+ },
259
+ {
260
+ name: 'a removed assertion in PRODUCTION code is out of scope',
261
+ expect: 'accept',
262
+ run: () => withDiff(header('src/a.ts') + '- expect(x).toBe(1);\n'),
263
+ },
264
+ {
265
+ name: 'REMOVING a skip is the opposite of weakening',
266
+ expect: 'accept',
267
+ run: () => withDiff(header('tests/a.test.ts') + "- it.skip('x', () => {});\n"),
268
+ },
269
+ {
270
+ name: 'a DIFF FIXTURE held as string data is not a suppression',
271
+ expect: 'accept',
272
+ run: () =>
273
+ withDiff(
274
+ header('tests/gate.test.ts') +
275
+ '+ \'+@pytest.mark.skip\\n\',\n',
276
+ ),
277
+ },
278
+ {
279
+ name: '…and the same construct written as CODE still reds',
280
+ expect: 'reject',
281
+ run: () => withDiff(header('tests/gate.test.ts') + '+@pytest.mark.skip\n'),
282
+ },
283
+ {
284
+ name: 'a committed verdict artefact discharges a real weakening',
285
+ expect: 'accept',
286
+ run: () =>
287
+ withDiff(header('tests/a.test.ts') + '- expect(x).toBe(1);\n', [
288
+ 'agents/evidence/reviews/some-review.md',
289
+ ]),
290
+ },
291
+ ],
292
+ });
293
+ }
294
+
295
+ /* c8 ignore start — CLI entry, exercised by the self-test rather than in-process. */
296
+ if (import.meta.url === pathToFileURL(path.resolve(process.argv[1] ?? '')).href) {
297
+ process.exit(main());
298
+ }
299
+ /* c8 ignore stop */
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * The closure detector — finds the open decisions in a plan, and classifies
4
+ * each one by OWNERSHIP. It never resolves one.
5
+ *
6
+ * That boundary is the whole design. A decision dispatcher was killed on
7
+ * purpose: no TypeScript path routes a decision, and this script adds none.
8
+ * What it adds is the half that can be mechanical — *which questions are open,
9
+ * and whose they are* — so the claim "this plan reached execution with twelve
10
+ * open technical decisions and asked the owner none of them" is falsifiable
11
+ * rather than a feeling.
12
+ *
13
+ * Detector families, each a phrase an author writes when they KNOW something
14
+ * is open. Deliberately narrow: a detector that fires on ordinary plan prose
15
+ * turns every producer red at once, and the cheapest repair is then to weaken
16
+ * it until it finds nothing.
17
+ *
18
+ * `tbd` TBD / to be decided / decide later
19
+ * `unpicked-alternative` "either X or Y", "option A … option B"
20
+ * `unchecked-assumption` "assuming", "presumably", "probably fine"
21
+ * `missing-verify` an open step with no `verify:` line
22
+ * `ambiguous-acceptance` a vague acceptance criterion (improve / clean up
23
+ * / best practices / handle errors properly)
24
+ * `contradictory` "contradicts", "conflicts with", "but also"
25
+ * `product-semantics` two valid user-visible outcomes
26
+ * `typed-op` an operation from the typed vocabulary
27
+ * `spend` a ceiling or quota question
28
+ *
29
+ * Ownership classification is the output that matters. Six families classify
30
+ * to a technical class the agent, an independent session, a council or a team
31
+ * resolves; exactly two reach the owner (`product-semantics` → `product-owned`,
32
+ * `typed-op` → `destructive-owned`); `spend` reaches nobody — it pauses and
33
+ * reports (ADR-268 § 8).
34
+ *
35
+ * Exit codes: 0 no open decisions · 1 open decisions found · 2 bad argv.
36
+ * Finding open decisions is the POINT, so `--strict` is what makes exit 1 a
37
+ * failure for a caller that wants one; the default is a report.
38
+ */
39
+
40
+ import * as fs from 'node:fs';
41
+ import * as path from 'node:path';
42
+ import process from 'node:process';
43
+ import { fileURLToPath } from 'node:url';
44
+
45
+ const _HERE = fileURLToPath(import.meta.url);
46
+ const _PROG = 'closure_scan';
47
+
48
+ export type Kind =
49
+ | 'tbd'
50
+ | 'unpicked-alternative'
51
+ | 'unchecked-assumption'
52
+ | 'missing-verify'
53
+ | 'ambiguous-acceptance'
54
+ | 'contradictory'
55
+ | 'product-semantics'
56
+ | 'typed-op'
57
+ | 'spend';
58
+
59
+ export interface Finding {
60
+ readonly id: string;
61
+ readonly line: number;
62
+ readonly kind: Kind;
63
+ readonly ownership: string;
64
+ readonly excerpt: string;
65
+ }
66
+
67
+ /**
68
+ * Kind → ownership class. The two owner-routed rows are the whole cost of
69
+ * closure: everything else is resolved without reaching a person, which is
70
+ * what "zero owner questions for twelve technical ambiguities" means.
71
+ */
72
+ export const OWNERSHIP_BY_KIND: Readonly<Record<Kind, string>> = {
73
+ tbd: 'contested-technical',
74
+ 'unpicked-alternative': 'contested-technical',
75
+ 'unchecked-assumption': 'reversible-technical',
76
+ 'missing-verify': 'deterministic',
77
+ 'ambiguous-acceptance': 'reversible-technical',
78
+ contradictory: 'contested-technical',
79
+ 'product-semantics': 'product-owned',
80
+ 'typed-op': 'destructive-owned',
81
+ spend: 'spend-exhaustion',
82
+ };
83
+
84
+ /** Classes that reach a person. Everything else closes without an ask. */
85
+ export const OWNER_ROUTED: ReadonlySet<string> = new Set([
86
+ 'product-owned',
87
+ 'business-owned',
88
+ 'destructive-owned',
89
+ ]);
90
+
91
+ interface Pattern {
92
+ readonly kind: Kind;
93
+ readonly re: RegExp;
94
+ }
95
+
96
+ const LINE_PATTERNS: readonly Pattern[] = [
97
+ { kind: 'tbd', re: /\bTBD\b/ },
98
+ { kind: 'tbd', re: /\bto be decided\b/i },
99
+ { kind: 'tbd', re: /\bdecide later\b/i },
100
+ { kind: 'unpicked-alternative', re: /\beither\b[^.]{3,80}\bor\b/i },
101
+ { kind: 'unpicked-alternative', re: /\boption A\b[^.]{0,120}\boption B\b/i },
102
+ { kind: 'unpicked-alternative', re: /\bwe could (?:either )?\w+[^.]{0,80}\bor\b/i },
103
+ // "two equal technical strategies" is the canonical unpicked alternative,
104
+ // and the phrasing an author reaches for when the choice is genuinely open
105
+ // — it carries no `either`, no `or` and no `option A`.
106
+ { kind: 'unpicked-alternative', re: /\btwo (?:equal|competing)\b[^.]{0,60}\b(?:strateg|approach|design|option)/i },
107
+ { kind: 'unchecked-assumption', re: /\bassuming\b/i },
108
+ { kind: 'unchecked-assumption', re: /\bpresumably\b/i },
109
+ { kind: 'unchecked-assumption', re: /\bprobably (?:fine|works|ok)\b/i },
110
+ { kind: 'ambiguous-acceptance', re: /\b(?:improve|optimi[sz]e)\b(?![^.]*\bby\b)/i },
111
+ { kind: 'ambiguous-acceptance', re: /\bmake it (?:better|cleaner|nicer)\b/i },
112
+ { kind: 'ambiguous-acceptance', re: /\buse best practices\b/i },
113
+ { kind: 'ambiguous-acceptance', re: /\bhandle errors properly\b/i },
114
+ { kind: 'contradictory', re: /\bcontradicts\b/i },
115
+ { kind: 'contradictory', re: /\bconflicts with\b/i },
116
+ { kind: 'product-semantics', re: /\buser-visible\b[^.]{0,80}\b(?:or|versus|vs\.?)\b/i },
117
+ { kind: 'product-semantics', re: /\bwhat the user sees\b/i },
118
+ { kind: 'product-semantics', re: /\btwo valid (?:user|product) semantics\b/i },
119
+ { kind: 'typed-op', re: /\b(?:merge|force-push|deploy|publish|purchase|rotate the secret)\b/i },
120
+ { kind: 'spend', re: /\b(?:over|above|crosses?) the (?:configured )?(?:spend )?ceiling\b/i },
121
+ { kind: 'spend', re: /\bquota (?:is )?(?:exhausted|gone)\b/i },
122
+ ];
123
+
124
+ const STEP_RE = /^\s*-\s\[[ x~-]\]\s/;
125
+ const OPEN_STEP_RE = /^\s*-\s\[ \]\s/;
126
+ const H2_RE = /^##\s+/;
127
+ const DISCHARGE_H2_RE = /^##\s+(Decisions|Blockers|Kill register|Risk Register)\s*$/;
128
+ const ACCEPTANCE_H2_RE = /^##\s+Acceptance Criteria\s*$/;
129
+
130
+ function _isFence(line: string): boolean {
131
+ return /^\s*(```|~~~)/.test(line);
132
+ }
133
+
134
+ /**
135
+ * A scan unit: one logical statement, which is NOT one physical line.
136
+ *
137
+ * A roadmap step wraps over several lines, and the ambiguity it carries
138
+ * routinely straddles the wrap ("either newline-delimited JSON / or a single
139
+ * array"). Matching per line missed exactly those, which are the ones an
140
+ * author is least likely to notice. A step block is therefore ONE unit, joined
141
+ * and matched once — which also means one finding per step at most, so a step
142
+ * carrying two hedges is one open decision rather than two.
143
+ */
144
+ export interface Unit {
145
+ /** 1-based line of the unit's first line. */
146
+ readonly line: number;
147
+ readonly text: string;
148
+ readonly inAcceptance: boolean;
149
+ readonly isOpenStep: boolean;
150
+ readonly hasVerify: boolean;
151
+ }
152
+
153
+ export function units(lines: readonly string[]): Unit[] {
154
+ const out: Unit[] = [];
155
+ let inFence = false;
156
+ let inDischarge = false;
157
+ let inAcceptance = false;
158
+ let i = 0;
159
+ while (i < lines.length) {
160
+ const line = lines[i] as string;
161
+ if (_isFence(line)) {
162
+ inFence = !inFence;
163
+ i += 1;
164
+ continue;
165
+ }
166
+ if (inFence) {
167
+ i += 1;
168
+ continue;
169
+ }
170
+ if (H2_RE.test(line)) {
171
+ inDischarge = DISCHARGE_H2_RE.test(line);
172
+ inAcceptance = ACCEPTANCE_H2_RE.test(line);
173
+ }
174
+ if (inDischarge) {
175
+ i += 1;
176
+ continue;
177
+ }
178
+ if (STEP_RE.test(line)) {
179
+ let j = i + 1;
180
+ while (j < lines.length && !STEP_RE.test(lines[j] as string) && !H2_RE.test(lines[j] as string)) {
181
+ j += 1;
182
+ }
183
+ const block = lines.slice(i, j);
184
+ out.push({
185
+ line: i + 1,
186
+ // Whitespace-normalised: a wrapped step joined with a single
187
+ // space still carries the next line's indent, so a pattern
188
+ // written with single spaces stops matching across the wrap —
189
+ // which is the case this unit exists to catch.
190
+ text: block.join(' ').replace(/\s+/g, ' '),
191
+ inAcceptance,
192
+ isOpenStep: OPEN_STEP_RE.test(line),
193
+ hasVerify: /(?:^|\s)verify:/m.test(block.join('\n')),
194
+ });
195
+ i = j;
196
+ continue;
197
+ }
198
+ out.push({
199
+ line: i + 1,
200
+ text: line,
201
+ inAcceptance,
202
+ isOpenStep: false,
203
+ hasVerify: false,
204
+ });
205
+ i += 1;
206
+ }
207
+ return out;
208
+ }
209
+
210
+ export function scan(text: string): Finding[] {
211
+ const lines = text.split('\n');
212
+ const findings: Finding[] = [];
213
+
214
+ for (const u of units(lines)) {
215
+ if (/<!--\s*closure:\s*ignore\s*-->/.test(u.text)) continue;
216
+ let matched: Kind | null = null;
217
+ for (const { kind, re } of LINE_PATTERNS) {
218
+ if (re.test(u.text)) {
219
+ matched = kind;
220
+ break;
221
+ }
222
+ }
223
+ // An open step carries a `verify:` line — a step whose success cannot
224
+ // be checked is a trigger for a question, not for work. Acceptance
225
+ // criteria are criteria, not steps: they have their own contract and
226
+ // carry no verify line by design.
227
+ if (matched === null && u.isOpenStep && !u.hasVerify && !u.inAcceptance) {
228
+ matched = 'missing-verify';
229
+ }
230
+ if (matched === null) continue;
231
+ findings.push({
232
+ id: '',
233
+ line: u.line,
234
+ kind: matched,
235
+ ownership: OWNERSHIP_BY_KIND[matched],
236
+ excerpt: u.text.trim().replace(/\s+/g, ' ').slice(0, 120),
237
+ });
238
+ }
239
+
240
+ findings.sort((a, b) => a.line - b.line);
241
+ return findings.map((f, idx) => ({ ...f, id: `C${String(idx + 1)}` }));
242
+ }
243
+
244
+ /** The `## Decisions` skeleton — one row per finding, resolution left open. */
245
+ export function renderRows(findings: readonly Finding[]): string {
246
+ const header = '| ID | ownership | resolved by | decision | evidence | revisit if |';
247
+ const sep = '|---|---|---|---|---|---|';
248
+ const rows = findings.map(
249
+ (f) => `| ${f.id} | ${f.ownership} | <resolver> | <decision> | ${f.kind}:${String(f.line)} | <condition> |`,
250
+ );
251
+ return [header, sep, ...rows].join('\n');
252
+ }
253
+
254
+ export function ownerQuestionCount(findings: readonly Finding[]): number {
255
+ return findings.filter((f) => OWNER_ROUTED.has(f.ownership)).length;
256
+ }
257
+
258
+ // --------------------------------------------------------------------------
259
+ // CLI.
260
+ // --------------------------------------------------------------------------
261
+
262
+ function _usage(): string {
263
+ return (
264
+ `usage: ${_PROG} [-h] [--json] [--rows] [--strict] <roadmap.md>\n\n` +
265
+ `Find and classify the open decisions in a plan. Resolves nothing.\n`
266
+ );
267
+ }
268
+
269
+ export function main(argv?: readonly string[]): number {
270
+ const args = argv ?? process.argv.slice(2);
271
+ let json = false;
272
+ let rows = false;
273
+ let strict = false;
274
+ const paths: string[] = [];
275
+ for (const a of args) {
276
+ if (a === '-h' || a === '--help') {
277
+ process.stdout.write(_usage());
278
+ return 0;
279
+ } else if (a === '--json') json = true;
280
+ else if (a === '--rows') rows = true;
281
+ else if (a === '--strict') strict = true;
282
+ else if (a.startsWith('-')) {
283
+ process.stderr.write(`${_usage()}${_PROG}: error: unrecognized arguments: ${a}\n`);
284
+ return 2;
285
+ } else paths.push(a);
286
+ }
287
+ if (paths.length !== 1) {
288
+ process.stderr.write(`${_usage()}${_PROG}: error: exactly one roadmap path is required\n`);
289
+ return 2;
290
+ }
291
+ const target = path.resolve(paths[0] as string);
292
+ let text: string;
293
+ try {
294
+ text = fs.readFileSync(target, 'utf-8');
295
+ } catch {
296
+ process.stderr.write(`${_PROG}: error: cannot read ${paths[0] as string}\n`);
297
+ return 2;
298
+ }
299
+
300
+ const findings = scan(text);
301
+ const owner = ownerQuestionCount(findings);
302
+
303
+ if (json) {
304
+ process.stdout.write(
305
+ JSON.stringify({ findings, open: findings.length, owner_questions: owner }, null, 2) + '\n',
306
+ );
307
+ } else if (rows) {
308
+ process.stdout.write(renderRows(findings) + '\n');
309
+ } else {
310
+ process.stdout.write(`open decisions: ${String(findings.length)}\n`);
311
+ process.stdout.write(`owner questions: ${String(owner)}\n`);
312
+ for (const f of findings) {
313
+ process.stdout.write(
314
+ ` ${f.id} ${String(f.line).padStart(4)} ${f.kind.padEnd(20)} ${f.ownership.padEnd(22)} ${f.excerpt}\n`,
315
+ );
316
+ }
317
+ }
318
+ if (!findings.length) return 0;
319
+ return strict ? 1 : 0;
320
+ }
321
+
322
+ function _isCliEntry(): boolean {
323
+ if (!process.argv[1]) return false;
324
+ try {
325
+ return fs.realpathSync(_HERE) === fs.realpathSync(path.resolve(process.argv[1]));
326
+ } catch {
327
+ return false;
328
+ }
329
+ }
330
+
331
+ if (_isCliEntry() || process.argv[1] === _HERE) {
332
+ process.exitCode = main();
333
+ }
@@ -23,10 +23,21 @@
23
23
  * Run after editing the manifest:
24
24
  * ./scripts-run src/scripts/compile_hook_manifest
25
25
  *
26
+ * `--out <path>` writes the compiled bytes THERE and leaves the tree alone. It
27
+ * exists for `check_generator_sync`, whose contract is that a triple regenerates
28
+ * into a temp dir the gate owns and never into the working tree — a gate that
29
+ * repaired the artefact it is measuring would turn every staleness into a silent
30
+ * green plus an unexplained dirty file. Anything that wants to compare against
31
+ * the committed JSON uses this flag; anything that wants to FIX it omits it.
32
+ *
26
33
  * `tests/hooks/hook_manifest_compiled.test.ts` fails if the committed JSON
27
- * stops matching the YAML, so a forgotten run is caught rather than shipped.
34
+ * stops matching the YAML, so a forgotten run is caught rather than shipped —
35
+ * but only against the COMMITTED blob in CI. Locally that same test reads the
36
+ * working tree, so a recompile that was run and never committed makes it pass
37
+ * over a defect that is still on the branch. See the discriminator in
38
+ * `check_generator_sync`'s header.
28
39
  *
29
- * Exit codes: 0 written (or already current) · 2 internal error.
40
+ * Exit codes: 0 written (or already current) · 2 internal error or usage.
30
41
  */
31
42
  import * as fs from 'node:fs';
32
43
  import * as path from 'node:path';
@@ -46,9 +57,38 @@ export function compile(yamlText: string): string {
46
57
  });
47
58
  }
48
59
 
49
- export function main(): number {
60
+ export function main(argv: readonly string[] = process.argv.slice(2)): number {
61
+ let outPath: string | null = null;
62
+ for (let i = 0; i < argv.length; i += 1) {
63
+ const a = argv[i] as string;
64
+ if (a === '--out') {
65
+ const next = argv[i + 1];
66
+ if (next === undefined || next.startsWith('--')) {
67
+ process.stderr.write('compile_hook_manifest: --out needs a path\n');
68
+ return 2;
69
+ }
70
+ outPath = next;
71
+ i += 1;
72
+ continue;
73
+ }
74
+ process.stderr.write(
75
+ `compile_hook_manifest: unknown argument ${JSON.stringify(a)}\n` +
76
+ 'usage: compile_hook_manifest [--out <path>]\n',
77
+ );
78
+ return 2;
79
+ }
80
+
50
81
  const text = fs.readFileSync(YAML_PATH, 'utf-8');
51
82
  const next = compile(text);
83
+
84
+ if (outPath !== null) {
85
+ // Comparison copy. No `already current` short-circuit: the caller asked
86
+ // for the bytes, and an unwritten file would read as a generator that
87
+ // silently did nothing.
88
+ fs.writeFileSync(outPath, next);
89
+ return 0;
90
+ }
91
+
52
92
  const current = fs.existsSync(JSON_PATH) ? fs.readFileSync(JSON_PATH, 'utf-8') : null;
53
93
  if (current === next) {
54
94
  process.stdout.write('compile_hook_manifest: already current\n');