clearotron 0.3.2-beta.7 → 0.3.2-beta.9

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 (272) hide show
  1. package/.env.example +58 -26
  2. package/CONTRIBUTING.md +8 -8
  3. package/INSTALL.md +148 -81
  4. package/README.md +3 -3
  5. package/SECURITY.md +3 -3
  6. package/bin/brandowner.mjs +3 -3
  7. package/bin/framework-preflight.mjs +1 -1
  8. package/bin/onboard.mjs +637 -216
  9. package/bin/start.mjs +151 -27
  10. package/bin/update.mjs +58 -11
  11. package/build-info.json +2 -2
  12. package/docs/DELIVERY.md +2 -1
  13. package/docs/INTAKE.md +1 -1
  14. package/docs/ONBOARDING.md +1 -1
  15. package/docs/architecture/03-run-lifecycle.md +6 -6
  16. package/docs/architecture/04-configuration-reference.md +32 -14
  17. package/docs/architecture/05-config-governance.md +23 -8
  18. package/docs/architecture/05-customer-profiles.md +2 -2
  19. package/docs/architecture/06-operations-runbook.md +3 -3
  20. package/docs/architecture/08-development-guide.md +6 -6
  21. package/docs/configuration.md +5 -5
  22. package/docs/decisions/0003-credential-model.md +1 -1
  23. package/docs/writing-standard.md +4 -0
  24. package/driver/CHANGELOG.md +124 -0
  25. package/driver/README.md +3 -3
  26. package/driver/band-size.mjs +59 -0
  27. package/driver/binding-layers.mjs +1 -1
  28. package/driver/citation-census.json +3 -3
  29. package/driver/{prelim-variants-record.mjs → clearance-variants-record.mjs} +24 -24
  30. package/driver/common-law-receipts.mjs +2 -2
  31. package/driver/company-bundle.mjs +3 -3
  32. package/driver/compose-read.mjs +8 -14
  33. package/driver/config-inventory.mjs +112 -9
  34. package/driver/consumption-ledger.mjs +2 -2
  35. package/driver/contract-arm2-baseline.json +2 -5
  36. package/driver/contract-dictation-registry.mjs +19 -19
  37. package/driver/contract-e3-backlog.mjs +43 -43
  38. package/driver/contract-e3-baseline.json +14 -14
  39. package/driver/contract-vocabulary.mjs +68 -27
  40. package/driver/deliver-trigger.sh +16 -16
  41. package/driver/demo-container.mjs +3 -3
  42. package/driver/dev-portal.mjs +3 -3
  43. package/driver/disposition-call.mjs +1 -1
  44. package/driver/door-gates.mjs +41 -7
  45. package/driver/doubt-ledger.mjs +2 -2
  46. package/driver/drainer-identity.mjs +34 -8
  47. package/driver/driver.config.mjs +367 -104
  48. package/driver/engine/CONTRACT.md +10 -3
  49. package/driver/engine/README.md +2 -2
  50. package/driver/engine/anthropic-agent.mjs +77 -21
  51. package/driver/engine/auth.mjs +129 -10
  52. package/driver/engine/jx-turn.mjs +7 -6
  53. package/driver/engine/mcp/README.md +1 -1
  54. package/driver/engine/mcp/dispositions-server.mjs +3 -3
  55. package/driver/engine/mcp/gather-config.mjs +9 -9
  56. package/driver/engine/mcp/perplexity-server.mjs +2 -2
  57. package/driver/engine/mcp/recording-server.mjs +18 -5
  58. package/driver/engine/openai-agent.mjs +4 -2
  59. package/driver/engine/probe.mjs +110 -23
  60. package/driver/enqueue-schema.mjs +6 -2
  61. package/driver/findings-model.mjs +6 -3
  62. package/driver/flag-snapshot.mjs +34 -8
  63. package/driver/form-neighbourhood.mjs +54 -7
  64. package/driver/framework.mjs +4 -4
  65. package/driver/gateway.mjs +36 -24
  66. package/driver/jx-lanes.mjs +23 -4
  67. package/driver/jx-units.mjs +7 -4
  68. package/driver/jx.mjs +34 -4
  69. package/driver/knockout-review-record.mjs +56 -4
  70. package/driver/known-conflicts.mjs +1 -1
  71. package/driver/matter-frame-record.mjs +90 -1
  72. package/driver/named-band.mjs +1 -1
  73. package/driver/ordinary-words.mjs +51 -0
  74. package/driver/outbox-backoff.mjs +31 -16
  75. package/driver/package.json +1 -1
  76. package/driver/partial-payload-baseline.json +2 -2
  77. package/driver/phase0.mjs +3 -3
  78. package/driver/pipeline-knockout.mjs +5 -5
  79. package/driver/pipeline.mjs +396 -81
  80. package/driver/placement-form.mjs +77 -1
  81. package/driver/placement-model.mjs +1 -1
  82. package/driver/portal-config-view.mjs +30 -1
  83. package/driver/portal-report.mjs +107 -6
  84. package/driver/portal-service.mjs +80 -14
  85. package/driver/portal-upstream.mjs +1 -1
  86. package/driver/predelivery-lint.mjs +12 -2
  87. package/driver/preserve-merge.mjs +3 -3
  88. package/driver/product-rows.mjs +2 -2
  89. package/driver/products.mjs +1 -1
  90. package/driver/profiles/README.md +3 -3
  91. package/driver/profiles/demo-brand-owner.json +2 -2
  92. package/driver/profiles.mjs +55 -17
  93. package/driver/progress.mjs +18 -8
  94. package/driver/provider-usage.mjs +8 -8
  95. package/driver/publish/index.mjs +154 -8
  96. package/driver/publish/knockout.mjs +39 -5
  97. package/driver/publish/pool-admin.mjs +1 -1
  98. package/driver/publish/publish-inputs.mjs +18 -2
  99. package/driver/publish/render-knockout.mjs +184 -31
  100. package/driver/publish/render.mjs +323 -93
  101. package/driver/publish/report-data.mjs +4 -1
  102. package/driver/publish/report-topbar.mjs +58 -0
  103. package/driver/publish/search-depth.mjs +133 -4
  104. package/driver/publish/templates/report.css +78 -4
  105. package/driver/publish/xlsx.mjs +20 -1
  106. package/driver/queue-order.mjs +2 -2
  107. package/driver/recording-agreement.mjs +1 -1
  108. package/driver/reference-score.mjs +1 -1
  109. package/driver/register-availability.mjs +2 -2
  110. package/driver/register-count.mjs +50 -5
  111. package/driver/register-coverage.mjs +161 -1
  112. package/driver/register-digest-record.mjs +236 -11
  113. package/driver/register-grant-vocabulary.mjs +1 -1
  114. package/driver/register-plan.mjs +189 -2
  115. package/driver/registry-fidelity.mjs +3 -3
  116. package/driver/repair-composers.mjs +1 -1
  117. package/driver/repair-contract.mjs +1 -1
  118. package/driver/replay-archive.mjs +6 -6
  119. package/driver/report-overview-record.mjs +2 -2
  120. package/driver/result-noun-fields.mjs +2 -2
  121. package/driver/run-economics.mjs +41 -10
  122. package/driver/run-requirements.mjs +173 -9
  123. package/driver/runner.mjs +5 -5
  124. package/driver/scope-facts.mjs +20 -5
  125. package/driver/scope-ledger.mjs +5 -5
  126. package/driver/search-policy.mjs +22 -12
  127. package/driver/skills/README.md +15 -15
  128. package/driver/skills/blind-frame/SKILL.md +2 -2
  129. package/driver/skills/case-law-citation/SKILL.md +4 -4
  130. package/driver/skills/case-law-citation/sources/eurlex.md +1 -1
  131. package/driver/skills/{prelim-common-law → clearance-common-law}/SKILL.md +22 -22
  132. package/driver/skills/{prelim-common-law → clearance-common-law}/perplexity-prompts.md +1 -1
  133. package/driver/skills/{prelim-register → clearance-register}/SKILL.md +10 -10
  134. package/driver/skills/{prelim-register → clearance-register}/digest.md +2 -2
  135. package/driver/skills/{prelim-register → clearance-register}/providers/README.md +1 -1
  136. package/driver/skills/{prelim-register → clearance-register}/providers/clarivate.md +37 -35
  137. package/driver/skills/{prelim-register → clearance-register}/providers/corsearch.md +20 -11
  138. package/driver/skills/{prelim-register → clearance-register}/providers/signa.md +5 -5
  139. package/driver/skills/{prelim-register → clearance-register}/register-recipes.md +3 -3
  140. package/driver/skills/{prelim-register → clearance-register}/status-rules.md +2 -2
  141. package/driver/skills/{prelim-register → clearance-register}/stealth-filer-indicators.md +1 -1
  142. package/driver/skills/{prelim-register → clearance-register}/unit.md +2 -2
  143. package/driver/skills/{prelim-search → clearance-search}/SKILL.md +31 -31
  144. package/driver/skills/{prelim-search → clearance-search}/delivery-contract.md +1 -1
  145. package/driver/skills/{prelim-search → clearance-search}/phase2-execution.md +18 -18
  146. package/driver/skills/{prelim-search → clearance-search}/synthesis-rules.md +7 -7
  147. package/driver/skills/{prelim-variants → clearance-variants}/SKILL.md +18 -18
  148. package/driver/skills/{prelim-variants → clearance-variants}/transliteration-scripts.md +5 -5
  149. package/driver/skills/frame-diff/SKILL.md +1 -1
  150. package/driver/skills/knockout-assess/SKILL.md +10 -7
  151. package/driver/skills/matter-frame/SKILL.md +3 -3
  152. package/driver/skills/narrative-refutation/SKILL.md +9 -9
  153. package/driver/skills/placement-inquiry/SKILL.md +5 -5
  154. package/driver/stage-context.mjs +1 -1
  155. package/driver/stages-knockout.mjs +4 -4
  156. package/driver/stages.mjs +65 -61
  157. package/driver/status-snapshot.mjs +2 -2
  158. package/driver/suite-census.json +340 -136
  159. package/driver/surface-exit-verdict.mjs +58 -0
  160. package/driver/systemd/README.md +9 -6
  161. package/driver/systemd/clearotron-worker.service +1 -1
  162. package/driver/terminal-clamp.mjs +109 -1
  163. package/driver/tokens.mjs +169 -3
  164. package/driver/unit-environment.mjs +42 -15
  165. package/driver/unit-inventory.mjs +19 -2
  166. package/driver/usage-ledger.mjs +1 -1
  167. package/driver/variant-manifest-model.mjs +4 -4
  168. package/driver/verify-knockout.mjs +27 -0
  169. package/driver/verify.mjs +94 -6
  170. package/driver/whatif-queue.mjs +1 -1
  171. package/driver/wordlists/en.txt +63906 -0
  172. package/mcp-server/CHANGELOG.md +8 -0
  173. package/mcp-server/README.md +1 -1
  174. package/mcp-server/lib/README.md +1 -1
  175. package/mcp-server/lib/options.mjs +8 -7
  176. package/mcp-server/lib/plan.mjs +18 -2
  177. package/mcp-server/lib/runs.mjs +1 -1
  178. package/mcp-server/lib/usage.mjs +3 -3
  179. package/mcp-server/lib/whatif.mjs +1 -1
  180. package/mcp-server/package.json +1 -1
  181. package/mcp-server/server.mjs +18 -1
  182. package/package.json +12 -11
  183. package/portal-ui/dist/assets/{index-CVOIvdhc.css → index-CtvwLCti.css} +207 -3
  184. package/portal-ui/dist/assets/{index-5UyqAyNM.js → index-EVaSo5-g.js} +1580 -527
  185. package/portal-ui/dist/index.html +2 -2
  186. package/portal-ui/package.json +1 -1
  187. package/providers/README.md +1 -1
  188. package/providers/_shared/enumerate.mjs +6 -6
  189. package/providers/_shared/execute-plan.mjs +3 -3
  190. package/providers/_shared/ledger.mjs +119 -5
  191. package/providers/_shared/provider-text.mjs +2 -2
  192. package/providers/_shared/screen.mjs +2 -2
  193. package/providers/_shared/script-form.mjs +3 -3
  194. package/providers/_shared/territory-codes.mjs +23 -3
  195. package/providers/clarivate/README.md +1 -1
  196. package/providers/clarivate/src/capabilities.js +12 -12
  197. package/providers/clarivate/src/core.js +37 -43
  198. package/providers/corsearch/README.md +1 -1
  199. package/providers/corsearch/src/capabilities.js +5 -5
  200. package/providers/corsearch/src/core.js +3 -3
  201. package/providers/jx/README.md +2 -1
  202. package/providers/jx/src/turn-envelope.mjs +8 -3
  203. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  204. package/providers/oauth-mcp-bridge/package.json +1 -1
  205. package/providers/perplexity/src/core.js +1 -1
  206. package/providers/signa/README.md +1 -1
  207. package/providers/signa/src/capabilities.js +42 -49
  208. package/providers/signa/src/core.js +106 -29
  209. package/providers/uspto-local/README.md +1 -1
  210. package/providers/uspto-local/src/sync.js +1 -1
  211. package/scripts/README.md +1 -0
  212. package/scripts/ask-ai-render-check.mjs +127 -1
  213. package/scripts/authority-boundary-probe.mjs +8 -6
  214. package/scripts/backfill-started-at.mjs +2 -2
  215. package/scripts/census-merge-driver.mjs +33 -2
  216. package/scripts/citation-anchor-report.mjs +181 -0
  217. package/scripts/dead-names.mjs +1 -1
  218. package/scripts/deprecate-below.mjs +66 -8
  219. package/scripts/drain-preflight.mjs +1 -1
  220. package/scripts/e2e.mjs +174 -0
  221. package/scripts/env-audit.mjs +39 -6
  222. package/scripts/env-classify.mjs +20 -2
  223. package/scripts/freeze-example-run.mjs +61 -18
  224. package/scripts/generated-files-are-current.mjs +69 -4
  225. package/scripts/live-surface-check.mjs +124 -41
  226. package/scripts/markdown-link-check.mjs +1 -1
  227. package/scripts/merge-shape-check.mjs +242 -0
  228. package/scripts/mint-names-in-force.mjs +5 -5
  229. package/scripts/mint-offered-territories.mjs +72 -0
  230. package/scripts/mint-public-residue.mjs +2 -2
  231. package/scripts/mint-reference-strip-backlog.mjs +2 -2
  232. package/scripts/mint-suite-census.mjs +75 -2
  233. package/scripts/mint-writing-standard-backlog.mjs +2 -2
  234. package/scripts/purge-runs.mjs +7 -7
  235. package/scripts/reconcile-runs.mjs +2 -2
  236. package/scripts/release-approve-parked.mjs +20 -2
  237. package/scripts/release-await-cut.mjs +120 -1
  238. package/scripts/release-note-required.mjs +76 -8
  239. package/scripts/report-header-render-check.mjs +164 -0
  240. package/scripts/settings-render-check.mjs +75 -2
  241. package/scripts/test-full.mjs +96 -3
  242. package/scripts/test-run.mjs +10 -0
  243. package/shared/brand.mjs +27 -0
  244. package/shared/connect-clients.mjs +39 -11
  245. package/shared/deployment-box.mjs +7 -2
  246. package/shared/driver-dir.mjs +1 -1
  247. package/shared/env-aliases.mjs +1 -1
  248. package/shared/identifier-scan.mjs +65 -9
  249. package/shared/identifier-sentinels.mjs +22 -0
  250. package/shared/names-in-force.mjs +4 -2
  251. package/shared/offered-territories.json +738 -0
  252. package/shared/pre-rename-spellings.mjs +53 -0
  253. package/shared/reference-guard-classes.mjs +40 -2
  254. package/shared/stdio-connect.mjs +39 -4
  255. package/shared/tree-commit.mjs +48 -0
  256. /package/driver/skills/{prelim-register → clearance-register}/providers/euipo.md +0 -0
  257. /package/driver/skills/{prelim-register → clearance-register}/providers/free-tier.md +0 -0
  258. /package/driver/skills/{prelim-register → clearance-register}/providers/uspto-local.md +0 -0
  259. /package/driver/skills/{prelim-search → clearance-search}/field-doctrine-pharma.md +0 -0
  260. /package/driver/skills/{prelim-search → clearance-search}/firm-wide-reasoning.md +0 -0
  261. /package/driver/skills/{prelim-search → clearance-search}/report-prose.md +0 -0
  262. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-demo.manifest.json +0 -0
  263. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-demo.md +0 -0
  264. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-triage.manifest.json +0 -0
  265. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-triage.md +0 -0
  266. /package/driver/skills/{prelim-search → clearance-search}/risk-framework.manifest.json +0 -0
  267. /package/driver/skills/{prelim-search → clearance-search}/risk-framework.md +0 -0
  268. /package/driver/skills/{prelim-search → clearance-search}/template-formatting.md +0 -0
  269. /package/driver/skills/{prelim-search → clearance-search}/templates/email/generic.md +0 -0
  270. /package/driver/skills/{prelim-search → clearance-search}/templates/search-request-form.html +0 -0
  271. /package/driver/skills/{prelim-search → clearance-search}/worked-examples-demo.md +0 -0
  272. /package/driver/skills/{prelim-search → clearance-search}/worked-examples.md +0 -0
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
4
+ //
5
+ // mint-offered-territories.mjs — write the places the New Clearance form offers, derived by rule.
6
+ //
7
+ // The list is `searchableTerritories()` (driver/register-coverage.mjs): every place at least one supported
8
+ // register can search, named by its shipped label or the runtime's standard English name, never by hand.
9
+ // It is written ONCE, to shared/offered-territories.json, and read from there by both halves that must
10
+ // agree — the form (portal-ui composerProduct.ts, at build time) and the engine's mirror of it
11
+ // (driver/compose-read.mjs PROMPT_TERRITORIES). Two lists kept equal by a test drift in the gap between
12
+ // runs; one file read twice cannot.
13
+ //
14
+ // A COMMITTED file rather than a runtime call, for two reasons. The portal is a browser bundle and cannot
15
+ // run the engine's resolver; and what the form offers should change when somebody re-mints it and reads
16
+ // the diff, not silently when a provider's coverage table is edited. `generated-files-are-current.mjs`
17
+ // runs this with --check, so a coverage change that moves the list reds until it is minted.
18
+ //
19
+ // Usage: node scripts/mint-offered-territories.mjs [--check]
20
+ // --check exit 1 if the committed file disagrees with a fresh derivation, printing the difference
21
+
22
+ import { readFileSync, writeFileSync } from "node:fs";
23
+ import { dirname, join } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ const REPO = dirname(dirname(fileURLToPath(import.meta.url)));
27
+ const OUT = join(REPO, "shared", "offered-territories.json");
28
+
29
+ /** The file's content, derived fresh. Regions first, then countries, each as `{code, name}`. */
30
+ export async function deriveOfferedTerritories() {
31
+ const { searchableTerritories } = await import("../driver/register-coverage.mjs");
32
+ const { territoryTier } = await import("../driver/territory-tiers.mjs");
33
+ const { offered, unnamed } = await searchableTerritories();
34
+ const regions = [], countries = [], untiered = [];
35
+ for (const t of offered) {
36
+ const tier = territoryTier(t.name);
37
+ if (tier === "region") regions.push(t);
38
+ else if (tier === "country") countries.push(t);
39
+ else untiered.push(t.code);
40
+ }
41
+ return {
42
+ _generated: "by scripts/mint-offered-territories.mjs from searchableTerritories() — do not edit; re-mint",
43
+ regions, countries,
44
+ // Recorded, not dropped: a searchable place the list could not name, or could not place in a tier.
45
+ left_out: { unnamed, untiered },
46
+ };
47
+ }
48
+
49
+ const render = (doc) => JSON.stringify(doc, null, 2) + "\n";
50
+
51
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
52
+ const next = render(await deriveOfferedTerritories());
53
+ if (process.argv.includes("--check")) {
54
+ let current;
55
+ try { current = readFileSync(OUT, "utf8"); }
56
+ catch { console.error(`mint-offered-territories: ${OUT} is missing — run without --check to mint it.`); process.exit(1); }
57
+ if (current === next) {
58
+ const doc = JSON.parse(current);
59
+ console.log(`offered-territories: current, ${doc.regions.length} region(s) and ${doc.countries.length} countr(ies).`);
60
+ process.exit(0);
61
+ }
62
+ const names = (s) => { const d = JSON.parse(s); return new Set([...d.regions, ...d.countries].map((t) => `${t.code} ${t.name}`)); };
63
+ const [a, b] = [names(current), names(next)];
64
+ for (const x of b) if (!a.has(x)) console.error(` + ${x}`);
65
+ for (const x of a) if (!b.has(x)) console.error(` - ${x}`);
66
+ console.error("offered-territories: the committed list disagrees with the rule — re-mint: node scripts/mint-offered-territories.mjs");
67
+ process.exit(1);
68
+ }
69
+ writeFileSync(OUT, next);
70
+ const doc = JSON.parse(next);
71
+ console.log(`offered-territories: wrote ${doc.regions.length} region(s) and ${doc.countries.length} countr(ies) to ${OUT}`);
72
+ }
@@ -17,7 +17,7 @@
17
17
  import { readFileSync, writeFileSync } from "node:fs";
