@event4u/agent-config 16.1.0 → 16.3.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 (467) 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 +98 -257
  5. package/README.md +14 -12
  6. package/dist/agent-src/commands/analyze/inbox.md +1 -1
  7. package/dist/agent-src/commands/analyze/roadmap-repos.md +1 -1
  8. package/dist/agent-src/commands/bug/fix.md +17 -2
  9. package/dist/agent-src/commands/feature/plan.md +1 -1
  10. package/dist/agent-src/commands/feature/roadmap.md +1 -1
  11. package/dist/agent-src/commands/fix/ci.md +1 -1
  12. package/dist/agent-src/commands/implement-ticket.md +1 -1
  13. package/dist/agent-src/commands/jira-ticket.md +1 -1
  14. package/dist/agent-src/commands/roadmap/create.md +1 -1
  15. package/dist/agent-src/commands/roadmap/materialize.md +1 -1
  16. package/dist/agent-src/commands/roadmap/process-full.md +5 -5
  17. package/dist/agent-src/commands/roadmap/resolve-blockers.md +361 -0
  18. package/dist/agent-src/commands/roadmap/triage-parked.md +325 -0
  19. package/dist/agent-src/commands/roadmap.md +18 -4
  20. package/dist/agent-src/commands/tests/create.md +17 -3
  21. package/dist/agent-src/commands/tests/execute.md +22 -5
  22. package/dist/agent-src/commands/work.md +4 -0
  23. package/dist/agent-src/contexts/authority/decision-revisit-gate-enforcement.md +43 -0
  24. package/dist/agent-src/contexts/communication/rules-auto/design-fidelity-enforcement.md +24 -0
  25. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +6 -6
  26. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +4 -8
  27. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +32 -2
  28. package/dist/agent-src/contexts/execution/session-canary-enforcement-history.md +67 -0
  29. package/dist/agent-src/contexts/execution/toolchain-resolver.md +101 -7
  30. package/dist/agent-src/rules/active-remediation.md +1 -1
  31. package/dist/agent-src/rules/autonomous-execution.md +1 -0
  32. package/dist/agent-src/rules/broken-access-control.md +1 -0
  33. package/dist/agent-src/rules/command-suggestion-policy.md +1 -0
  34. package/dist/agent-src/rules/context-hygiene.md +1 -1
  35. package/dist/agent-src/rules/decision-revisit-gate.md +16 -33
  36. package/dist/agent-src/rules/design-fidelity.md +13 -16
  37. package/dist/agent-src/rules/doc-screenshot-hygiene.md +1 -0
  38. package/dist/agent-src/rules/domain-safety-disclaimer.md +1 -0
  39. package/dist/agent-src/rules/domain-safety-pii.md +1 -0
  40. package/dist/agent-src/rules/domain-safety-retention.md +1 -0
  41. package/dist/agent-src/rules/engineering-safety-floor.md +1 -0
  42. package/dist/agent-src/rules/evaluator-independence.md +3 -2
  43. package/dist/agent-src/rules/fast-path-marker-visibility.md +1 -0
  44. package/dist/agent-src/rules/finance-safety-floor.md +1 -0
  45. package/dist/agent-src/rules/git-history-discipline.md +1 -0
  46. package/dist/agent-src/rules/image-likeness-and-rights.md +1 -0
  47. package/dist/agent-src/rules/legal-safety-floor.md +3 -2
  48. package/dist/agent-src/rules/lethal-trifecta-guard.md +1 -0
  49. package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +1 -0
  50. package/dist/agent-src/rules/media-governance-routing.md +14 -17
  51. package/dist/agent-src/rules/persona-governance.md +2 -2
  52. package/dist/agent-src/rules/provider-lifecycle-discipline.md +1 -1
  53. package/dist/agent-src/rules/question-not-instruction.md +1 -0
  54. package/dist/agent-src/rules/roadmap-progress-sync.md +1 -1
  55. package/dist/agent-src/rules/runtime-safety.md +1 -0
  56. package/dist/agent-src/rules/secret-vcs-guard.md +1 -0
  57. package/dist/agent-src/rules/security-sensitive-stop.md +3 -2
  58. package/dist/agent-src/rules/session-canary.md +8 -62
  59. package/dist/agent-src/rules/settings-ask-protocol.md +1 -0
  60. package/dist/agent-src/rules/source-confidentiality.md +1 -0
  61. package/dist/agent-src/rules/strategy-safety-floor.md +1 -0
  62. package/dist/agent-src/rules/tool-safety.md +1 -0
  63. package/dist/agent-src/rules/ui-audit-gate.md +1 -2
  64. package/dist/agent-src/rules/untrusted-input-defense.md +1 -0
  65. package/dist/agent-src/rules/user-interrupt-priority.md +1 -0
  66. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +115 -20
  67. package/dist/agent-src/scripts/deferral_carry.ts +339 -0
  68. package/dist/agent-src/scripts/update_roadmap_progress.ts +4 -1
  69. package/dist/agent-src/skills/accessibility-auditor/data/aria-patterns.csv +1 -1
  70. package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +2 -2
  71. package/dist/agent-src/skills/api-design/data/manifest.json +2 -2
  72. package/dist/agent-src/skills/api-testing/SKILL.md +17 -0
  73. package/dist/agent-src/skills/code-intelligence/SKILL.md +14 -2
  74. package/dist/agent-src/skills/design-review/SKILL.md +30 -4
  75. package/dist/agent-src/skills/design-review/references/verification-automation.md +32 -0
  76. package/dist/agent-src/skills/design-system-capture/references/import-procedure.md +1 -1
  77. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +18 -3
  78. package/dist/agent-src/skills/existing-ui-audit/references/component-taxonomy-detection.md +56 -0
  79. package/dist/agent-src/skills/fe-design/SKILL.md +1 -1
  80. package/dist/agent-src/skills/iconography/SKILL.md +1 -1
  81. package/dist/agent-src/skills/js-library-packaging/SKILL.md +2 -2
  82. package/dist/agent-src/skills/markitdown/SKILL.md +4 -0
  83. package/dist/agent-src/skills/monorepo-workspace/SKILL.md +1 -1
  84. package/dist/agent-src/skills/playwright-testing/SKILL.md +30 -6
  85. package/dist/agent-src/skills/quality-tools/SKILL.md +11 -1
  86. package/dist/agent-src/skills/quality-tools/references/go-tools.md +76 -0
  87. package/dist/agent-src/skills/quality-tools/references/python-tools.md +104 -0
  88. package/dist/agent-src/skills/review-routing/SKILL.md +2 -2
  89. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +1 -1
  90. package/dist/agent-src/skills/storybook-workshop/SKILL.md +3 -3
  91. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +1 -1
  92. package/dist/agent-src/skills/test-case-discovery/SKILL.md +7 -0
  93. package/dist/agent-src/skills/test-driven-development/SKILL.md +17 -0
  94. package/dist/agent-src/skills/test-performance/SKILL.md +19 -1
  95. package/dist/agent-src/skills/ui-component-architect/SKILL.md +25 -2
  96. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  97. package/dist/agent-src/templates/roadmaps.md +30 -0
  98. package/dist/agent-src/templates/scripts/work_engine/directives/ui/app_spec.ts +10 -4
  99. package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +302 -21
  100. package/dist/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +22 -3
  101. package/dist/agent-src/templates/scripts/work_engine/directives/ui/scaffold.ts +6 -1
  102. package/dist/agent-src/templates/scripts/work_engine/stack/runner.ts +774 -109
  103. package/dist/agent-src/templates/scripts/work_engine/state.ts +57 -7
  104. package/dist/agent-src/templates/scripts/work_engine/taxonomy/detect.ts +500 -0
  105. package/dist/agent-src/templates/scripts/work_engine/taxonomy/place.ts +183 -0
  106. package/dist/cli/python/knowledge_ingest.js +1 -1
  107. package/dist/cli/python/workspace_drive.js +1 -1
  108. package/dist/cli/registry.js +1 -1
  109. package/dist/cli-delegate/archive_completed_roadmaps.js +92 -12
  110. package/dist/cli-delegate/check_update_banner.js +12 -1
  111. package/dist/cli-delegate/chunk-523HCFYO.js +90 -0
  112. package/dist/cli-delegate/{chunk-M2DPGFZ4.js → chunk-6V6TA25H.js} +22 -152
  113. package/dist/cli-delegate/chunk-7IQE3D5S.js +191 -0
  114. package/dist/cli-delegate/{chunk-6KFEC3G7.js → chunk-CQWX336L.js} +1 -1
  115. package/dist/cli-delegate/chunk-DEIV7XP7.js +108 -0
  116. package/dist/cli-delegate/chunk-EEHDXSYD.js +2040 -0
  117. package/dist/cli-delegate/{chunk-P6ZHP5W2.js → chunk-GKE7ZVFA.js} +4 -4
  118. package/dist/cli-delegate/chunk-JQOM7TTM.js +1201 -0
  119. package/dist/cli-delegate/{chunk-UNCRJBT3.js → chunk-JQVYKIZA.js} +41 -0
  120. package/dist/cli-delegate/{chunk-FC75PGJE.js → chunk-JY2I2DXV.js} +12 -1
  121. package/dist/cli-delegate/chunk-KB5FZ655.js +208 -0
  122. package/dist/cli-delegate/{chunk-YUM5KURG.js → chunk-KR6EM23A.js} +235 -1455
  123. package/dist/cli-delegate/{chunk-LTYVIY76.js → chunk-LIYTLH6O.js} +1 -1
  124. package/dist/cli-delegate/chunk-NQ7WT43P.js +2729 -0
  125. package/dist/cli-delegate/chunk-NXEILF2K.js +2160 -0
  126. package/dist/cli-delegate/{chunk-5R7HOLAF.js → chunk-NYX3L2PZ.js} +1 -1
  127. package/dist/cli-delegate/{chunk-4E2W63DA.js → chunk-OKJUIAPS.js} +62 -1
  128. package/dist/cli-delegate/{chunk-QAJS5YWY.js → chunk-PHD7OOLE.js} +2 -1
  129. package/dist/cli-delegate/{chunk-RGNIWTEA.js → chunk-QLDHIPWR.js} +1 -1
  130. package/dist/cli-delegate/{chunk-HDVVGPQW.js → chunk-RQNNW7IF.js} +1405 -567
  131. package/dist/cli-delegate/{chunk-S3VFXMTM.js → chunk-S4UMZNES.js} +1 -1
  132. package/dist/cli-delegate/{chunk-6KM6ORRY.js → chunk-S4YWNRKO.js} +24 -8
  133. package/dist/cli-delegate/{chunk-GJCBB2UG.js → chunk-TRI6DQZ6.js} +3054 -325
  134. package/dist/cli-delegate/chunk-ULQRUR23.js +208 -0
  135. package/dist/cli-delegate/{chunk-NUHLI2RX.js → chunk-YCU3TIXH.js} +2 -0
  136. package/dist/cli-delegate/chunk-ZBZV2G5R.js +210 -0
  137. package/dist/cli-delegate/{chunk-WGXTVKCQ.js → chunk-ZUZKZYCN.js} +5 -3
  138. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  139. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  140. package/dist/cli-delegate/cmd_conformance.js +1862 -809
  141. package/dist/cli-delegate/cmd_doctor.js +13 -6
  142. package/dist/cli-delegate/cmd_doctor_neighbours.js +23 -0
  143. package/dist/cli-delegate/cmd_explain.js +5 -5
  144. package/dist/cli-delegate/cmd_export.js +11 -8
  145. package/dist/cli-delegate/cmd_fleet.js +3 -3
  146. package/dist/cli-delegate/cmd_handoff.js +10 -7
  147. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  148. package/dist/cli-delegate/cmd_memory_get.js +3 -1
  149. package/dist/cli-delegate/cmd_migrate.js +2 -2
  150. package/dist/cli-delegate/cmd_packs_active.js +3 -3
  151. package/dist/cli-delegate/cmd_preflight.js +6 -6
  152. package/dist/cli-delegate/cmd_prune.js +11 -8
  153. package/dist/cli-delegate/cmd_refresh.js +2 -1
  154. package/dist/cli-delegate/cmd_route_audit.js +8 -6
  155. package/dist/cli-delegate/cmd_route_explain.js +4 -4
  156. package/dist/cli-delegate/cmd_session_recycle.js +4 -3
  157. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  158. package/dist/cli-delegate/cmd_settings_migrate.js +11 -8
  159. package/dist/cli-delegate/cmd_settings_set.js +4 -4
  160. package/dist/cli-delegate/cmd_sync.js +11 -8
  161. package/dist/cli-delegate/cmd_uninstall.js +11 -8
  162. package/dist/cli-delegate/cmd_update.js +1 -1
  163. package/dist/cli-delegate/cmd_upgrade.js +3 -2
  164. package/dist/cli-delegate/cmd_validate.js +11 -8
  165. package/dist/cli-delegate/cmd_versions.js +1 -1
  166. package/dist/cli-delegate/deferral_carry.js +16 -0
  167. package/dist/cli-delegate/gate_execute.js +2 -2
  168. package/dist/cli-delegate/roadmap_gates.js +5 -5
  169. package/dist/cli-delegate/update_roadmap_progress.js +1 -1
  170. package/dist/discovery/deprecation-report.md +1 -1
  171. package/dist/discovery/discovery-manifest.json +132 -80
  172. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  173. package/dist/discovery/discovery-manifest.summary.md +3 -3
  174. package/dist/discovery/dormancy-report.md +1 -1
  175. package/dist/discovery/orphan-report.md +1 -1
  176. package/dist/discovery/packs.json +7 -5
  177. package/dist/discovery/trust-report.md +2 -2
  178. package/dist/discovery/workspaces.json +6 -4
  179. package/dist/hooks/dispatch.js +7972 -5186
  180. package/dist/hooks/dispatch.sha256 +1 -0
  181. package/dist/install/claudePathsPlan.js +47 -6
  182. package/dist/install/claudeRuleRewrite.js +7 -0
  183. package/dist/install/emit_host_rules_cli.js +39 -36
  184. package/dist/install/globalRuleScope.js +87 -0
  185. package/dist/install/host_rule_ownership.js +160 -0
  186. package/dist/install/install.mjs +2226 -1055
  187. package/dist/install/installThinLayer.js +261 -0
  188. package/dist/install/installedRuleLinks.js +176 -0
  189. package/dist/install/wizard-plan.js +24 -0
  190. package/dist/mcp/registry-manifest.json +3 -3
  191. package/dist/mcp/server.json +2 -2
  192. package/dist/mcp/server.mjs +40913 -34865
  193. package/dist/scripts/_lib/agent_settings.js +11 -0
  194. package/dist/scripts/_lib/claude_settings_hooks.js +10 -8
  195. package/dist/scripts/_lib/installed_layer.js +378 -0
  196. package/dist/scripts/_lib/lean_projection_mode.js +218 -15
  197. package/dist/scripts/_lib/rule_consequence_class.js +45 -0
  198. package/dist/scripts/_lib/rule_injection.js +67 -10
  199. package/dist/scripts/_lib/rule_law_section.js +122 -0
  200. package/dist/scripts/_lib/thin_rules.js +458 -0
  201. package/dist/scripts/_lib/windsurf_render.js +60 -0
  202. package/dist/scripts/condense.js +14 -26
  203. package/dist/scripts/project_thin_rules.js +14 -294
  204. package/dist/server/schemas/settings.js +29 -0
  205. package/dist/shared/skillRanking.js +74 -13
  206. package/dist/ui/assets/index-Cwq4d0hX.js +23 -0
  207. package/dist/ui/assets/index-Cyjy8bWO.css +1 -0
  208. package/dist/ui/index.html +2 -2
  209. package/docs/contracts/settings-classes.md +10 -6
  210. package/package.json +4 -4
  211. package/src/agent-src/scripts/archive_completed_roadmaps.ts +115 -20
  212. package/src/agent-src/scripts/deferral_carry.ts +339 -0
  213. package/src/agent-src/scripts/update_roadmap_progress.ts +4 -1
  214. package/src/agent-src/templates/scripts/work_engine/directives/ui/app_spec.ts +10 -4
  215. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +302 -21
  216. package/src/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +22 -3
  217. package/src/agent-src/templates/scripts/work_engine/directives/ui/scaffold.ts +6 -1
  218. package/src/agent-src/templates/scripts/work_engine/stack/runner.ts +774 -109
  219. package/src/agent-src/templates/scripts/work_engine/state.ts +57 -7
  220. package/src/agent-src/templates/scripts/work_engine/taxonomy/detect.ts +500 -0
  221. package/src/agent-src/templates/scripts/work_engine/taxonomy/place.ts +183 -0
  222. package/src/config/agent-settings.template.yml +91 -22
  223. package/src/config/ci-local-parity.yml +18 -0
  224. package/src/config/continuity-surface.json +9 -2
  225. package/src/config/evaluator-budgets.json +4 -4
  226. package/src/config/gate-coverage.yml +327 -3
  227. package/src/config/gate-violation-baselines.json +21 -9
  228. package/src/config/hook-bundle-budget.json +25 -0
  229. package/src/config/hook-latency-budget.json +21 -0
  230. package/src/config/hook-token-budget.json +8 -3
  231. package/src/config/host-instruction-limits.json +33 -0
  232. package/src/config/metric-registry.yml +114 -0
  233. package/src/config/placeholder-drift-budget.json +10 -0
  234. package/src/config/routing-coverage-seed.json +38 -3
  235. package/src/config/rule-activation-census.json +7 -3
  236. package/src/config/rule-consequence-class.json +161 -0
  237. package/src/config/rule-law-ceilings.json +99 -0
  238. package/src/install/claudePathsPlan.ts +46 -4
  239. package/src/install/claudeRuleRewrite.ts +10 -1
  240. package/src/install/emit_host_rules_cli.ts +43 -34
  241. package/src/install/globalRuleScope.ts +109 -0
  242. package/src/install/host_rule_ownership.ts +170 -0
  243. package/src/install/installThinLayer.ts +312 -0
  244. package/src/install/installedRuleLinks.ts +220 -0
  245. package/src/install/wizard-plan.ts +24 -0
  246. package/src/scripts/_cli/cmd_doctor.ts +28 -90
  247. package/src/scripts/_cli/cmd_doctor_neighbours.ts +293 -0
  248. package/src/scripts/_cli/doctor_execution.ts +100 -17
  249. package/src/scripts/_cli/doctor_network_posture.ts +226 -0
  250. package/src/scripts/_dispatch.bash +16 -2
  251. package/src/scripts/_lib/agent_settings.ts +11 -0
  252. package/src/scripts/_lib/body_portable.ts +102 -0
  253. package/src/scripts/_lib/cc_transcript.ts +132 -1
  254. package/src/scripts/_lib/claude_settings_hooks.ts +10 -8
  255. package/src/scripts/_lib/concern_sla_window.ts +200 -0
  256. package/src/scripts/_lib/estate_offsets.ts +241 -0
  257. package/src/scripts/_lib/exemption_shape.ts +143 -0
  258. package/src/scripts/_lib/fnv.ts +23 -0
  259. package/src/scripts/_lib/foreign_scan.ts +106 -0
  260. package/src/scripts/_lib/forge_protection.ts +114 -0
  261. package/src/scripts/_lib/forge_reader.ts +558 -0
  262. package/src/scripts/_lib/gitignore_block.ts +314 -0
  263. package/src/scripts/_lib/global_deploy_inventory.ts +32 -0
  264. package/src/scripts/_lib/graph_feeder_record.ts +214 -0
  265. package/src/scripts/_lib/hook_settings.ts +73 -0
  266. package/src/scripts/_lib/host_capability.ts +40 -19
  267. package/src/scripts/_lib/host_env_write.ts +111 -0
  268. package/src/scripts/_lib/host_hook_merge.ts +165 -0
  269. package/src/scripts/_lib/installed_layer.ts +538 -0
  270. package/src/scripts/_lib/json_merge.ts +82 -0
  271. package/src/scripts/_lib/json_pointers.ts +93 -11
  272. package/src/scripts/_lib/lean_projection_mode.ts +275 -15
  273. package/src/scripts/_lib/llm_proposer_transport.ts +14 -4
  274. package/src/scripts/_lib/machine_wake.ts +16 -3
  275. package/src/scripts/_lib/neighbour_census.ts +760 -0
  276. package/src/scripts/_lib/neighbour_scan.ts +122 -0
  277. package/src/scripts/_lib/neighbour_tool_use.ts +189 -0
  278. package/src/scripts/_lib/obligations.ts +119 -8
  279. package/src/scripts/_lib/probe_inputs.ts +271 -0
  280. package/src/scripts/_lib/prompt_shape.ts +46 -0
  281. package/src/scripts/_lib/read_surface_scan.ts +322 -0
  282. package/src/scripts/_lib/release_highlights.ts +2 -1
  283. package/src/scripts/_lib/release_migration_gate.ts +19 -3
  284. package/src/scripts/_lib/reserved_name_sweep.ts +73 -0
  285. package/src/scripts/_lib/retrieval_sanitize.ts +77 -16
  286. package/src/scripts/_lib/review_coverage.ts +134 -0
  287. package/src/scripts/_lib/rule_consequence_class.ts +71 -0
  288. package/src/scripts/_lib/rule_injection.ts +130 -10
  289. package/src/scripts/_lib/rule_law_section.ts +147 -0
  290. package/src/scripts/_lib/rule_layer_overlap.ts +58 -0
  291. package/src/scripts/_lib/shell_write_shapes.ts +108 -0
  292. package/src/scripts/_lib/skill_origin.ts +254 -0
  293. package/src/scripts/_lib/structural_hiding.ts +622 -0
  294. package/src/scripts/_lib/structured_ask.ts +64 -21
  295. package/src/scripts/_lib/thin_rules.ts +540 -0
  296. package/src/scripts/_lib/touched_file_quality.ts +434 -0
  297. package/src/scripts/_lib/turn_end_refusals.ts +600 -4
  298. package/src/scripts/_lib/turn_end_transcript.ts +216 -0
  299. package/src/scripts/_lib/verification_command.ts +324 -0
  300. package/src/scripts/_lib/verification_evidence.ts +647 -0
  301. package/src/scripts/_lib/verify_clause.ts +280 -0
  302. package/src/scripts/_lib/windsurf_render.ts +63 -0
  303. package/src/scripts/ai_council/cli_call_budget.ts +13 -5
  304. package/src/scripts/ai_council/clients.ts +26 -57
  305. package/src/scripts/ai_council/config.ts +1 -1
  306. package/src/scripts/ai_council/vendor_defaults.ts +45 -0
  307. package/src/scripts/ai_team/team_dispatch.ts +4 -0
  308. package/src/scripts/audit_skill_overlap.ts +261 -0
  309. package/src/scripts/before_complete_hook.ts +573 -25
  310. package/src/scripts/bench_hook_latency.ts +412 -5
  311. package/src/scripts/bench_touched_file_quality.ts +238 -0
  312. package/src/scripts/cache_realization_report.ts +61 -1
  313. package/src/scripts/check_argument_hint.ts +287 -0
  314. package/src/scripts/check_claims.ts +125 -1
  315. package/src/scripts/check_enforcement_coverage.ts +38 -9
  316. package/src/scripts/check_enforcement_matrix.ts +621 -22
  317. package/src/scripts/check_estate_count.ts +130 -153
  318. package/src/scripts/check_finding_dispositions.ts +138 -7
  319. package/src/scripts/check_generator_sync.ts +71 -0
  320. package/src/scripts/check_hook_bundle_composition.ts +370 -0
  321. package/src/scripts/check_host_docs_digest.ts +786 -0
  322. package/src/scripts/check_host_format_column.ts +559 -0
  323. package/src/scripts/check_input_references.ts +228 -0
  324. package/src/scripts/check_inputs_parity.ts +235 -0
  325. package/src/scripts/check_kernel_edit_ratified.ts +89 -9
  326. package/src/scripts/check_kill_switch_table.ts +294 -0
  327. package/src/scripts/check_memory.ts +7 -1
  328. package/src/scripts/check_placeholder_drift.ts +221 -0
  329. package/src/scripts/check_read_surface_coverage.ts +515 -0
  330. package/src/scripts/check_release_highlights.ts +10 -0
  331. package/src/scripts/check_release_holds.ts +2 -1
  332. package/src/scripts/check_release_pr_shape.ts +38 -15
  333. package/src/scripts/check_routing_coverage.ts +378 -11
  334. package/src/scripts/check_standing_rule_delivery.ts +33 -13
  335. package/src/scripts/check_static_parity.ts +17 -0
  336. package/src/scripts/check_verify_expectation_delta.ts +521 -0
  337. package/src/scripts/ci_settle.ts +145 -19
  338. package/src/scripts/closure_scan.ts +115 -1
  339. package/src/scripts/code_graph/detect.ts +127 -7
  340. package/src/scripts/code_graph/foreign.ts +194 -0
  341. package/src/scripts/code_graph/query.ts +217 -4
  342. package/src/scripts/code_graph/types.ts +26 -2
  343. package/src/scripts/code_graph/validate.ts +3 -1
  344. package/src/scripts/code_graph/verbs.ts +103 -0
  345. package/src/scripts/compile_hook_manifest.ts +90 -14
  346. package/src/scripts/condense.ts +14 -27
  347. package/src/scripts/estate_exemption_shape.ts +202 -0
  348. package/src/scripts/external_sources_denylist.json +29 -4
  349. package/src/scripts/generate_host_cost_table.ts +1 -1
  350. package/src/scripts/hook_manifest.json +1 -1
  351. package/src/scripts/hook_manifest.yaml +192 -14
  352. package/src/scripts/hooks/block_config_weakening.ts +294 -4
  353. package/src/scripts/hooks/block_kernel_rule_writes.ts +9 -78
  354. package/src/scripts/hooks/block_plumbing_writes.ts +342 -0
  355. package/src/scripts/hooks/bundle_integrity.ts +291 -0
  356. package/src/scripts/hooks/chain_nudge_hook.ts +1 -2
  357. package/src/scripts/hooks/code_graph_context_hook.ts +253 -25
  358. package/src/scripts/hooks/comment_discipline_hook.ts +1 -3
  359. package/src/scripts/hooks/concern_failure_policy.ts +280 -0
  360. package/src/scripts/hooks/concern_registry.ts +2 -0
  361. package/src/scripts/hooks/concern_timings.ts +94 -0
  362. package/src/scripts/hooks/delegation_nudge_hook.ts +1 -2
  363. package/src/scripts/hooks/design_pass_hook.ts +100 -9
  364. package/src/scripts/hooks/design_slop_hook.ts +1 -2
  365. package/src/scripts/hooks/dispatch_hook.ts +77 -71
  366. package/src/scripts/hooks/edit_shape_hook.ts +1 -2
  367. package/src/scripts/hooks/end_review_nudge_hook.ts +1 -1
  368. package/src/scripts/hooks/evidence_independence.ts +1 -2
  369. package/src/scripts/hooks/exit_codes.ts +129 -0
  370. package/src/scripts/hooks/host_lowering.json +1 -0
  371. package/src/scripts/hooks/host_lowering.ts +146 -3
  372. package/src/scripts/hooks/host_lowering.yaml +262 -46
  373. package/src/scripts/hooks/host_semantics.ts +30 -2
  374. package/src/scripts/hooks/injection_budget.ts +9 -2
  375. package/src/scripts/hooks/interruption_ledger_hook.ts +1 -2
  376. package/src/scripts/hooks/journal_record_hook.ts +1 -2
  377. package/src/scripts/hooks/memory_recall_hook.ts +1 -2
  378. package/src/scripts/hooks/obligation_settle_hook.ts +151 -5
  379. package/src/scripts/hooks/one_question_per_ask_hook.ts +43 -6
  380. package/src/scripts/hooks/orchestration_record_hook.ts +1 -2
  381. package/src/scripts/hooks/payload_stub.ts +10 -1
  382. package/src/scripts/hooks/permission_gate.ts +1 -2
  383. package/src/scripts/hooks/push_settle_hook.ts +1 -3
  384. package/src/scripts/hooks/replay_hook.ts +2 -1
  385. package/src/scripts/hooks/reread_guard_hook.ts +1 -2
  386. package/src/scripts/hooks/review_baseline_hook.ts +1 -2
  387. package/src/scripts/hooks/rtk_wrap_hook.ts +1 -2
  388. package/src/scripts/hooks/rule_inject_hook.ts +765 -68
  389. package/src/scripts/hooks/run_continuation_hook.ts +13 -15
  390. package/src/scripts/hooks/skill_route_hook.ts +1 -2
  391. package/src/scripts/hooks/source_first_gate_hook.ts +1 -2
  392. package/src/scripts/hooks/spawn_guard_shadow_hook.ts +1 -2
  393. package/src/scripts/hooks/subagent_ledger_hook.ts +1 -2
  394. package/src/scripts/hooks/suggestion_capture_hook.ts +1 -1
  395. package/src/scripts/hooks/table_fingerprint.ts +70 -0
  396. package/src/scripts/hooks/telemetry_flush_hook.ts +1 -2
  397. package/src/scripts/hooks/telemetry_usage_hook.ts +65 -10
  398. package/src/scripts/hooks/tool_result_bytes_hook.ts +93 -9
  399. package/src/scripts/hooks/turn_end_gate_hook.ts +421 -324
  400. package/src/scripts/hooks/ui_route_nudge_hook.ts +1 -2
  401. package/src/scripts/hooks_status.ts +112 -0
  402. package/src/scripts/injection_scan_hook.ts +7 -0
  403. package/src/scripts/install.ts +75 -189
  404. package/src/scripts/installed_layer_report.ts +103 -0
  405. package/src/scripts/language_mirror_hook.ts +15 -6
  406. package/src/scripts/learning_sidecar.ts +119 -8
  407. package/src/scripts/lint_deny_text.ts +419 -0
  408. package/src/scripts/lint_exit_codes.ts +262 -0
  409. package/src/scripts/lint_hook_manifest.ts +108 -0
  410. package/src/scripts/lint_major_migration_sections.ts +156 -26
  411. package/src/scripts/lint_pack_boundaries.ts +63 -5
  412. package/src/scripts/lint_roadmap_blockers.ts +78 -12
  413. package/src/scripts/lint_rule_law_section.ts +517 -0
  414. package/src/scripts/lint_skill_descriptions.ts +1 -1
  415. package/src/scripts/lint_skill_scripts_readonly.ts +1 -1
  416. package/src/scripts/lint_workflow_security.ts +161 -5
  417. package/src/scripts/mcp_server/consumer_tool_catalog.json +53 -0
  418. package/src/scripts/mcp_server/graph_tools.ts +72 -3
  419. package/src/scripts/mcp_server/prompts.ts +142 -1
  420. package/src/scripts/measure_routing_signal.ts +54 -16
  421. package/src/scripts/measure_skill_ranker_baseline.ts +515 -53
  422. package/src/scripts/measure_turn_end_gate.ts +193 -0
  423. package/src/scripts/memory_learn_hook.ts +91 -6
  424. package/src/scripts/memory_lookup.ts +1 -1
  425. package/src/scripts/model_rule_injection.ts +52 -5
  426. package/src/scripts/prepack-check.mjs +53 -0
  427. package/src/scripts/project_thin_rules.ts +34 -327
  428. package/src/scripts/release_publication.ts +13 -1
  429. package/src/scripts/report_evidence_temperature.ts +657 -0
  430. package/src/scripts/report_installed_rule_links.ts +157 -0
  431. package/src/scripts/report_invocation_surface.ts +383 -0
  432. package/src/scripts/report_label_agreement.ts +233 -0
  433. package/src/scripts/report_obligation_carriers.ts +699 -0
  434. package/src/scripts/report_obligation_mechanism.ts +230 -0
  435. package/src/scripts/report_obligation_writer_split.ts +236 -0
  436. package/src/scripts/report_single_token_triggers.ts +277 -0
  437. package/src/scripts/report_skill_menu_census.ts +208 -0
  438. package/src/scripts/report_skill_ranker_confusion.ts +442 -0
  439. package/src/scripts/report_standing_payload_by_host.ts +188 -15
  440. package/src/scripts/report_verification_record_coverage.ts +306 -0
  441. package/src/scripts/roadmap_verify_share.ts +213 -0
  442. package/src/scripts/rule_activation_census.ts +5 -2
  443. package/src/scripts/rule_trigger_eval.ts +9 -9
  444. package/src/scripts/schemas/command.schema.json +44 -0
  445. package/src/scripts/schemas/review-findings.schema.json +7 -2
  446. package/src/scripts/schemas/rule.schema.json +5 -0
  447. package/src/scripts/schemas/skill.schema.json +44 -0
  448. package/src/scripts/self_review_gate.ts +421 -17
  449. package/src/scripts/semantic_parity_census.ts +314 -0
  450. package/src/scripts/session_eol_report.ts +54 -1
  451. package/src/scripts/skill_tools/score_skill_relevance.ts +290 -25
  452. package/src/scripts/sweep_dead_scan_roots.ts +1 -1
  453. package/src/scripts/sweep_skill_ranker_signals.ts +129 -0
  454. package/src/scripts/sync_gitignore.ts +29 -235
  455. package/src/scripts/trigger_eval_grandfather.json +0 -7
  456. package/src/scripts/trigger_eval_rotation.ts +122 -27
  457. package/src/scripts/ui_conformance_probe.ts +156 -4
  458. package/src/scripts/update_prices.ts +85 -3
  459. package/src/scripts/write_bundle_digest.mjs +74 -0
  460. package/src/server/schemas/settings.ts +41 -0
  461. package/src/shared/skillRanking.ts +117 -11
  462. package/src/templates/consumer-settings/ONBOARDING.md +1 -0
  463. package/dist/cli-delegate/chunk-FXYUYU4N.js +0 -4416
  464. package/dist/cli-delegate/chunk-QJWAUMJW.js +0 -1072
  465. package/dist/cli-delegate/chunk-YDPILCOL.js +0 -152
  466. package/dist/ui/assets/index-DH1xc62v.js +0 -29
  467. package/dist/ui/assets/index-jSfMW8_t.css +0 -1
