@event4u/agent-config 14.7.0 → 14.9.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 (230) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +175 -390
  3. package/README.md +1 -1
  4. package/agents/templates/.ai-council.yml.example +34 -4
  5. package/dist/agent-src/commands/evals/git-pr-merge.json +13 -0
  6. package/dist/agent-src/commands/evals/roadmap.json +17 -1
  7. package/dist/agent-src/commands/pr/create.md +14 -2
  8. package/dist/agent-src/commands/pr/merge.md +383 -0
  9. package/dist/agent-src/commands/review/changes.md +95 -14
  10. package/dist/agent-src/commands/roadmap/process-full.md +157 -3
  11. package/dist/agent-src/commands/roadmap.md +1 -1
  12. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +46 -36
  13. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +37 -2
  14. package/dist/agent-src/contexts/execution/host-capability-manifest.md +77 -0
  15. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +100 -2
  16. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +154 -2
  17. package/dist/agent-src/contexts/execution/subagent-response-contract.md +80 -0
  18. package/dist/agent-src/rules/artifact-drafting-protocol.md +5 -1
  19. package/dist/agent-src/rules/decision-revisit-gate.md +65 -33
  20. package/dist/agent-src/rules/recurring-criticism.md +1 -1
  21. package/dist/agent-src/rules/roadmap-progress-sync.md +19 -0
  22. package/dist/agent-src/rules/source-confidentiality.md +1 -1
  23. package/dist/agent-src/rules/untrusted-input-defense.md +9 -3
  24. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
  25. package/dist/agent-src/scripts/dashboard_mode.ts +212 -0
  26. package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -48
  27. package/dist/agent-src/skills/adr-create/SKILL.md +124 -5
  28. package/dist/agent-src/skills/code-review/evals/evals.json +87 -46
  29. package/dist/agent-src/skills/decision-record/SKILL.md +61 -3
  30. package/dist/agent-src/skills/decision-review/SKILL.md +65 -0
  31. package/dist/agent-src/skills/humanizer/SKILL.md +39 -0
  32. package/dist/agent-src/skills/humanizer/evals/strip_fixtures.json +57 -0
  33. package/dist/agent-src/skills/humanizer/references/fixtures.md +54 -0
  34. package/dist/agent-src/skills/judge-spec-compliance/SKILL.md +154 -0
  35. package/dist/agent-src/skills/judge-synthesis/SKILL.md +45 -0
  36. package/dist/agent-src/skills/merge-conflicts/SKILL.md +26 -0
  37. package/dist/agent-src/skills/roadmap-writing/SKILL.md +18 -18
  38. package/dist/agent-src/skills/security/SKILL.md +8 -0
  39. package/dist/agent-src/skills/security-audit/SKILL.md +12 -1
  40. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  41. package/dist/agent-src/templates/roadmaps.md +28 -2
  42. package/dist/cli/registry.js +3 -0
  43. package/dist/cli/registry.js.map +1 -1
  44. package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-77GR3VD4.js} +68 -43
  45. package/dist/cli-delegate/{chunk-JZ4NR2TW.js → chunk-7PRE6ZV2.js} +1 -1
  46. package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-CYH5SP6M.js} +4 -23
  47. package/dist/cli-delegate/{chunk-Q26HT4QT.js → chunk-E46KPN6K.js} +251 -150
  48. package/dist/cli-delegate/{chunk-74E3TKWV.js → chunk-H4VIVD4C.js} +5 -5
  49. package/dist/cli-delegate/chunk-I3EIN2YG.js +36 -0
  50. package/dist/cli-delegate/{chunk-4VR4ORQ6.js → chunk-LPPWO5CE.js} +4 -3
  51. package/dist/cli-delegate/{chunk-7F3VYSM2.js → chunk-PLSG6UL5.js} +14 -14
  52. package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
  53. package/dist/cli-delegate/cmd_conformance.js +101 -25
  54. package/dist/cli-delegate/cmd_converge.js +3 -3
  55. package/dist/cli-delegate/cmd_doctor.js +7 -7
  56. package/dist/cli-delegate/cmd_export.js +6 -6
  57. package/dist/cli-delegate/cmd_handoff.js +7 -6
  58. package/dist/cli-delegate/cmd_memory_get.js +2 -1
  59. package/dist/cli-delegate/cmd_migrate.js +2 -2
  60. package/dist/cli-delegate/cmd_preflight.js +5 -5
  61. package/dist/cli-delegate/cmd_prune.js +6 -6
  62. package/dist/cli-delegate/cmd_route_audit.js +6 -5
  63. package/dist/cli-delegate/cmd_route_explain.js +3 -3
  64. package/dist/cli-delegate/cmd_session_recycle.js +3 -3
  65. package/dist/cli-delegate/cmd_settings_get.js +4 -4
  66. package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
  67. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  68. package/dist/cli-delegate/cmd_sync.js +6 -6
  69. package/dist/cli-delegate/cmd_uninstall.js +6 -6
  70. package/dist/cli-delegate/cmd_update.js +3 -3
  71. package/dist/cli-delegate/cmd_upgrade.js +3 -3
  72. package/dist/cli-delegate/cmd_validate.js +6 -6
  73. package/dist/cli-delegate/cmd_versions.js +3 -3
  74. package/dist/discovery/deprecation-report.md +1 -1
  75. package/dist/discovery/discovery-manifest.json +83 -30
  76. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  77. package/dist/discovery/discovery-manifest.summary.md +4 -4
  78. package/dist/discovery/dormancy-report.md +1 -1
  79. package/dist/discovery/orphan-report.md +1 -1
  80. package/dist/discovery/packs.json +11 -9
  81. package/dist/discovery/trust-report.md +2 -2
  82. package/dist/discovery/workspaces.json +9 -7
  83. package/dist/hooks/dispatch.js +1295 -812
  84. package/dist/install/globalRuleLayers.js +169 -0
  85. package/dist/install/globalRuleLayers.js.map +1 -0
  86. package/dist/install/partitionEligibility.js +156 -0
  87. package/dist/install/partitionEligibility.js.map +1 -1
  88. package/dist/install/ruleLayerPartition.js +157 -0
  89. package/dist/install/ruleLayerPartition.js.map +1 -0
  90. package/dist/mcp/registry-manifest.json +3 -3
  91. package/dist/mcp/server.json +2 -2
  92. package/dist/mcp/server.mjs +1358 -805
  93. package/dist/scripts/_lib/claude_plugin.js +162 -0
  94. package/dist/scripts/_lib/claude_plugin.js.map +1 -0
  95. package/dist/scripts/_lib/environment_detector.js +397 -0
  96. package/dist/scripts/_lib/environment_detector.js.map +1 -0
  97. package/dist/scripts/_lib/prune_empty_dirs.js +85 -0
  98. package/dist/scripts/_lib/prune_empty_dirs.js.map +1 -0
  99. package/dist/scripts/_lib/spawn_env.js +129 -0
  100. package/dist/scripts/_lib/spawn_env.js.map +1 -0
  101. package/dist/scripts/ai_council/modes.js +199 -0
  102. package/dist/scripts/ai_council/modes.js.map +1 -0
  103. package/dist/scripts/ai_council/transport_resolver.js +418 -0
  104. package/dist/scripts/ai_council/transport_resolver.js.map +1 -0
  105. package/dist/scripts/condense.js +52 -89
  106. package/dist/scripts/condense.js.map +1 -1
  107. package/dist/server/routes/wizard.js +3 -2
  108. package/dist/server/routes/wizard.js.map +1 -1
  109. package/dist/ui/assets/index-Cxgy63kz.js +26 -0
  110. package/dist/ui/assets/index-Cxgy63kz.js.map +1 -0
  111. package/dist/ui/index.html +1 -1
  112. package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +45 -1
  113. package/docs/guidelines/agent-infra/false-green.md +47 -1
  114. package/docs/guidelines/agent-infra/traceability-field-mechanics.md +50 -0
  115. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +17 -0
  116. package/package.json +7 -3
  117. package/src/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
  118. package/src/agent-src/scripts/dashboard_mode.ts +212 -0
  119. package/src/agent-src/scripts/update_roadmap_progress.ts +47 -48
  120. package/src/config/ci-local-parity.yml +21 -0
  121. package/src/config/estate-count-budget.json +13 -471
  122. package/src/config/evaluator-budgets.json +4 -4
  123. package/src/config/gate-coverage.yml +458 -12
  124. package/src/config/gate-violation-baselines.json +18 -8
  125. package/src/config/gitignore-block.txt +15 -2
  126. package/src/config/hook-latency-budget.json +5 -0
  127. package/src/config/publish-surface.json +60 -0
  128. package/src/config/requirements-trace-budget.json +32 -0
  129. package/src/config/rule-stub-ceilings.json +25 -4
  130. package/src/install/globalRuleLayers.ts +197 -0
  131. package/src/install/partitionEligibility.ts +177 -0
  132. package/src/install/ruleLayerPartition.ts +212 -0
  133. package/src/scripts/_cli/cmd_adr_effective.ts +484 -0
  134. package/src/scripts/_dispatch.bash +18 -0
  135. package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
  136. package/src/scripts/_lib/adr_frontmatter.ts +459 -0
  137. package/src/scripts/_lib/base_tree.ts +175 -0
  138. package/src/scripts/_lib/billing_grant.ts +165 -0
  139. package/src/scripts/_lib/billing_grant_cli.ts +123 -0
  140. package/src/scripts/_lib/council_fallback_posture.ts +53 -6
  141. package/src/scripts/_lib/council_fallback_wiring.ts +61 -1
  142. package/src/scripts/_lib/envelope_grounding.ts +11 -1
  143. package/src/scripts/_lib/host_capability.ts +59 -5
  144. package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
  145. package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
  146. package/src/scripts/_lib/quota_parked.ts +133 -0
  147. package/src/scripts/_lib/retrieval_sanitize.ts +62 -0
  148. package/src/scripts/_lib/review_telemetry.ts +196 -0
  149. package/src/scripts/_lib/subagent_response.ts +92 -0
  150. package/src/scripts/adr/evidence_census.ts +899 -0
  151. package/src/scripts/adr/regenerate_index.ts +25 -38
  152. package/src/scripts/adr_cite_check.ts +540 -39
  153. package/src/scripts/ai_council/clients.ts +12 -1
  154. package/src/scripts/ai_council/config.ts +72 -115
  155. package/src/scripts/ai_council/fallback_config.ts +19 -7
  156. package/src/scripts/ai_council/mid_flight_fallback.ts +88 -7
  157. package/src/scripts/ai_council/orchestrator.ts +2 -2
  158. package/src/scripts/ai_council/py_format.ts +124 -0
  159. package/src/scripts/ai_council/transport_resolver.ts +65 -2
  160. package/src/scripts/audit_adr_coverage.ts +134 -21
  161. package/src/scripts/bench_hook_latency.ts +204 -0
  162. package/src/scripts/build_archive_index.ts +34 -0
  163. package/src/scripts/check_adr_frontmatter.ts +574 -39
  164. package/src/scripts/check_council_pin_staleness.ts +300 -0
  165. package/src/scripts/check_estate_count.ts +420 -293
  166. package/src/scripts/check_gate_coverage.ts +91 -0
  167. package/src/scripts/check_hook_bundle_content.ts +219 -0
  168. package/src/scripts/check_new_adr_evidence.ts +529 -0
  169. package/src/scripts/check_no_stub_inventory_table.ts +292 -0
  170. package/src/scripts/check_pack_size.ts +234 -1
  171. package/src/scripts/check_publish_surface.ts +297 -0
  172. package/src/scripts/check_references.ts +18 -0
  173. package/src/scripts/check_requirements_trace.ts +378 -0
  174. package/src/scripts/check_rule_layer_partition.ts +531 -0
  175. package/src/scripts/check_secret_leak.ts +166 -12
  176. package/src/scripts/check_single_delivery.ts +137 -4
  177. package/src/scripts/check_standing_rule_delivery.ts +8 -2
  178. package/src/scripts/code_graph/cli.ts +44 -5
  179. package/src/scripts/code_graph/extract.ts +87 -0
  180. package/src/scripts/condense.ts +61 -91
  181. package/src/scripts/council_cli.ts +12 -16
  182. package/src/scripts/detect_ai_tells.ts +87 -0
  183. package/src/scripts/dispatch_r2_reviewer.ts +42 -0
  184. package/src/scripts/external_sources_denylist.json +4 -1
  185. package/src/scripts/generate_capabilities_index.ts +41 -0
  186. package/src/scripts/git_authorization_hook.ts +54 -4
  187. package/src/scripts/hooks/block_unauthorized_git.ts +41 -3
  188. package/src/scripts/hooks/dispatch_hook.ts +154 -187
  189. package/src/scripts/hooks/dispatch_issues.ts +38 -19
  190. package/src/scripts/hooks/fallback_yaml.ts +66 -0
  191. package/src/scripts/hooks/host_semantics.ts +21 -0
  192. package/src/scripts/hooks/injection_budget.ts +13 -3
  193. package/src/scripts/hooks/py_json_dumps.ts +103 -0
  194. package/src/scripts/hooks/run_continuation_hook.ts +3 -1
  195. package/src/scripts/hooks/state_io.ts +88 -1
  196. package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
  197. package/src/scripts/hooks/subagent_ledger_hook.ts +76 -11
  198. package/src/scripts/injection_scan_hook.ts +257 -15
  199. package/src/scripts/lint_adr_sweep_routing.ts +365 -0
  200. package/src/scripts/lint_hook_manifest.ts +49 -0
  201. package/src/scripts/lint_provenance_vocabulary.ts +472 -4
  202. package/src/scripts/lint_workflow_security.ts +53 -10
  203. package/src/scripts/lint_workflow_security_allowlist.json +2 -18
  204. package/src/scripts/report_envelope_rate.ts +166 -0
  205. package/src/scripts/review_axis_report.ts +218 -0
  206. package/src/scripts/routing_doctor.ts +5 -1
  207. package/src/scripts/run_supervise.ts +46 -1
  208. package/src/scripts/schemas/evals.schema.json +5 -0
  209. package/src/scripts/sync_pr_branch.ts +170 -20
  210. package/dist/cli/commands/commands.candidates.test.js +0 -257
  211. package/dist/cli/commands/commands.candidates.test.js.map +0 -1
  212. package/dist/cli/commands/profiles.test.js +0 -88
  213. package/dist/cli/commands/profiles.test.js.map +0 -1
  214. package/dist/cli/commands/recordTriggerEval.test.js +0 -113
  215. package/dist/cli/commands/recordTriggerEval.test.js.map +0 -1
  216. package/dist/cli/configRoot.test.js +0 -97
  217. package/dist/cli/configRoot.test.js.map +0 -1
  218. package/dist/cli/mcp/content.test.js +0 -54
  219. package/dist/cli/mcp/content.test.js.map +0 -1
  220. package/dist/cli/mcp/dispatch.test.js +0 -108
  221. package/dist/cli/mcp/dispatch.test.js.map +0 -1
  222. package/dist/server/writeRoot.test.js +0 -91
  223. package/dist/server/writeRoot.test.js.map +0 -1
  224. package/dist/shared/capabilities.test.js +0 -39
  225. package/dist/shared/capabilities.test.js.map +0 -1
  226. package/dist/ui/assets/index-C1pBRm1f.js +0 -26
  227. package/dist/ui/assets/index-C1pBRm1f.js.map +0 -1
  228. package/dist/cli-delegate/{chunk-PPQO4NGR.js → chunk-6RJA2F5A.js} +3 -3
  229. package/dist/cli-delegate/{chunk-6EOCNQAM.js → chunk-E4XIZDIB.js} +0 -0
  230. package/dist/cli-delegate/{chunk-GJ5BVHXM.js → chunk-PMEKGDED.js} +3 -3