18
18
  import { join, dirname } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
- import { CLASSES, censusOf, publishedOf } from "../shared/reference-guard-classes.mjs";
20
+ import { CLASSES, censusOf, publishedOf, publishedReader } from "../shared/reference-guard-classes.mjs";
21
21
  import { trackedFiles, skipReason } from "../shared/tracked-files.mjs";
22
22
  import { isEntrypoint } from "../shared/is-entrypoint.mjs";
23
23
 
@@ -35,7 +35,7 @@ export function mint() {
35
35
  const p = publishedOf(tracked, ROOT);
36
36
  if (p.error) { console.error(`mint-public-residue: ${p.error}`); process.exit(2); }
37
37
  if (p.laid) console.log(`mint-public-residue: ${p.laid} tracked path(s) are not in HEAD — laid over this checkout, not published in it, and not counted`);
38
- const c = censusOf(p.files, (f) => readFileSync(join(ROOT, f), "utf8"));
38
+ const c = censusOf(p.files, publishedReader(ROOT, (f) => readFileSync(join(ROOT, f), "utf8")));
39
39
  return { classes: CLASSES.map((x) => x.id), total: c.total, files: c.files };
40
40
  }
41
41
 
@@ -14,7 +14,7 @@
14
14
  // CI runs. Without it, the table is rewritten.