@@ -0,0 +1,786 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * check_host_docs_digest — the docs-drift watcher over `verified.docs_digest`.
4
+ *
5
+ * `host_lowering.yaml` records, per host, the page a row's answers were read
6
+ * off (`verified.docs_url`) and the day they were read (`docs_at`). Those two
7
+ * fields date the reading and nothing watches the page. A vendor can rewrite
8
+ * its hooks contract the day after a row is filled, and every gate in this tree
9
+ * stays green until the row's own `expires` lapses — up to a year later.
10
+ *
11
+ * `docs_digest` closes that window: the sha256 of the body as fetched by the
12
+ * last digest run. This script re-fetches each `docs_url` and compares.
13
+ *
14
+ * It is deliberately NOT "as fetched on `docs_at`". Those are two events — the
15
+ * reading a row's answers came from, and the hashing of a body — and the very
16
+ * first fill separated them (digests taken 2026-10-01 against rows read
17
+ * 2026-09-29). One sentence covering both asserted a provenance nobody had.
18
+ *
19
+ * WHAT A CHANGED DIGEST MEANS, AND WHAT IT DELIBERATELY DOES NOT.
20
+ * A changed body is evidence that the page moved, never a reading of what it
21
+ * now says. So the watcher adopts no upstream text, derives no slot, and
22
+ * changes no `block_exit`. The most it ever does is expire the row, which hands
23
+ * the question to `lint_hook_manifest._check_host_lowering` — a gate that
24
+ * already refuses an expired row carrying a blocking binding and only warns on
25
+ * one that does not. A red there opens a local finding for a human to read the
26
+ * page; it never encodes the page.
27
+ *
28
+ * "The most it EVER does", because in production it does less. The weekly job
29
+ * runs `--fetch` with no `--write`, so nothing is expired automatically: the
30
+ * job simply goes red. Expiry happens only when a human runs `--fetch --write`.
31
+ * An earlier version of this paragraph read as if escalation were automatic.
32
+ *
33
+ * WHY `expires` MOVES TO THE DAY BEFORE DETECTION, NOT TO THE DETECTION DATE.
34
+ * The table's own header states the rule and the reason: the gate tests
35
+ * `expires < today`, so `expires: <today>` is still valid TODAY and the refusal
36
+ * would not land until tomorrow. "Valid through yesterday" is how a row stops
37
+ * being current on the day the drift was seen. (The roadmap that asked for this
38
+ * watcher wrote `expires: <today>`; the table header is the later and correct
39
+ * statement, and this script follows the table.)
40
+ *
41
+ * WHY THE DIGEST IS NOT PART OF THE EXPIRY TEST ITSELF. A digest is evidence
42
+ * about the page; `expires` is this package's review-by date. Collapsing them
43
+ * would make a vendor's CSS rebuild read as a lapsed citation. The digest moves
44
+ * `expires`; it is never compared against a clock.
45
+ *
46
+ * MODES
47
+ * (no flags) Offline audit. Reports which rows can be watched, which
48
+ * carry no digest yet, and which carry no URL to watch.
49
+ * Touches the network never and exits 0 — it is a report,
50
+ * not a gate, so a CI job may run it with no egress.
51
+ * --fetch Re-fetch every `docs_url` and compare. Exit 1 if any
52
+ * digest CHANGED, 0 otherwise. This is the scheduled job's
53
+ * read-only mode: it NEVER touches the working tree. The
54
+ * table is written by exactly one `writeFileSync` call and
55
+ * it sits inside `if (doWrite)`, so `--fetch` alone cannot
56
+ * reach it; `--write` without `--fetch` is refused outright
57
+ * (exit 2) rather than silently doing nothing.
58
+ * --fetch --write The same, and write the result back into the YAML: fill a
59
+ * null digest, and on a CHANGED digest also pull `expires`
60
+ * back to the day before detection.
61
+ * --self-test Fixture round-trip over the pure functions. No network.
62
+ *
63
+ * A row whose `docs_url` is null is not a gap and is not an error: `cowork`
64
+ * has no public hooks page to cite, which the table says in its own comment.
65
+ * There is no body, so there is no digest, and the watcher reports it as
66
+ * `no-url` and moves on.
67
+ *
68
+ * UNREACHABLE IS NOT CHANGED. A fetch that fails — offline runner, 503, a
69
+ * timeout — establishes nothing about the page, so it never expires a row. It
70
+ * is reported, and with `--strict-fetch` it also fails the run, so a scheduled
71
+ * job can distinguish "nothing moved" from "nobody could look".
72
+ *
73
+ * GONE IS NOT UNREACHABLE. A 404/410 is the server answering authoritatively
74
+ * that the page is not there, which is evidence the citation is dead — the
75
+ * opposite of "nobody could look". It fails the run WITHOUT `--strict-fetch`,
76
+ * because the scheduled job omits that flag precisely so transient flakes stay
77
+ * quiet, and a dead citation keeping a stale digest and a year-long `expires`
78
+ * in silence is the failure this watcher exists to prevent. It still writes
79
+ * nothing: a human decides whether the page moved or the host dropped hooks.
80
+ */
81
+
82
+ import * as crypto from 'node:crypto';
83
+ import * as fs from 'node:fs';
84
+ import * as os from 'node:os';
85
+ import * as path from 'node:path';
86
+ import { fileURLToPath } from 'node:url';
87
+
88
+ import { asOf } from './_lib/as_of.js';
89
+ import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
90
+ import { sanitize_text } from './_lib/retrieval_sanitize.js';
91
+ import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
92
+ import { HOST_LOWERING_PATH, parseHostLowering, type HostLowering } from './hooks/host_lowering.js';
93
+
94
+ /** sha256, hex — the digest shape `verified.docs_digest` carries. */
95
+ export function digestOf(body: string): string {
96
+ return crypto.createHash('sha256').update(body, 'utf-8').digest('hex');
97
+ }
98
+
99
+ export type DigestState =
100
+ /** Row has a digest and the fetched body still hashes to it. */
101
+ | 'unchanged'
102
+ /** Row had a digest and the body now hashes to something else. */
103
+ | 'changed'
104
+ /** Row had no digest; this run establishes the first one. */
105
+ | 'filled'
106
+ /** Row cites no page — nothing to hash. Not a defect. */
107
+ | 'no-url'
108
+ /** The page could not be read — transient. Establishes nothing either way. */
109
+ | 'unreachable'
110
+ /**
111
+ * The page is GONE — the server answered, authoritatively, that it is not
112
+ * there (404/410).
113
+ *
114
+ * Split from `unreachable` because lumping them made a dead citation
115
+ * indistinguishable from a flaky one, and the scheduled job deliberately
116
+ * does not pass `--strict-fetch` so that flakes stay quiet. A removed page
117
+ * is the opposite of "nobody could look": it is strong evidence the
118
+ * citation is no longer valid, and a row whose page no longer exists must
119
+ * not keep a year-long `expires` and a stale digest in silence.
120
+ */
121
+ | 'gone';
122
+
123
+ export interface DigestFinding {
124
+ readonly host: string;
125
+ readonly surface: string;
126
+ readonly url: string | null;
127
+ readonly state: DigestState;
128
+ /** Digest recorded in the table before this run. */
129
+ readonly was: string | null;
130
+ /** Digest computed this run, or null when nothing was read. */
131
+ readonly now: string | null;
132
+ /** Why a fetch failed, for `unreachable`. */
133
+ readonly detail?: string | undefined;
134
+ }
135
+
136
+ /** The one state that moves `expires`. */
137
+ export function isDrift(f: DigestFinding): boolean {
138
+ return f.state === 'changed';
139
+ }
140
+
141
+ /** The day before `today`, in ISO form — see the header for why. */
142
+ export function dayBefore(today: string): string {
143
+ const d = new Date(`${today}T00:00:00Z`);
144
+ d.setUTCDate(d.getUTCDate() - 1);
145
+ return d.toISOString().slice(0, 10);
146
+ }
147
+
148
+ /**
149
+ * Classify one row against a body that was (or was not) read.
150
+ *
151
+ * Split from the fetching so the whole decision is testable with no network —
152
+ * the half that carries the risk is this one, and a test that could only reach
153
+ * it through a live HTTP call would be watching the wrong thing.
154
+ */
155
+ export function classify(
156
+ host: string,
157
+ surface: string,
158
+ url: string | null,
159
+ recorded: string | null,
160
+ body: string | null,
161
+ detail?: string,
162
+ ): DigestFinding {
163
+ if (url === null) {
164
+ return { host, surface, url, state: 'no-url', was: recorded, now: null };
165
+ }
166
+ if (body === null) {
167
+ // `gone` is decided by the CALLER (it saw the status code) and arrives
168
+ // as the detail prefix, because `classify` is pure and never fetches.
169
+ const state: DigestState = detail !== undefined && detail.startsWith('GONE ') ? 'gone' : 'unreachable';
170
+ return { host, surface, url, state, was: recorded, now: null, detail };
171
+ }
172
+ const now = digestOf(body);
173
+ if (recorded === null) {
174
+ return { host, surface, url, state: 'filled', was: null, now };
175
+ }
176
+ return { host, surface, url, state: recorded === now ? 'unchanged' : 'changed', was: recorded, now };
177
+ }
178
+
179
+ /** Every (host, surface) the table carries, with the URL and digest it records. */
180
+ export function watchable(
181
+ table: HostLowering,
182
+ ): ReadonlyArray<{ host: string; surface: string; url: string | null; digest: string | null }> {
183
+ const out: Array<{ host: string; surface: string; url: string | null; digest: string | null }> = [];
184
+ for (const [host, surfaces] of table) {
185
+ for (const [surface, row] of surfaces) {
186
+ if (row.verified === null) continue;
187
+ out.push({
188
+ host,
189
+ surface,
190
+ url: row.verified.docs_url,
191
+ digest: row.verified.docs_digest,
192
+ });
193
+ }
194
+ }
195
+ return out;
196
+ }
197
+
198
+ /**
199
+ * Write findings back into the YAML TEXT by editing the two lines that change.
200
+ *
201
+ * NOT a `parseDocument` round-trip, and the reason is measured rather than
202
+ * assumed. `parseDocument(...).toString()` on this table keeps every comment
203
+ * but renormalises the hand-aligned flow mappings in `slots:` — 361 bytes of
204
+ * deliberate column alignment across 32 rows, none of it this watcher's to
205
+ * touch. A scheduled job whose first write reformats a third of the file is a
206
+ * job whose diffs nobody reads. Editing the `docs_digest:` and `expires:` lines
207
+ * in place keeps the change to exactly the lines whose content changed, which
208
+ * is also what makes the resulting commit reviewable.
209
+ *
210
+ * The two lines are located structurally, not by a bare `grep`: the walk tracks
211
+ * the host (indent 2) and surface (indent 6) headers and only rewrites fields
212
+ * inside that row's `verified:` block, so a `docs_digest:` appearing in a
213
+ * comment or under a different host is never hit.
214
+ */
215
+ export function applyFindings(
216
+ yamlText: string,
217
+ findings: readonly DigestFinding[],
218
+ today: string,
219
+ ): { text: string; expired: string[]; written: string[]; missed: string[] } {
220
+ const wanted = new Map<string, DigestFinding>();
221
+ for (const f of findings) {
222
+ // `gone` belongs in this skip list and was missing from it, which made
223
+ // the one state that means "the citation is dead" the one state that
224
+ // ERASED the evidence: it fell through to the digest branch and wrote
225
+ // `docs_digest: null`. That is self-defeating as well as wrong — with a
226
+ // null digest and an untouched year-long `expires`, a page that later
227
+ // returned would classify as `filled` and the drift signal would be
228
+ // gone permanently. The module header already promised a `gone` row
229
+ // writes nothing; this is the line that makes the promise true.
230
+ if (f.state === 'no-url' || f.state === 'unreachable' || f.state === 'gone') continue;
231
+ wanted.set(`${f.host}/${f.surface}`, f);
232
+ }
233
+ if (wanted.size === 0) return { text: yamlText, expired: [], written: [], missed: [] };
234
+
235
+ const expired: string[] = [];
236
+ // Which rows a line was actually rewritten for. Without this the caller
237
+ // reported `filled` — "newly recorded" — for a row whose `verified:` block
238
+ // carries no `docs_digest:` key at all: the walk finds nothing to replace,
239
+ // writes nothing, and the run still claims success. A write claimed and not
240
+ // performed is worse than a loud failure, so the two sets are compared.
241
+ const written: string[] = [];
242
+ const lines = yamlText.split('\n');
243
+ let host: string | null = null;
244
+ let surface: string | null = null;
245
+ let inVerified = false;
246
+
247
+ for (let i = 0; i < lines.length; i += 1) {
248
+ const line = lines[i] as string;
249
+ const hostM = /^ {2}([A-Za-z0-9_-]+):\s*$/.exec(line);
250
+ if (hostM) {
251
+ host = hostM[1] as string;
252
+ surface = null;
253
+ inVerified = false;
254
+ continue;
255
+ }
256
+ const surfaceM = /^ {6}([A-Za-z0-9_-]+):\s*$/.exec(line);
257
+ if (surfaceM) {
258
+ surface = surfaceM[1] as string;
259
+ inVerified = false;
260
+ continue;
261
+ }
262
+ if (/^ {8}verified:\s*$/.test(line)) {
263
+ inVerified = true;
264
+ continue;
265
+ }
266
+ // Any other KEY at the row's own indent closes the block — but a
267
+ // comment at that indent does not. The table puts long findings in
268
+ // `#`-comments at every depth, and treating one as the block end would
269
+ // silently stop rewriting the fields below it: a no-op write that
270
+ // reports success, which is the worst shape a watcher can have.
271
+ if (inVerified && /^ {8}\S/.test(line) && !/^\s*#/.test(line)) {
272
+ inVerified = false;
273
+ continue;
274
+ }
275
+ if (!inVerified || host === null || surface === null) continue;
276
+
277
+ const f = wanted.get(`${host}/${surface}`);
278
+ if (f === undefined) continue;
279
+
280
+ // A DRIFTED ROW KEEPS ITS RECORDED DIGEST. Two reasons, and the second
281
+ // is the one that matters more.
282
+ //
283
+ // 1. Provenance. The digest names the body a human ESTABLISHED for
284
+ // this row. Silently replacing it with a body nobody read makes the
285
+ // field assert an establishment that never happened — a false
286
+ // statement in a table whose whole purpose is that its statements
287
+ // are true, and a quiet form of exactly the "adopts no upstream
288
+ // text" this module promises not to do. (Two earlier versions of
289
+ // this comment were themselves wrong about the field's definition;
290
+ // see `host_lowering.ts` for the three retired wordings and why
291
+ // `docs_at` and `docs_digest` are independent rather than a pair.)
292
+ // 2. The drift signal would erase itself. Overwriting the digest means
293
+ // the NEXT run compares new-against-new and reports `unchanged`, so
294
+ // a human who misses the one red week never learns the page moved.
295
+ // Keeping the old digest holds the row red until a human re-reads
296
+ // the page and re-establishes `docs_at` + `docs_digest` together.
297
+ //
298
+ // So drift writes `expires` ONLY. A first fill (`filled`) still writes
299
+ // the digest, because there is no prior reading to contradict.
300
+ if (isDrift(f)) {
301
+ const expiresM = /^(\s+expires:\s*).*$/.exec(line);
302
+ if (expiresM) {
303
+ lines[i] = `${expiresM[1] as string}${dayBefore(today)}`;
304
+ expired.push(`${host}/${surface}`);
305
+ written.push(`${host}/${surface}`);
306
+ }
307
+ continue;
308
+ }
309
+ const digestM = /^(\s+docs_digest:\s*).*$/.exec(line);
310
+ if (digestM) {
311
+ lines[i] = `${digestM[1] as string}${f.now ?? 'null'}`;
312
+ written.push(`${host}/${surface}`);
313
+ }
314
+ }
315
+ const missed = [...wanted.keys()].filter((k) => !written.includes(k));
316
+ return { text: lines.join('\n'), expired, written, missed };
317
+ }
318
+
319
+ async function fetchBody(url: string, timeoutMs: number): Promise<{ body: string | null; detail?: string }> {
320
+ const ac = new AbortController();
321
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
322
+ try {
323
+ const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
324
+ // 404/410 is the server stating authoritatively that the page is not
325
+ // there. Everything else non-ok — 5xx, 429, a proxy error — may be
326
+ // transient and must not condemn a citation.
327
+ if (res.status === 404 || res.status === 410) {
328
+ return { body: null, detail: `GONE HTTP ${res.status}` };
329
+ }
330
+ if (!res.ok) return { body: null, detail: `HTTP ${res.status}` };
331
+ return { body: await res.text() };
332
+ } catch (exc) {
333
+ // The ONE fetch-derived string this script ever prints, so it is the one
334
+ // that gets the sanitize floor. The body is hashed and discarded — a
335
+ // sha256 cannot carry a bidi override — but an exception message can
336
+ // quote a server-supplied URL or header, and this text lands on stdout
337
+ // in a CI log a human reads. Stripping hidden-instruction vectors here
338
+ // is what makes this module's `covered` row in
339
+ // `docs/contracts/retrieval-read-surfaces.md` a true statement rather
340
+ // than an import that satisfies a generator.
341
+ return { body: null, detail: sanitize_text(exc instanceof Error ? exc.message : String(exc)) };
342
+ } finally {
343
+ clearTimeout(timer);
344
+ }
345
+ }
346
+
347
+ const KNOWN_FLAGS: ReadonlySet<string> = new Set([
348
+ // `--as-of` is consumed by `asOf()` itself, not by this parser, but it MUST
349
+ // be listed: `asOf()` prints "Pass --as-of <iso> to pin it" on every
350
+ // unpinned run, and following that advice used to exit 2 on an unknown
351
+ // flag. A tool that refuses its own printed advice is worse than one that
352
+ // gives none.
353
+ '--as-of',
354
+ '--fetch',
355
+ '--write',
356
+ '--self-test',
357
+ '--strict-fetch',
358
+ '--quiet',
359
+ '--lowering',
360
+ '--today',
361
+ ]);
362
+
363
+ const STATE_ICON: Record<DigestState, string> = {
364
+ unchanged: '✅',
365
+ changed: '❌',
366
+ filled: '🆕',
367
+ 'no-url': '—',
368
+ unreachable: '⚠️ ',
369
+ gone: '💀',
370
+ };
371
+
372
+ function selfTest(): number {
373
+ const problems: string[] = [];
374
+ if (digestOf('abc') !== crypto.createHash('sha256').update('abc').digest('hex')) {
375
+ problems.push('digestOf disagrees with sha256');
376
+ }
377
+ if (dayBefore('2026-10-01') !== '2026-09-30') problems.push('dayBefore month boundary');
378
+ if (dayBefore('2026-01-01') !== '2025-12-31') problems.push('dayBefore year boundary');
379
+
380
+ const same = classify('h', 'any', 'u', digestOf('body'), 'body');
381
+ if (same.state !== 'unchanged') problems.push('equal body must be unchanged');
382
+ const moved = classify('h', 'any', 'u', digestOf('body'), 'other');
383
+ if (moved.state !== 'changed') problems.push('different body must be changed');
384
+ if (classify('h', 'any', 'u', null, 'body').state !== 'filled') problems.push('null digest must fill');
385
+ if (classify('h', 'any', null, null, null).state !== 'no-url') problems.push('null url must be no-url');
386
+ if (classify('h', 'any', 'u', 'd', null).state !== 'unreachable') problems.push('no body must be unreachable');
387
+
388
+ // An unreachable row must not expire anything — the property the whole
389
+ // "unreachable is not changed" paragraph rests on.
390
+ const src = fs.readFileSync(HOST_LOWERING_PATH, 'utf-8');
391
+ const untouched = applyFindings(src, [classify('claude', 'any', 'u', 'd', null)], '2026-10-01');
392
+ if (untouched.expired.length !== 0) problems.push('unreachable must not expire a row');
393
+ if (untouched.text !== src) problems.push('unreachable must not edit the table');
394
+
395
+ const drifted = applyFindings(src, [classify('claude', 'any', 'u', 'old', 'new-body')], '2026-10-01');
396
+ if (!drifted.text.includes('WHY THIS FILE EXISTS')) problems.push('write must preserve comments');
397
+ if (drifted.expired.join() !== 'claude/any') problems.push('drift must report the row it expired');
398
+
399
+ // The write touches EXACTLY the one line whose content changed — `expires`
400
+ // — and the hand-aligned `slots:` block is not it. Counting the differing
401
+ // lines is what keeps the "minimal diff" claim in the block comment above
402
+ // honest; asserting only that the new value is present would pass just as
403
+ // well on a full reformat. One line and not two: a drifted row keeps its
404
+ // recorded digest, see `applyFindings`.
405
+ const before = src.split('\n');
406
+ const after = drifted.text.split('\n');
407
+ const changedLines = before.length === after.length
408
+ ? before.reduce((n, l, i) => (l === after[i] ? n : n + 1), 0)
409
+ : -1;
410
+ if (changedLines !== 1) {
411
+ problems.push(`drift write must change exactly 1 line, changed ${changedLines}`);
412
+ }
413
+
414
+ // A sibling row must be untouched. Derived from the table rather than
415
+ // compared against a literal date: a hard-coded `expires` here reds the
416
+ // self-test whenever an unrelated row is legitimately renewed, which is a
417
+ // fixture asserting the calendar instead of the code.
418
+ const siblingBefore = parseHostLowering(src).get('gemini')?.get('any')?.verified;
419
+ const siblingAfter = parseHostLowering(drifted.text).get('gemini')?.get('any')?.verified;
420
+ if (siblingBefore?.expires !== siblingAfter?.expires) problems.push('a sibling row must not be expired');
421
+ if (siblingBefore?.docs_digest !== siblingAfter?.docs_digest) problems.push('a sibling digest must not move');
422
+
423
+ // The drifted row's OWN digest is unchanged, anchored at the row rather
424
+ // than by a bare `includes` that any row could satisfy.
425
+ const driftedRow = parseHostLowering(drifted.text).get('claude')?.get('any')?.verified;
426
+ if (driftedRow?.expires !== '2026-09-30') problems.push('drifted row must expire to the day before');
427
+ if (driftedRow?.docs_digest !== parseHostLowering(src).get('claude')?.get('any')?.verified?.docs_digest) {
428
+ problems.push('drift must not overwrite the recorded digest');
429
+ }
430
+
431
+ // 404 is not a flake.
432
+ if (classify('h', 'any', 'u', 'd', null, 'GONE HTTP 404').state !== 'gone') problems.push('404 must be gone');
433
+ if (classify('h', 'any', 'u', 'd', null, 'HTTP 503').state !== 'unreachable') problems.push('503 must be unreachable');
434
+
435
+ // A `gone` row writes nothing — the high finding of round 3, asserted here
436
+ // as well as in the vitest suite because this is the block the
437
+ // `no_canary_reason` cites as the negative control.
438
+ const deadRow = applyFindings(src, [classify('claude', 'any', 'u', 'old', null, 'GONE HTTP 404')], '2026-10-01');
439
+ if (deadRow.text !== src) problems.push('a gone row must not edit the table');
440
+ if (deadRow.written.length !== 0) problems.push('a gone row must write nothing');
441
+
442
+ // `written` vs `missed`: a row resolved but not writable must be REPORTED,
443
+ // not counted as recorded. The `no_canary_reason` credits this block with
444
+ // covering that direction, so it has to actually be here.
445
+ const noKey = src.replace(/\n\s+docs_digest: [0-9a-f]{64}/, '');
446
+ const missedRun = applyFindings(noKey, [classify('claude', 'any', 'u', null, 'body')], '2026-10-01');
447
+ if (missedRun.missed.join() !== 'claude/any') problems.push('a row with no digest key must be reported as missed');
448
+ if (missedRun.written.length !== 0) problems.push('a missed row must not count as written');
449
+
450
+ for (const p of problems) process.stderr.write(`❌ ${p}\n`);
451
+
452
+ // The pure-function checks above prove the DECISIONS. These prove the
453
+ // BINARY — argv parsing and the entry guard included — by shelling out to
454
+ // the real CLI, which is the layer that has silently no-opped in this
455
+ // repository before. `gate-self-test:registered-non-adopters` requires it
456
+ // of any registered gate, and the requirement is right: an enforced
457
+ // `scanned:` floor only proves the gate READ something.
458
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
459
+ const rel = 'src/scripts/check_host_docs_digest.ts';
460
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'chdd-selftest-'));
461
+ const emptyTable = path.join(tmp, 'empty.yaml');
462
+ fs.writeFileSync(emptyTable, 'schema_version: 1\nhosts: {}\n');
463
+ const malformed = path.join(tmp, 'bad.yaml');
464
+ fs.writeFileSync(malformed, 'schema_version: 1\nnot_hosts: []\n');
465
+
466
+ const cliCode = runSelfTest({
467
+ gate: 'check_host_docs_digest',
468
+ minCases: 6,
469
+ minRejectCases: 4,
470
+ cases: [
471
+ {
472
+ name: 'an unknown flag is refused, not ignored',
473
+ expect: 'reject',
474
+ run: () => runGateCli(repoRoot, rel, ['--not-a-flag'], repoRoot),
475
+ },
476
+ {
477
+ name: '`--flag=value` resolves instead of being silently dropped',
478
+ expect: 'reject',
479
+ run: () => runGateCli(repoRoot, rel, ['--lowering=/nonexistent/table.yaml'], repoRoot),
480
+ },
481
+ {
482
+ name: '`--write` without `--fetch` is refused',
483
+ expect: 'reject',
484
+ run: () => runGateCli(repoRoot, rel, ['--write'], repoRoot),
485
+ },
486
+ {
487
+ name: 'an empty table fails the scan-scope floor rather than reporting clean',
488
+ expect: 'reject',
489
+ run: () => runGateCli(repoRoot, rel, ['--lowering', emptyTable, '--quiet'], repoRoot),
490
+ },
491
+ {
492
+ name: 'a malformed table exits non-zero instead of throwing past the contract',
493
+ expect: 'reject',
494
+ run: () => runGateCli(repoRoot, rel, ['--lowering', malformed, '--quiet'], repoRoot),
495
+ },
496
+ {
497
+ name: 'the committed table passes the offline report',
498
+ expect: 'accept',
499
+ run: () => runGateCli(repoRoot, rel, ['--quiet'], repoRoot),
500
+ },
501
+ ],
502
+ });
503
+ fs.rmSync(tmp, { recursive: true, force: true });
504
+
505
+ if (problems.length === 0 && cliCode === 0) {
506
+ process.stdout.write('✅ check_host_docs_digest: self-test passed.\n');
507
+ }
508
+ return problems.length === 0 && cliCode === 0 ? 0 : 1;
509
+ }
510
+
511
+ export async function main(argv: readonly string[]): Promise<number> {
512
+ for (const a of argv) {
513
+ if (a.startsWith('--') && !KNOWN_FLAGS.has(a.split('=')[0] ?? a)) {
514
+ process.stderr.write(
515
+ `check_host_docs_digest: unknown flag \`${a}\`. Known: ${[...KNOWN_FLAGS].sort().join(', ')}\n`,
516
+ );
517
+ return 2;
518
+ }
519
+ }
520
+ if (argv.includes('--self-test')) return selfTest();
521
+
522
+ const quiet = argv.includes('--quiet');
523
+ const doFetch = argv.includes('--fetch');
524
+ const doWrite = argv.includes('--write');
525
+ const strictFetch = argv.includes('--strict-fetch');
526
+ /**
527
+ * Resolve `--flag value` AND `--flag=value`, and refuse a flag with no value.
528
+ *
529
+ * The first version accepted only the space form while the unknown-flag
530
+ * guard above split on `=` — so `--lowering=/nonexistent` passed validation
531
+ * and was then silently ignored, and the run read the REAL table and
532
+ * reported green. That is the precise fail-open this guard exists to
533
+ * prevent, and a sibling gate shipped it once already. A missing value is
534
+ * an error rather than a null, because `--today` as the last argument used
535
+ * to fall through to the wall clock and `--today --quiet` used to take
536
+ * `--quiet` as the date.
537
+ */
538
+ const bad: string[] = [];
539
+ const valueOf = (flag: string): string | null => {
540
+ const inline = argv.find((a) => a.startsWith(`${flag}=`));
541
+ if (inline !== undefined) {
542
+ const v = inline.slice(flag.length + 1);
543
+ if (v === '') bad.push(`${flag}= has an empty value`);
544
+ return v === '' ? null : v;
545
+ }
546
+ const i = argv.indexOf(flag);
547
+ if (i < 0) return null;
548
+ const next = argv[i + 1];
549
+ if (next === undefined || next.startsWith('--')) {
550
+ bad.push(`${flag} needs a value`);
551
+ return null;
552
+ }
553
+ return next;
554
+ };
555
+ const tablePath = valueOf('--lowering') ?? HOST_LOWERING_PATH;
556
+ const todayRaw = valueOf('--today');
557
+ if (todayRaw !== null) {
558
+ // Shape AND validity. Shape alone accepted `2026-13-45`, which then
559
+ // built an `Invalid Date` in `dayBefore` and threw `RangeError` out of
560
+ // `toISOString()` — surfacing as a bare stack trace from the catch-all,
561
+ // which is the outcome this parser exists to prevent. Checked here, at
562
+ // the boundary, rather than defended against at every use.
563
+ const shapeOk = /^\d{4}-\d{2}-\d{2}$/.test(todayRaw);
564
+ const realDate = shapeOk && !Number.isNaN(new Date(`${todayRaw}T00:00:00Z`).getTime())
565
+ // `new Date('2026-02-31')` does NOT produce Invalid Date in every
566
+ // engine — it can roll over into March — so the round-trip is what
567
+ // actually rejects a non-existent day.
568
+ && new Date(`${todayRaw}T00:00:00Z`).toISOString().slice(0, 10) === todayRaw;
569
+ if (!realDate) {
570
+ bad.push(`--today ${todayRaw} is not a real ISO YYYY-MM-DD date`);
571
+ }
572
+ }
573
+ if (bad.length > 0) {
574
+ for (const b of bad) process.stderr.write(`check_host_docs_digest: ${b}.\n`);
575
+ return 2;
576
+ }
577
+ // `asOf()` rather than the wall clock: a verdict that depends on the hour it
578
+ // ran is not reproducible from the commit, and this one writes a date INTO
579
+ // the tree. It resolves --as-of, AC_AS_OF and the commit date under CI.
580
+ const today = todayRaw ?? asOf().toISOString().slice(0, 10);
581
+
582
+ if (strictFetch && !doFetch) {
583
+ process.stderr.write(
584
+ 'check_host_docs_digest: `--strict-fetch` needs `--fetch` — it qualifies a fetch verdict, ' +
585
+ 'and silently doing nothing is the failure this script argues against.\n',
586
+ );
587
+ return 2;
588
+ }
589
+ if (doWrite && !doFetch) {
590
+ process.stderr.write('check_host_docs_digest: `--write` needs `--fetch` — there is nothing to write without a read.\n');
591
+ return 2;
592
+ }
593
+
594
+ // The PARSE is inside the guard too, not only the read. A malformed table
595
+ // threw past the `return 2` contract and surfaced as an unhandled rejection
596
+ // — a stack trace on the scheduled job, which contradicts that job's own
597
+ // promise that a red there means "go read a page, nothing is broken here".
598
+ let text: string;
599
+ let table: HostLowering;
600
+ try {
601
+ text = fs.readFileSync(tablePath, 'utf-8');
602
+ table = parseHostLowering(text);
603
+ } catch (exc) {
604
+ process.stderr.write(
605
+ `check_host_docs_digest: cannot read or parse ${tablePath}: ` +
606
+ `${exc instanceof Error ? exc.message : String(exc)}\n`,
607
+ );
608
+ return 2;
609
+ }
610
+ const rows = watchable(table);
611
+ // Scan-scope hardening, the same contract every other gate here carries: a
612
+ // gate that silently walks an empty corpus reports "clean" and is
613
+ // indistinguishable from a gate that works. If the table is ever moved or
614
+ // its shape changes so `watchable` resolves nothing, this exits non-zero
615
+ // instead of reporting no drift across zero rows.
616
+ try {
617
+ reportScanned({
618
+ gate: 'check_host_docs_digest',
619
+ scanned: rows.length,
620
+ units: 'verified row(s)',
621
+ roots: ['src/scripts/hooks/host_lowering.yaml'],
622
+ });
623
+ } catch (exc) {
624
+ // An empty or moved corpus is an ANTICIPATED input condition, so it
625
+ // gets the house treatment — one line, exit 2 — rather than escaping to
626
+ // the module-entry catch-all as a stack trace. The catch-all is for the
627
+ // unanticipated; this header argues that twice and then let this one
628
+ // through, which a completion review caught.
629
+ if (exc instanceof DeadScopeError) {
630
+ process.stderr.write(`❌ ${exc.message}\n`);
631
+ return 2;
632
+ }
633
+ throw exc;
634
+ }
635
+
636
+ if (!doFetch) {
637
+ const withDigest = rows.filter((r) => r.digest !== null).length;
638
+ const noUrl = rows.filter((r) => r.url === null).length;
639
+ if (!quiet) {
640
+ process.stdout.write(
641
+ `check_host_docs_digest: ${rows.length} verified row(s) — ` +
642
+ `${withDigest} watched, ${rows.length - withDigest - noUrl} awaiting a first digest, ` +
643
+ `${noUrl} with no page to watch.\n`,
644
+ );
645
+ for (const r of rows) {
646
+ const mark = r.url === null ? '—' : r.digest === null ? '🆕' : '✅';
647
+ process.stdout.write(` ${mark} ${r.host}/${r.surface} ${r.url ?? '(no docs_url)'}\n`);
648
+ }
649
+ process.stdout.write(' Offline report — pass `--fetch` to compare against the live pages.\n');
650
+ }
651
+ return 0;
652
+ }
653
+
654
+ const findings: DigestFinding[] = [];
655
+ for (const r of rows) {
656
+ if (r.url === null) {
657
+ findings.push(classify(r.host, r.surface, null, r.digest, null));
658
+ continue;
659
+ }
660
+ const { body, detail } = await fetchBody(r.url, 30_000);
661
+ findings.push(classify(r.host, r.surface, r.url, r.digest, body, detail));
662
+ }
663
+
664
+ if (!quiet) {
665
+ for (const f of findings) {
666
+ // `gone` carries its status code in `detail` exactly as
667
+ // `unreachable` does, and printing it only for the latter meant a
668
+ // dead citation logged without saying whether it was a 404 or a 410.
669
+ const extra =
670
+ f.state === 'unreachable' || f.state === 'gone' ? ` (${f.detail ?? 'unreadable'})` : '';
671
+ process.stdout.write(` ${STATE_ICON[f.state]} ${f.host}/${f.surface} — ${f.state}${extra}\n`);
672
+ // A drift line names everything needed to act on it without a
673
+ // second run: which page, what it hashed to before, what it hashes
674
+ // to now, and when that was seen. A bare "changed" sends the reader
675
+ // back to the terminal to find out what changed about what.
676
+ if (isDrift(f)) {
677
+ process.stdout.write(
678
+ ` url: ${f.url ?? '(none)'}\n` +
679
+ ` was: ${f.was ?? 'null'}\n` +
680
+ ` now: ${f.now ?? 'null'}\n` +
681
+ ` seen: ${today}\n`,
682
+ );
683
+ }
684
+ }
685
+ }
686
+
687
+ const drifted = findings.filter(isDrift);
688
+ const unreachable = findings.filter((f) => f.state === 'unreachable');
689
+
690
+ if (doWrite) {
691
+ const { text: next, expired, missed } = applyFindings(text, findings, today);
692
+ if (next !== text) {
693
+ fs.writeFileSync(tablePath, next);
694
+ process.stdout.write(`✍️ ${path.basename(tablePath)} updated.\n`);
695
+ // The compiled sibling is fingerprinted over the YAML TEXT, so any
696
+ // write here desyncs it and reds `task preflight` and CI. Both
697
+ // completion reviewers found this the same way: by watching it
698
+ // happen on this branch. Naming the command beats rediscovering it.
699
+ process.stdout.write(
700
+ ' NEXT: ./scripts-run src/scripts/compile_hook_manifest --table host-lowering\n' +
701
+ ' (the compiled sibling is hashed over the YAML text, so this write makes it stale)\n',
702
+ );
703
+ }
704
+ for (const e of expired) {
705
+ process.stdout.write(
706
+ ` ${e}: docs body changed — \`expires\` pulled to ${dayBefore(today)}, digest left as recorded.\n` +
707
+ ' This marks the row stale; it does NOT re-establish it. To re-establish: read the page, then set\n' +
708
+ ' `docs_at` to today, `docs_digest` to the new body\'s hash, and `expires` FORWARD again — nothing\n' +
709
+ ' moves `expires` forward automatically, so a row left half-fixed stays red in `lint_hook_manifest`.\n',
710
+ );
711
+ }
712
+ if (missed.length > 0) {
713
+ // A row the walk could not write — most plausibly a `verified:`
714
+ // block carrying no `docs_digest:` key at all. Silence here would
715
+ // have the run report "newly recorded" for a row it never touched.
716
+ process.stderr.write(
717
+ `❌ check_host_docs_digest: ${missed.length} row(s) were resolved but NOT written: ` +
718
+ `${missed.join(', ')}. The \`verified:\` block is probably missing the key the walk rewrites. ` +
719
+ 'Nothing was claimed for them.\n',
720
+ );
721
+ return 2;
722
+ }
723
+ }
724
+
725
+ if (drifted.length > 0) {
726
+ process.stderr.write(
727
+ `❌ check_host_docs_digest: ${drifted.length} host doc page(s) changed since the row was established: ` +
728
+ `${drifted.map((f) => f.host).join(', ')}. This is a signal to READ the page, never to adopt it.\n`,
729
+ );
730
+ return 1;
731
+ }
732
+ const gone = findings.filter((f) => f.state === 'gone');
733
+ if (gone.length > 0) {
734
+ process.stderr.write(
735
+ `❌ check_host_docs_digest: ${gone.length} cited page(s) are GONE (404/410): ` +
736
+ `${gone.map((f) => `${f.host} ${f.url ?? ''}`).join(', ')}. ` +
737
+ 'A removed page is not a flake — the citation is dead and the row still carries its digest and `expires`. ' +
738
+ 'Find the current page, re-read it, and re-establish the row by hand.\n',
739
+ );
740
+ return 1;
741
+ }
742
+ if (unreachable.length > 0 && strictFetch) {
743
+ process.stderr.write(
744
+ `❌ check_host_docs_digest: ${unreachable.length} page(s) could not be read ` +
745
+ `(${unreachable.map((f) => f.host).join(', ')}). Unreachable establishes nothing — re-run with egress.\n`,
746
+ );
747
+ return 1;
748
+ }
749
+ if (!quiet) {
750
+ const filled = findings.filter((f) => f.state === 'filled').length;
751
+ // "newly recorded" is only true when something was written. In the
752
+ // read-only mode the scheduled job runs, a row with a null digest was
753
+ // computed and discarded, and calling that "recorded" would have the
754
+ // weekly job claim, every week and forever, to have fixed something it
755
+ // did not touch.
756
+ const filledPhrase = doWrite
757
+ ? `${filled} newly recorded`
758
+ : `${filled} with no digest yet (not written — this run is read-only)`;
759
+ process.stdout.write(
760
+ `✅ check_host_docs_digest: no host documentation drift ` +
761
+ `(${findings.filter((f) => f.state === 'unchanged').length} unchanged, ` +
762
+ `${filledPhrase}, ${unreachable.length} unreachable).\n`,
763
+ );
764
+ }
765
+ return 0;
766
+ }
767
+
768
+ const _invokedDirectly =
769
+ process.argv[1] !== undefined && /check_host_docs_digest\.ts$/.test(process.argv[1]);
770
+ if (_invokedDirectly) {
771
+ // `.catch` as well as `.then`: without it any throw this module did not
772
+ // anticipate leaves an unhandled rejection rather than the documented
773
+ // exit-2 contract, and on a scheduled job the difference between "exit 2,
774
+ // something is wrong with the inputs" and a bare stack trace is the whole
775
+ // signal.
776
+ void main(process.argv.slice(2))
777
+ .then((code) => {
778
+ process.exitCode = code;
779
+ })
780
+ .catch((exc: unknown) => {
781
+ process.stderr.write(
782
+ `check_host_docs_digest: unexpected failure: ${exc instanceof Error ? exc.stack ?? exc.message : String(exc)}\n`,
783
+ );
784
+ process.exitCode = 2;
785
+ });
786
+ }