@@ -89,6 +89,19 @@ export interface GateSpec {
89
89
  status: 'enforced' | 'pending';
90
90
  /** Absent = this gate has no canary recipe; reported, never hidden. */
91
91
  canary?: CanarySpec;
92
+ /**
93
+ * Why this entry has no `canary:` recipe — REQUIRED when one is absent.
94
+ *
95
+ * The measurement that forced this field: over 44 enforced entries, only
96
+ * 20 of 44 by a wide detector (and 13 by a narrow one) can be invoked over an
97
+ * injected path at all, so a non-mutating in-memory negative-control mode
98
+ * would cover under half the manifest. Rather than build a per-PR step named
99
+ * "negative controls" that exercises a minority, the gap is REPORTED — and a
100
+ * reported gap is only honest if every row carries its reason. A silently
101
+ * absent row reads as coverage, which is the exact failure this whole file
102
+ * exists to prevent, one level up.
103
+ */
104
+ no_canary_reason?: string;
92
105
  /**
93
106
  * Exit code meaning "my prerequisite is absent, so I inspected nothing" — e.g.
94
107
  * check_site_links exits 2 when the site is unbuilt or stale. Without this the
@@ -120,6 +133,13 @@ export interface GateResult {
120
133
  message: string;
121
134
  }
122
135
 
136
+ function _require_str(v: unknown, field: string, id: string, i: number): string {
137
+ if (typeof v !== 'string' || v.trim() === '') {
138
+ throw new Error(`gates[${String(i)}] (${id}): ${field} must be a non-empty string`);
139
+ }
140
+ return v;
141
+ }
142
+
123
143
  function _require_int(v: unknown, id: string, i: number): number {
124
144
  const n = Number(v);
125
145
  if (!Number.isInteger(n)) {
@@ -147,6 +167,16 @@ export function load_manifest(file = MANIFEST): GateSpec[] {
147
167
  if (!Number.isInteger(min) || min < 0) {
148
168
  throw new Error(`gates[${i}] (${id}): min_scanned must be a non-negative integer`);
149
169
  }
170
+ if (
171
+ String(e['status'] ?? 'enforced') === 'enforced' &&
172
+ e['canary'] === undefined &&
173
+ e['no_canary_reason'] === undefined
174
+ ) {
175
+ throw new Error(
176
+ `gates[${String(i)}] (${id}): an enforced entry with no 'canary:' recipe must carry ` +
177
+ `'no_canary_reason:' — a silently absent negative control reads as coverage`,
178
+ );
179
+ }
150
180
  const status = String(e['status'] ?? 'enforced');
151
181
  if (status !== 'enforced' && status !== 'pending') {
152
182
  throw new Error(`gates[${i}] (${id}): status must be 'enforced' or 'pending'`);
@@ -162,6 +192,9 @@ export function load_manifest(file = MANIFEST): GateSpec[] {
162
192
  : { unavailable_exit: _require_int(e['unavailable_exit'], id, i) }),
163
193
  ...(e['note'] === undefined ? {} : { note: String(e['note']) }),
164
194
  ...(e['canary'] === undefined ? {} : { canary: _require_canary(e['canary'], id, i) }),
195
+ ...(e['no_canary_reason'] === undefined
196
+ ? {}
197
+ : { no_canary_reason: _require_str(e['no_canary_reason'], 'no_canary_reason', id, i) }),
165
198
  };
166
199
  });
167
200
  }
@@ -338,6 +371,39 @@ const ICON: Record<Verdict, string> = {
338
371
  unavailable: '⚠️',
339
372
  };
340
373
 
374
+ /**
375
+ * Report which enforced gates carry a negative control and which do not.
376
+ *
377
+ * OBSERVABILITY, NEVER PROOF — and the distinction is the whole point of the
378
+ * wording below. A `canary:` recipe DECLARED is not a recipe RUN: the mutating
379
+ * `--canary` path plants a real file and is deliberately off the per-PR
380
+ * workflow, so on a normal PR the "with recipe" number describes what COULD be
381
+ * exercised, not what was. Reporting it as though it were coverage would be the
382
+ * inflation this file exists to prevent, one level up from where it prevents it.
383
+ *
384
+ * The two populations are reported separately on purpose: whether a gate can be
385
+ * invoked over an injected path and whether it has a recipe are different facts,
386
+ * and an earlier draft of this work conflated them.
387
+ */
388
+ function report_negative_control_inventory(specs: readonly GateSpec[]): void {
389
+ const enforced = specs.filter((s) => s.status === 'enforced');
390
+ const withRecipe = enforced.filter((s) => s.canary !== undefined);
391
+ const without = enforced.filter((s) => s.canary === undefined);
392
+ process.stdout.write(
393
+ `\nnegative controls: ${String(withRecipe.length)} of ${String(enforced.length)} enforced ` +
394
+ `entries carry a \`canary:\` recipe · ${String(without.length)} do not\n`,
395
+ );
396
+ // The arithmetic is the guard against skimming: two numbers that reconcile
397
+ // against the enforced total cannot be read as "all of them checked".
398
+ process.stdout.write(
399
+ ` ⚠️ declared, not run — the mutating --canary path is operator-invoked and off the per-PR workflow, ` +
400
+ `so no negative control ran here.\n`,
401
+ );
402
+ for (const s of without) {
403
+ process.stdout.write(` · ${s.id}: ${s.no_canary_reason ?? '(no reason recorded)'}\n`);
404
+ }
405
+ }
406
+
341
407
  export function main(argv: readonly string[]): number {
342
408
  const wantJson = argv.includes('--format') && argv[argv.indexOf('--format') + 1] === 'json';
343
409
  const listOnly = argv.includes('--list');
@@ -455,6 +521,7 @@ export function main(argv: readonly string[]): number {
455
521
  `⚠️ ${String(pending.length)} gate(s) are listed but NOT enforced — this guard's coverage is partial by declaration.\n`,
456
522
  );
457
523
  }
524
+ report_negative_control_inventory(specs);
458
525
  const hardening = report_hardening_ratchet();
459
526
  const selfTest = report_self_test_ratchet();
460
527
  if (failed.length > 0) {
@@ -771,6 +838,30 @@ export function parse_census(md: string): Map<string, number | null> {
771
838
  if (gateCol < 0 || cs.length <= Math.max(gateCol, unitCol)) continue;
772
839
  const idm = /`([A-Za-z0-9_.-]+)`/.exec(cs[gateCol] as string);
773
840
  if (idm === null) continue;
841
+ // A row the census could not extract a root for is NOT a row that read
842
+ // nothing — the two are indistinguishable in the units column, which prints
843
+ // `**0**` for both, and conflating them produced a false `census_stale`
844
+ // disagreement on every dynamically-scoped gate. `check_secret_leak`
845
+ // resolves its corpus by spawning `git diff` / `npm pack`, and
846
+ // `check_no_roadmap_refs` likewise: a static root extractor finds nothing to
847
+ // count, so the canary correctly reds them while the census correctly says
848
+ // 0, and the comparison then calls a correct pair a defect.
849
+ //
850
+ // `null` already means "no measurable count" here and is already skipped by
851
+ // the disagreement check, so this maps the un-extractable case onto the
852
+ // meaning it always had.
853
+ if (/\(none extracted\)/.test(line)) {
854
+ // Left UNSET, not set to `null`. The disagreement check treats
855
+ // `undefined` as "not censused — nothing to disagree with" and `null` as
856
+ // "censused and read nothing", which is a stale row. An un-extractable
857
+ // root is the first, not the second: nothing was measured, so there is no
858
+ // measurement to disagree with the canary about.
859
+ //
860
+ // Setting `null` here was tried first and made it WORSE — it turned every
861
+ // dynamically-scoped gate into a fresh false `census_stale`, six of them
862
+ // at once. The distinction between the two absences is the whole fix.
863
+ continue;
864
+ }
774
865
  const raw = cs[unitCol] as string;
775
866
  const num = /(\d[\d,]*)/.exec(raw.replace(/\*/g, ''));
776
867
  out.set(idm[1] as string, num === null ? null : Number((num[1] as string).replace(/,/g, '')));
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Content-equivalence gate for the self-hosted hook bundle.
4
+ *
5
+ * WHY THIS EXISTS — measured, not hypothetical.
6
+ *
7
+ * `check_hook_bundle_freshness` compares **mtimes**: every bundled hook source
8
+ * must be older than `dist/hooks/dispatch.js`. That establishes ordering, not
9
+ * equivalence. An mtime-preserving edit, a `touch` on the bundle, a restore
10
+ * from an archive, or a filesystem with coarse timestamp resolution all make
11
+ * the inference false while the check stays green.
12
+ *
13
+ * On 2026-08-21 a live run edited `LEDGER_MAX_AGE_MS` in
14
+ * `block_unauthorized_git.ts`, did not rebuild, and the guard kept enforcing
15
+ * the old value; all three copies (source, repo bundle, global install) were
16
+ * inspected by hand before anyone noticed. **The freshness gate did not miss
17
+ * that one — it is pre-push and was never run.** Saying it "stayed green" would
18
+ * be a plausible mechanism standing in for the observed one, which is the
19
+ * failure this repository's own evidence discipline names.
20
+ *
21
+ * What the mtime check genuinely cannot see is narrower and is the reason this
22
+ * gate exists: the mtime check cannot see an edit that does not move the mtime — an
23
+ mtime-preserving write, a `touch` on the bundle, an archive or `cp -p` restore,
24
+ or a filesystem whose timestamp resolution is coarser than the edit interval.
25
+ Demonstrated on this tree rather than argued: an mtime-preserving edit to one
26
+ constant left the bundle at 1 139 302 bytes with an untouched mtime, the
27
+ freshness gate passed, and the digest differed.
28
+ *
29
+ * This gate answers the question directly — are the bytes that execute the
30
+ * bytes this source produces?
31
+ *
32
+ * WHAT IT CANNOT DO, STATED PLAINLY.
33
+ *
34
+ * `dist/hooks/` is **untracked** — `git ls-files dist/hooks/` returns zero.
35
+ * There is no committed artefact to compare against, so this gate can only
36
+ * compare the bundle **present on this machine** against a fresh rebuild from
37
+ * this checkout's sources. In CI a fresh checkout has no bundle at all, so
38
+ * this gate declares a loud no-op there and a green CI says nothing about the
39
+ * bundle a maintainer is actually running. That asymmetry is the finding, not
40
+ * a gap to paper over: the bundle is a local execution artefact and only a
41
+ * local gate can see it.
42
+ *
43
+ * The esbuild invocation is NOT duplicated here — it is read from
44
+ * `package.json`'s `build:hooks` script with only `--outfile=` rewritten, via
45
+ * the same `rewriteOutfile` helper `rebuild_hook_bundle` uses, so the flag set
46
+ * has exactly one home.
47
+ *
48
+ * DETERMINISM — measured, not assumed. Two consecutive rebuilds of an
49
+ * unchanged tree produced the identical sha256 (`616561fb…`, 2026-08-21), and
50
+ * a one-constant edit that left the output byte-count unchanged
51
+ * (1 139 302 bytes both times) still produced a different digest. So the
52
+ * comparison is byte-exact with no canonicalisation, and it is sensitive to
53
+ * the smallest semantic change. Had the rebuild NOT been reproducible, that
54
+ * would have been a release-integrity defect to fix rather than a reason to
55
+ * fall back to timestamps.
56
+ *
57
+ * Exit codes: 0 = equivalent, or no bundle to check · 1 = the executing bundle
58
+ * is not what this source produces · 2 = the rebuild itself failed.
59
+ */
60
+ // ledger-exempt: single-artefact transaction — one rebuild compared against one
61
+ // bundle, yielding one aggregate verdict. The per-source accounting lives in
62
+ // check_hook_bundle_freshness, which carries the ledger.
63
+ import { spawnSync } from "node:child_process";
64
+ import * as crypto from "node:crypto";
65
+ import * as fs from "node:fs";
66
+ import * as path from "node:path";
67
+ import { fileURLToPath } from "node:url";
68
+
69
+ import { reportScanned } from "./_lib/scan_scope.js";
70
+ import { rewriteOutfile } from "./rebuild_hook_bundle.js";
71
+
72
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
73
+ const REPO_ROOT = path.resolve(HERE, "..", "..");
74
+ const LIVE_REL = path.join("dist", "hooks", "dispatch.js");
75
+ // Suffixed with the pid: two concurrent invocations in one checkout (the
76
+ // pre-push gate plus a manual run) otherwise share one path, the first
77
+ // `finally` deletes the other's output, and the survivor reports "rebuild
78
+ // failed" off a missing file — a false red on a pre-push gate, which is the
79
+ // failure mode the PATH injection below exists to avoid.
80
+ const PROBE_REL = path.join("dist", "hooks", `dispatch.content-check.${String(process.pid)}.js`);
81
+
82
+ /** sha256 of a file's bytes, hex. */
83
+ export function digestOf(file: string): string {
84
+ return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
85
+ }
86
+
87
+ /**
88
+ * Read `build:hooks` out of package.json. Returns null when the script is
89
+ * absent — a renamed build script must fail loudly, never be guessed at.
90
+ */
91
+ export function buildScript(repoRoot: string): string | null {
92
+ const pkgPath = path.join(repoRoot, "package.json");
93
+ if (!fs.existsSync(pkgPath)) return null;
94
+ const pkg: unknown = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
95
+ const scripts =
96
+ typeof pkg === "object" && pkg !== null ? (pkg as Record<string, unknown>)["scripts"] : null;
97
+ if (typeof scripts !== "object" || scripts === null) return null;
98
+ const s = (scripts as Record<string, unknown>)["build:hooks"];
99
+ return typeof s === "string" ? s : null;
100
+ }
101
+
102
+ export function main(root: string = REPO_ROOT): number {
103
+ const live = path.join(root, LIVE_REL);
104
+ if (!fs.existsSync(live)) {
105
+ // Loud no-op, matching the sibling gates: a fresh checkout or CI has no
106
+ // bundle, and materialising one here would change what other gates see.
107
+ process.stdout.write(
108
+ `check_hook_bundle_content: no self-hosted ${LIVE_REL} — nothing executes locally, nothing to compare\n`,
109
+ );
110
+ reportScanned({
111
+ gate: "check_hook_bundle_content",
112
+ scanned: 0,
113
+ units: "bundle(s)",
114
+ roots: [LIVE_REL],
115
+ allowEmpty:
116
+ "OPTIONAL_INPUT: dist/hooks/ is UNTRACKED (`git ls-files dist/hooks/` returns zero), so a fresh " +
117
+ "checkout or a CI runner has no bundle by construction. An empty scope here is the " +
118
+ "correct reading of the environment, not a dead scan root — the artefact this gate " +
119
+ "guards is a local build output and exists only where the hooks actually run.",
120
+ });
121
+ return 0;
122
+ }
123
+
124
+ const script = buildScript(root);
125
+ if (script === null) {
126
+ process.stderr.write(
127
+ "check_hook_bundle_content: package.json has no `build:hooks` script — refusing to guess the flag set\n",
128
+ );
129
+ return 2;
130
+ }
131
+ const rewritten = rewriteOutfile(script, PROBE_REL);
132
+ if (rewritten === null) {
133
+ process.stderr.write(
134
+ `check_hook_bundle_content: \`build:hooks\` no longer writes ${LIVE_REL} — refusing to build to a guessed path\n`,
135
+ );
136
+ return 2;
137
+ }
138
+
139
+ const probe = path.join(root, PROBE_REL);
140
+ try {
141
+ // PATH is injected exactly as `rebuild_hook_bundle` does it, and for the
142
+ // same reason: `build:hooks` starts with a bare `esbuild`, so inheriting an
143
+ // ambient PATH without `node_modules/.bin` makes this gate report "rebuild
144
+ // failed" on a bundle that is in fact correct — a false red on a pre-push
145
+ // gate, which is how a pre-push gate teaches people to skip it.
146
+ const built = spawnSync("sh", ["-c", rewritten], {
147
+ cwd: root,
148
+ encoding: "utf8",
149
+ env: {
150
+ ...process.env,
151
+ PATH: `${path.join(root, "node_modules", ".bin")}:${process.env["PATH"] ?? ""}`,
152
+ },
153
+ });
154
+ if (built.status !== 0 || !fs.existsSync(probe)) {
155
+ process.stderr.write(
156
+ `check_hook_bundle_content: rebuild failed (exit ${String(built.status)})\n${built.stderr ?? ""}\n`,
157
+ );
158
+ return 2;
159
+ }
160
+
161
+ const liveDigest = digestOf(live);
162
+ const probeDigest = digestOf(probe);
163
+ if (liveDigest === probeDigest) {
164
+ process.stdout.write(
165
+ `✅ ${LIVE_REL} is byte-identical to a rebuild from this source (sha256 ${liveDigest.slice(0, 12)})\n`,
166
+ );
167
+ reportScanned({
168
+ gate: "check_hook_bundle_content",
169
+ scanned: 1,
170
+ units: "bundle(s)",
171
+ roots: [LIVE_REL],
172
+ });
173
+ return 0;
174
+ }
175
+
176
+ process.stderr.write(
177
+ `❌ ${LIVE_REL} is NOT what this source produces.\n` +
178
+ ` executing: sha256 ${liveDigest.slice(0, 12)} (${String(fs.statSync(live).size)} bytes)\n` +
179
+ ` rebuilt: sha256 ${probeDigest.slice(0, 12)} (${String(fs.statSync(probe).size)} bytes)\n` +
180
+ ` The mtime check cannot see this — it compares ordering, not bytes.\n` +
181
+ ` Heal with: ./scripts-run src/scripts/rebuild_hook_bundle\n`,
182
+ );
183
+ reportScanned({
184
+ gate: "check_hook_bundle_content",
185
+ scanned: 1,
186
+ units: "bundle(s)",
187
+ roots: [LIVE_REL],
188
+ });
189
+ return 1;
190
+ } finally {
191
+ try {
192
+ fs.rmSync(probe, { force: true });
193
+ } catch {
194
+ // Best effort — a leftover probe file is untracked build output.
195
+ }
196
+ }
197
+ }
198
+
199
+ // Resolved on BOTH sides with `realpathSync`, mirroring `rebuild_hook_bundle`.
200
+ // A string compare against `import.meta.url` is false under a symlinked
201
+ // checkout, and under any repo path containing a space or a non-ASCII
202
+ // character, which `import.meta.url` percent-encodes and `path.resolve` does
203
+ // not. The failure is silent: `main()` never runs, the process exits 0, and
204
+ // the gate reports nothing — a green on an invariant it never evaluated,
205
+ // which is the exact class this gate exists to remove.
206
+ const invokedDirectly = ((): boolean => {
207
+ const entry = process.argv[1];
208
+ if (entry === undefined) return false;
209
+ try {
210
+ return fs.realpathSync(path.resolve(entry)) === fs.realpathSync(fileURLToPath(import.meta.url));
211
+ } catch {
212
+ return false;
213
+ }
214
+ })();
215
+
216
+ if (invokedDirectly) {
217
+ process.exit(main());
218
+ }
219
+