15
15
  import { readFileSync, writeFileSync } from "node:fs";
16
16
  import { execFileSync } from "node:child_process";
17
- import { publishedOf } from "../shared/reference-guard-classes.mjs";
17
+ import { publishedOf, publishedReader } from "../shared/reference-guard-classes.mjs";
18
18
  import { join, dirname } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { SIGNATURES, censusOf } from "../driver/reference-strip-signatures.mjs";
@@ -34,7 +34,7 @@ const p = publishedOf(all, ROOT);
34
34
  if (p.error) { console.error(`mint-reference-strip-backlog: ${p.error}`); process.exit(2); }
35
35
  if (p.laid) console.log(`mint-reference-strip-backlog: ${p.laid} tracked path(s) are not in HEAD — laid over this checkout, not published in it, and not counted`);
36
36
  const tracked = p.files;
37
- const minted = censusOf(ROOT, tracked, (f) => readFileSync(join(ROOT, f), "utf8"));
37
+ const minted = censusOf(ROOT, tracked, publishedReader(ROOT, (f) => readFileSync(join(ROOT, f), "utf8")));
38
38
 
39
39
  if (process.argv.includes("--check")) {
40
40
  const have = JSON.parse(readFileSync(TABLE, "utf8"));
@@ -39,7 +39,7 @@ import { CENSUS_WORKSPACES, CENSUS_ROOT_SCRIPTS, countTestSites, collectionFromM
39
39
  // THE MEASUREMENT SIDE of the root-script check: the census states the globs it expects and this
40
40
  // supplies what the corpus actually resolves to, so the two can disagree.
41
41
  import { providerTestFiles } from "./test-full.mjs";
42
- import { withheldEntryFor, announceWithheldMode } from "../shared/withheld-paths-access.mjs"; // — withheld is a stated absence, not a loss.: the record does not ship, and without it every absence is a loss
42
+ import { withheldEntryFor, announceWithheldMode, CUT_RECORD_PRESENT } from "../shared/withheld-paths-access.mjs"; // — withheld is a stated absence, not a loss.: the record does not ship, and without it every absence is a loss
43
43
  import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — realpath both sides, or a symlinked invocation exits 0 silently
44
44
  import { publishedOf } from "../shared/reference-guard-classes.mjs";
45
45
 
@@ -73,6 +73,60 @@ const README = [
73
73
  "RE-STAMP DELIBERATELY: node scripts/mint-suite-census.mjs --apply",
74
74
  ];
75
75
 
76
+ /**
77
+ * WHAT A LAID PATH MEANS DEPENDS ON THE TREE, and this census used to answer the same way for both.
78
+ *
79
+ * It printed that some tracked paths were not in HEAD and not counted, and then reported the population
80
+ * as "unchanged" — a summary of a population it had just said it did not read. Measured twice on
81
+ * 2026-09-17, from opposite directions: mid-merge it dropped every path arriving from the other parent,
82
+ * and with an ordinary new test file staged it dropped that, because `git add` puts a file in the index
83
+ * and only a commit puts it in HEAD. Both printed the note; both reported unchanged; one shipped a census
84
+ * six tests and twenty-three assertions short and was caught by `--check` afterwards rather than here.
85
+ *
86
+ * THE DISCRIMINATOR ALREADY EXISTS and is the cut record. Under the private overlay a laid path is the
87
+ * withheld corpus staged over a clone — expected, and excluding it is the whole reason HEAD is consulted
88
+ * rather than the index. With no cut record nothing is withheld from this tree, so a tracked path missing
89
+ * from HEAD is not a withheld file: it is work the author has staged and is about to leave out of the
90
+ * stamp. That is the strict mode this repository already declares for every other absence.
91
+ *
92
+ * So it refuses there, and names the paths. It does NOT refuse under the overlay, because that would break
93
+ * the case HEAD-filtering was introduced for — and a guard that fires on the legitimate use is one
94
+ * somebody switches off.
95
+ *
96
+ * Pure, and takes the cut-record flag rather than reading it, so both branches can be driven. The flag is
97
+ * fixed when its module is imported and a test cannot flip it without writing into the tree.
98
+ */
99
+ export function laidPathVerdict({ laid = 0, laidPaths = [], cutRecordPresent = false, overlayLaid = null } = {}) {
100
+ if (!laid) return { refuse: false, message: null };
101
+ if (cutRecordPresent) {
102
+ return { refuse: false,
103
+ message: `mint-suite-census: ${laid} tracked path(s) are not in HEAD — laid over this checkout, not published in it, and not counted` };
104
+ }
105
+ // THE OVERLAY SAYS WHAT IT LAID, because the cut record this leaned on is carried by no tree, the
106
+ // overlaid one included (shared/withheld-paths-access.mjs says so of itself). So under the private
107
+ // overlay this refused every run, and the arm that runs every minter over the overlaid tree went red on
108
+ // a census that was current. The overlay now writes the paths it laid into the clone's own `.git`, where
109
+ // nothing tracks or ships them. A staged path on that list is laid; one that is not is still staged work
110
+ // and still refuses — the list excuses exactly what the overlay put there and nothing beside it.
111
+ const unlisted = overlayLaid ? laidPaths.filter((f) => !overlayLaid.has(f)) : laidPaths;
112
+ if (overlayLaid && !unlisted.length) {
113
+ return { refuse: false,
114
+ message: `mint-suite-census: ${laid} tracked path(s) are not in HEAD — laid by the private overlay, which lists every one, and not counted` };
115
+ }
116
+ laidPaths = unlisted;
117
+ laid = unlisted.length;
118
+ const named = laidPaths.slice(0, 20).map((f) => ` ${f}`).join("\n");
119
+ const more = laidPaths.length > 20 ? `\n … and ${laidPaths.length - 20} more` : "";
120
+ return { refuse: true,
121
+ message: `mint-suite-census: ${laid} tracked path(s) are in the index and NOT in HEAD, and this tree carries no\n`
122
+ + " cut record — so nothing here is withheld and these are not laid-over files. They are staged work,\n"
123
+ + " and a census minted now would be stamped over a population it did not read:\n"
124
+ + `${named}${more}\n`
125
+ + " Commit them first, then mint. `git add` puts a file in the index; only a commit puts it in HEAD,\n"
126
+ + " and this reads HEAD so the private overlay's withheld corpus is not counted as an addition.\n"
127
+ + " Mid-merge this is the same fault wearing different clothes: commit the merge, then mint." };
128
+ }
129
+
76
130
  /**
77
131
  * The tracked files matching `globs` that HEAD actually carries.
78
132
  *
@@ -94,6 +148,22 @@ const README = [
94
148
  * A tree with no HEAD cannot say what it published. That is a failure to look, and it exits rather
95
149
  * than reading as "nothing is laid here", which is the permissive answer and the one that passes.
96
150
  */
151
+ /**
152
+ * The paths the private overlay says it laid over this clone, or null where it laid nothing.
153
+ *
154
+ * Read from the clone's own git directory, `info/overlay-laid`, one path per line. Inside `.git` on
155
+ * purpose: nothing there is tracked, so it can never reach the published tree or be counted by a guard,
156
+ * and a developer's checkout has no such file unless an overlay wrote it.
157
+ */
158
+ export function overlayLaidOf(root) {
159
+ try {
160
+ const gitDir = execFileSync("git", ["-C", root, "rev-parse", "--absolute-git-dir"], { encoding: "utf8" }).trim();
161
+ const list = join(gitDir, "info", "overlay-laid");
162
+ if (!existsSync(list)) return null;
163
+ return new Set(readFileSync(list, "utf8").split("\n").map((x) => x.trim()).filter(Boolean));
164
+ } catch { return null; }
165
+ }
166
+
97
167
  function publishedFiles(root, globs) {
98
168
  let all = [];
99
169
  try {
@@ -103,7 +173,10 @@ function publishedFiles(root, globs) {
103
173
  if (!all.length) return [];
104
174
  const p = publishedOf(all, root);
105
175
  if (p.error) { console.error(`mint-suite-census: ${p.error}`); process.exit(2); }
106
- if (p.laid) console.log(`mint-suite-census: ${p.laid} tracked path(s) are not in HEAD — laid over this checkout, not published in it, and not counted`);
176
+ const verdict = laidPathVerdict({ laid: p.laid, laidPaths: p.laidPaths ?? [], cutRecordPresent: CUT_RECORD_PRESENT,
177
+ overlayLaid: overlayLaidOf(root) });
178
+ if (verdict.refuse) { console.error(verdict.message); process.exit(2); }
179
+ if (verdict.message) console.log(verdict.message);
107
180
  return p.files.slice().sort();
108
181
  }
109
182
 
@@ -25,7 +25,7 @@ import { readFileSync, writeFileSync } from "node:fs";
25
25
  import { join, dirname } from "node:path";
26
26
  import { fileURLToPath } from "node:url";
27
27
  import { CLASSES, censusOf } from "../shared/writing-standard-classes.mjs";
28
- import { publishedOf } from "../shared/reference-guard-classes.mjs";
28
+ import { publishedOf, publishedReader } from "../shared/reference-guard-classes.mjs";
29
29
  import { trackedFiles, skipReason } from "../shared/tracked-files.mjs";
30
30
  import { isEntrypoint } from "../shared/is-entrypoint.mjs";
31
31
 
@@ -43,7 +43,7 @@ export function mint() {
43
43
  const p = publishedOf(tracked, ROOT);
44
44
  if (p.error) { console.error(`mint-writing-standard-backlog: ${p.error}`); process.exit(2); }
45
45
  if (p.laid) console.log(`mint-writing-standard-backlog: ${p.laid} tracked path(s) are not in HEAD — laid over this checkout, not published in it, and not counted`);
46
- const c = censusOf(p.files, (f) => readFileSync(join(ROOT, f), "utf8"));
46
+ const c = censusOf(p.files, publishedReader(ROOT, (f) => readFileSync(join(ROOT, f), "utf8")));
47
47
  return { classes: CLASSES.map((x) => x.id), total: c.total, files: c.files };
48
48
  }
49
49
 
@@ -13,8 +13,8 @@
13
13
  // republishable from the workspace archive, which does not satisfy "gone":
14
14
  //
15
15
  // 1. pool <poolRoot>/<runId>/ — published, Caddy-served
16
- // 2. archive <ws>/studio/prelim-search/archive/<YYYY-MM>/<matter>/<codename>/ — republish source
17
- // 3. live <ws>/studio/prelim-search/<matter>/<codename>/ — running/failed, never published
16
+ // 2. archive <ws>/studio/clearance-search/archive/<YYYY-MM>/<matter>/<codename>/ — republish source
17
+ // 3. live <ws>/studio/clearance-search/<matter>/<codename>/ — running/failed, never published
18
18
  //
19
19
  // Dry-run is the default and --apply is required to remove anything. The guards below exist because
20
20
  // the failure that matters is not "deleted too little" — it is deleting the one run someone wanted
@@ -24,7 +24,7 @@ import "../shared/env-local.mjs"; // — FIRST: the CLEAROTRON_* translation m
24
24
  // module-top capture below it evaluates. A call in this file's BODY
25
25
  // would run too late — that was the repair that left this open.
26
26
  import { readdirSync, statSync, readFileSync, rmSync, existsSync, appendFileSync } from "node:fs";
27
- import { join, basename } from "node:path";
27
+ import { join, basename } from "node:path"; import { studioDirFor } from "../shared/pre-rename-spellings.mjs";
28
28
  import { driverDir } from "../shared/driver-dir.mjs"; //
29
29
  import { pendingRunIds, noticeOwed } from "../shared/notice-owed.mjs"; // one reading of "still owes a notice", shared with the delivery path
30
30
  // The ONE exception to this script's no-driver-imports posture, and it is the reason that posture exists:
@@ -94,10 +94,10 @@ const WORKSPACE_ROOT = requireRoot("CLEAROTRON_WORK_DIR", "workspace");
94
94
  // same answer to a careless reader, and the careless answer is DELETE.
95
95
  // THE SAME RESOLUTION THE DELIVERY PATH USES, including its default. Reading only the environment
96
96
  // variable would be a SECOND resolution, and a wrong one: `driver.config.mjs` falls back to
97
- // `<workspaceRoot>/prelim-outbox`, so on every box that does not set the variable — which is most of
97
+ // `<workspaceRoot>/clearance-outbox`, so on every box that does not set the variable — which is most of
98
98
  // them — an env-only read answers "no outbox" for an outbox that exists and has markers in it. That is
99
99
  // the same class of defect as not looking at all.
100
- const OUTBOX_DIR = String(process.env.CLEAROTRON_OUTBOX_DIR ?? "").trim() || join(WORKSPACE_ROOT, "prelim-outbox");
100
+ const OUTBOX_DIR = String(process.env.CLEAROTRON_OUTBOX_DIR ?? "").trim() || join(WORKSPACE_ROOT, "clearance-outbox");
101
101
  const OUTBOX_NAMES = (() => {
102
102
  try { return readdirSync(OUTBOX_DIR); } catch (e) {
103
103
  // AN OUTBOX THAT WAS NEVER CREATED IS EMPTY; ONE THAT WILL NOT OPEN IS UNKNOWN. Collapsing the two
@@ -172,7 +172,7 @@ const POOL_NON_RUN = new Set(["assets", "customer", "_state"]);
172
172
  // system's own state is not.
173
173
  const RUN_ARTIFACTS = ["meta.json", "report.html", "report.md", "findings.json", "status.json"];
174
174
  const looksLikeRun = (dir) => RUN_ARTIFACTS.some((f) => existsSync(join(dir, f)));
175
- // Not matter directories inside a workspace prelim-search root.
175
+ // Not matter directories inside a workspace clearance-search root.
176
176
  const WS_NON_MATTER = new Set(["STATUS.md", "archive", "queue", "_known-conflicts"]);
177
177
 
178
178
  const readJson = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
@@ -210,7 +210,7 @@ function collect() {
210
210
  }
211
211
 
212
212
  for (const agent of AGENTS) {
213
- const base = join(WORKSPACE_ROOT, `workspace-${agent}`, "studio", "prelim-search");
213
+ const base = studioDirFor(join(WORKSPACE_ROOT, `workspace-${agent}`));
214
214
  if (!isDir(base)) continue;
215
215
 
216
216
  // 2. Workspace archive — <YYYY-MM>/<matter>/<codename>. runId is matter+codename, matching the
@@ -35,7 +35,7 @@
35
35
  import "../shared/env-local.mjs"; // — FIRST: applies the CLEAROTRON_* translation before any module-top
36
36
  // capture evaluates. Reads no `.env` here — that load is gated on isCliEntry(argv[1]).
37
37
  import { readdirSync, readFileSync, writeFileSync, renameSync, existsSync } from "node:fs";
38
- import { join } from "node:path";
38
+ import { join } from "node:path"; import { studioDirFor } from "../shared/pre-rename-spellings.mjs";
39
39
  import { DRIVER_DIR } from "../shared/driver-dir.mjs"; //
40
40
  import { classifyRun, terminalPatch, DEFAULT_QUIET_MS } from "../driver/reconcile-runs.mjs";
41
41
  import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — realpath both sides, or a symlinked invocation exits 0 silently
@@ -120,7 +120,7 @@ function main() {
120
120
  const now = Date.now();
121
121
  const rows = [];
122
122
  for (const agent of AGENTS) {
123
- const studio = join(WORKSPACE_ROOT, `workspace-${agent}`, "studio", "prelim-search");
123
+ const studio = studioDirFor(join(WORKSPACE_ROOT, `workspace-${agent}`));
124
124
  for (const dir of findRunDirs(studio)) rows.push({ agent, ...reconcileRunDir(dir, { apply: APPLY, now, quietMs: QUIET_MS }) });
125
125
  }
126
126
 
@@ -99,7 +99,25 @@ async function branchHead() {
99
99
  return (await br.json())?.commit?.sha ?? null;
100
100
  }
101
101
 
102
- async function main() {
102
+ /**
103
+ * ONE PASS: read the version branch's head, and approve any run parked on exactly that head.
104
+ *
105
+ * EXPORTED BECAUSE ONCE IS NOT ENOUGH. The step that calls this runs before a wait that then lasts
106
+ * minutes, and the version branch is force-pushed whenever the version step runs — so main moving
107
+ * during the wait leaves a fresh parked run on a fresh head with nobody left to approve it. Measured on
108
+ * the 0.3.2-beta.8 cut: two heads, one approval, cleared by hand. `awaitCut` calls this once per poll
109
+ * so the second head gets the same look the first did.
110
+ *
111
+ * IT NEVER THROWS AND NEVER RETURNS NON-ZERO. A pass that cannot look says so and reports nothing
112
+ * approved; the cut's own wait ends the way it always did. Approving is a convenience, and a
113
+ * convenience that can fail a release is not one.
114
+ */
115
+ export async function approvePass() {
116
+ try { return await onePass(); }
117
+ catch (e) { say(`could not run (${e?.message ?? e}) — nothing approved.`); return 0; }
118
+ }
119
+
120
+ async function onePass() {
103
121
  // THE ABSENT SECRET IS THE ORDINARY CASE UNTIL THE TOKEN IS MINTED, and it must read as ordinary.
104
122
  if (!TOKEN) {
105
123
  say("no ACTIONS_APPROVE_TOKEN — nothing approved; the version run waits for a person, as before.");
@@ -148,4 +166,4 @@ async function main() {
148
166
  // RUN WHEN INVOKED, whatever the file is called. The old guard compared argv[1] to this file's NAME, so
149
167
  // a rename made the step print nothing and exit 0 — a script that never ran, wearing the face of a
150
168
  // successful no-op. Comparing the resolved URLs asks the real question instead.
151
- if (import.meta.url === pathToFileURL(argv[1] ?? "").href) main().then((c) => exit(c)).catch((e) => { say(`could not run (${e?.message ?? e}) — nothing approved.`); exit(0); });
169
+ if (import.meta.url === pathToFileURL(argv[1] ?? "").href) approvePass().then((c) => exit(c));
@@ -48,6 +48,7 @@
48
48
  // one question is how a pipeline comes to publish something nobody merged.
49
49
  import { appendFileSync } from "node:fs";
50
50
  import { execFileSync } from "node:child_process";
51
+ import { approvePass } from "./release-approve-parked.mjs";
51
52
  import { isEntrypoint } from "../shared/is-entrypoint.mjs";
52
53
  import { cutDecision, versionAtHead, tagsHere } from "./release-cut-decision.mjs";
53
54
 
@@ -117,10 +118,23 @@ export const STEP_MS = 30 * 1000;
117
118
  *
118
119
  * Returns `{ cut, version, waitedMs, gaveUp }`. `gaveUp` is not a failure — see the header.
119
120
  */
120
- export async function awaitCut({ refresh, read, sleep, waitMs = WAIT_MS, stepMs = STEP_MS, now = () => 0 } = {}) {
121
+ export async function awaitCut({ refresh, read, sleep, tend = null, waitMs = WAIT_MS, stepMs = STEP_MS, now = () => 0 } = {}) {
121
122
  const started = now();
122
123
  let waitedMs = 0;
123
124
  for (;;) {
125
+ // ── THE SECOND LOOK, EVERY PASS ────────────────────────────────────────────────────────────────
126
+ //
127
+ // `tend` is the parked-run approval, and it is here rather than once before the loop because the
128
+ // version branch is force-pushed whenever the version step runs. If main moves while this waits,
129
+ // the version pull request is refreshed onto a new head, that head parks a run of its own, and the
130
+ // approval already spent was spent on a head that no longer exists.
131
+ //
132
+ // IT CANNOT BREAK THE WAIT. A tend that throws is reported and the pass continues: this loop's job
133
+ // is to notice a merge, and it must keep doing that whether or not a convenience beside it worked.
134
+ if (tend) {
135
+ try { await tend(); }
136
+ catch (e) { console.log(`release-await-cut: the parked-run look failed (${String(e?.message ?? e).slice(0, 120)}) — the wait continues.`); }
137
+ }
124
138
  await refresh();
125
139
  const d = read();
126
140
  // ASKED BEFORE THE FIRST SLEEP, so a merge that landed while CI was finishing costs no wait at all —
@@ -135,6 +149,91 @@ export async function awaitCut({ refresh, read, sleep, waitMs = WAIT_MS, stepMs
135
149
 
136
150
  const git = (args) => execFileSync("git", args, { encoding: "utf8" });
137
151
 
152
+ /**
153
+ * Was a cut ASKED FOR by this run, and which pull request is it waiting on.
154
+ *
155
+ * A push and the schedule set nothing in motion — they ask main a question and the cron floor sits
156
+ * underneath either answer. A dispatch is different: the same run opened the version pull request and
157
+ * told it to merge itself, so the wait expiring means the thing this run was for did not happen.
158
+ */
159
+ export function cutRequest(env = process.env) {
160
+ const asked = String(env.CLEAROTRON_CUT_REQUESTED ?? "").trim() === "true";
161
+ const pr = String(env.CLEAROTRON_CUT_PR ?? "").trim();
162
+ return { asked, pr: /^[0-9]+$/.test(pr) ? Number(pr) : null };
163
+ }
164
+
165
+ /**
166
+ * What to say when the budget expires, and whether it is a failure. PURE.
167
+ *
168
+ * THE DISTINCTION THIS EXISTS FOR. Three ways to publish nothing look identical from outside:
169
+ * nothing was set in motion, this run set something in motion and it did not land, and the loop could
170
+ * not look at all. The third is exit 2 and is decided before this is reached. The first two are what
171
+ * this separates, and until it did, a dispatched cut that released nothing reported success — measured
172
+ * twice on 2026-09-17, on runs 35202212206 and 35260883981.
173
+ *
174
+ * NAMING WHICH IS HALF THE POINT. A red that says only "did not merge" sends the next reader to the
175
+ * same log this was written from. The reason is read off the pull request rather than guessed.
176
+ */
177
+ export function expiryVerdict({ cut, requested, pr = null }) {
178
+ if (cut) return { red: false, reason: null };
179
+ if (!requested) return { red: false, reason: null };
180
+ if (!pr) return { red: true, reason: "a cut was dispatched, and no version pull request was found to wait on" };
181
+ // A PULL REQUEST THAT COULD NOT BE READ IS NOT ONE THAT WAS NOT FOUND. Both used to arrive here as
182
+ // `null`, so a failed read reported "no version pull request was found" about a pull request that was
183
+ // standing open — a confident wrong reason, on the one line this function exists to get right.
184
+ if (pr.unread) {
185
+ return { red: true, reason: `the version pull request (${pr.number}) could not be read, so why it did not merge is unknown — read it by hand (${pr.why})` };
186
+ }
187
+ if (pr.merged) return { red: true, reason: "the version pull request merged, yet main carries no untagged version — the publish this run was for has gone missing" };
188
+ const bad = (pr.checks ?? []).filter((c) => ["failure", "error", "timed_out", "cancelled", "action_required"].includes(c.conclusion));
189
+ if (bad.length) {
190
+ const named = bad.map((c) => `${c.context} (${c.conclusion})`).join(", ");
191
+ return { red: true, reason: `the version pull request did not merge: ${named}` };
192
+ }
193
+ if (!(pr.checks ?? []).some((c) => c.conclusion)) {
194
+ return { red: true, reason: "the version pull request did not merge: no check on it had concluded when the budget expired — it is most likely waiting for approval before its run will start" };
195
+ }
196
+ if (pr.mergeable === false) return { red: true, reason: "the version pull request did not merge: it is not mergeable" };
197
+ return { red: true, reason: "the version pull request did not merge, and nothing on it says why — read it by hand" };
198
+ }
199
+
200
+ /**
201
+ * One entry of the rollup, as a conclusion or `null` while it has none.
202
+ *
203
+ * THE ROLLUP HOLDS TWO SHAPES. A check run carries `conclusion`; a commit status carries `state` and no
204
+ * `conclusion` at all, so reading only the first counts every red status as not-yet-concluded.
205
+ */
206
+ function conclusionOf(c) {
207
+ if (c.conclusion) return String(c.conclusion).toLowerCase();
208
+ if (c.state && !["PENDING", "EXPECTED"].includes(String(c.state).toUpperCase())) return String(c.state).toLowerCase();
209
+ return null;
210
+ }
211
+
212
+ /**
213
+ * The version pull request's state and its checks' conclusions. Injected so an arm can drive it.
214
+ *
215
+ * `null` means there was no number to read. A read that FAILED returns `{ unread: true }` instead, so the
216
+ * verdict can tell "no pull request" from "could not look at it".
217
+ */
218
+ export async function readVersionPr(number, { run = ((args) => execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })) } = {}) {
219
+ if (!number) return null;
220
+ try {
221
+ const j = JSON.parse(run(["pr", "view", String(number), "--repo", "CordilleraSarl/clearotron",
222
+ "--json", "merged,mergeable,statusCheckRollup"]));
223
+ return {
224
+ merged: Boolean(j.merged),
225
+ mergeable: j.mergeable === "MERGEABLE" ? true : j.mergeable === "CONFLICTING" ? false : null,
226
+ checks: (j.statusCheckRollup ?? []).map((c) => ({
227
+ context: String(c.name ?? c.context ?? "a check"),
228
+ conclusion: conclusionOf(c),
229
+ })),
230
+ };
231
+ } catch (e) {
232
+ const why = String(e?.stderr || e?.message || e).trim().split("\n")[0].slice(0, 160);
233
+ return { unread: true, number, why };
234
+ }
235
+ }
236
+
138
237
  /**
139
238
  * One read of `main`: the version it carries, whether that version is tagged, and WHICH COMMIT said so.
140
239
  *
@@ -209,6 +308,8 @@ function main() {
209
308
  // answer this pipeline cannot afford, so it is refreshed on every pass rather than once at checkout.
210
309
  refresh: async () => { git(["fetch", "--no-tags", "--prune", "origin", "+refs/heads/main:refs/remotes/origin/main"]); git(["fetch", "--tags", "--force", "origin"]); },
211
310
  read: () => readMain(),
311
+ // THE SAME SCRIPT THE STEP ABOVE RUNS, not a second implementation of the same decision.
312
+ tend: () => approvePass(),
212
313
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
213
314
  now: () => Date.now() - started,
214
315
  }).catch((e) => {
@@ -229,7 +330,10 @@ function main() {
229
330
  }).then((r) => {
230
331
  if (!r) return;
231
332
  const secs = Math.round(r.waitedMs / 1000);
333
+ // A DISPATCHED CUT DOES NOT GET THE REASSURANCE. The error below says what happened, and a line
334
+ // calling it ordinary directly above that error is the one a reader skimming the log stops at.
232
335
  if (r.cut) console.log(`release-await-cut: main carries ${r.version} with no tag, after ${secs}s. Publishing.`);
336
+ else if (cutRequest().asked) console.log(`release-await-cut: nothing to publish after ${secs}s — main carries ${r.version} and it is already tagged.`);
233
337
  else console.log(`release-await-cut: nothing to publish after ${secs}s — main carries ${r.version} and it is `
234
338
  + "already tagged, or the version branch did not merge. This is the ordinary outcome and not a fault; "
235
339
  + "the scheduled check is still underneath it.");
@@ -239,6 +343,21 @@ function main() {
239
343
  // `sha` IS WRITTEN ON BOTH ANSWERS, not only on a cut. It records which commit this loop's verdict is
240
344
  // about, so a run that published nothing can still be read back against the tree it looked at.
241
345
  if (out) appendFileSync(out, `cut=${r.cut ? "true" : "false"}\nversion=${r.version}\nsha=${r.sha ?? ""}\nlooked=true\n`);
346
+ return r;
347
+ }).then(async (r) => {
348
+ // ── A DISPATCHED CUT THAT PUBLISHED NOTHING IS A FAILED RUN ─────────────────────────────────────
349
+ //
350
+ // Everything above stays exit 0, and on a push or the schedule that is right: nothing was asked
351
+ // for, and the cron floor sits underneath. A dispatch is a request, and THIS run is what tries to
352
+ // satisfy it — it opened the version pull request and armed it. So the budget expiring means the
353
+ // one thing the run existed to do did not happen, and reporting success for that is how a release
354
+ // goes missing with every light green.
355
+ if (!r) return; // could-not-look already exited 2 above
356
+ const { asked, pr } = cutRequest();
357
+ const v = expiryVerdict({ cut: r.cut, requested: asked, pr: asked && !r.cut ? await readVersionPr(pr) : null });
358
+ if (!v.red) return;
359
+ console.error(`::error::release-await-cut: this run dispatched a cut and released nothing. ${v.reason}`);
360
+ process.exitCode = 1;
242
361
  });
243
362
  }
244
363