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
@@ -103,7 +103,9 @@ import { readDrainerStamp, drainerVerdict, defaultPpidOf } from "../driver/drain
103
103
  import { readUpdaterStamp, updaterVerdict, resolveUpdaterStampPath, updaterAbsentHere, UPDATER_STAMP_BASENAME } from "../driver/updater-identity.mjs"; // — the mechanism that PLACES commits
104
104
  import { claimerIsAlive } from "../driver/claim-liveness.mjs"; // the shared liveness test, same polarity as the queue's
105
105
  import { processTable } from "../shared/process-table.mjs"; // — /proc is not the only box
106
- import { envFrom } from "../shared/env-aliases.mjs"; // — the name a reader is told to set is the one in force
106
+ import { envFrom } from "../shared/env-aliases.mjs";
107
+ import { gitTry, treeOf } from "../shared/tree-commit.mjs"; // — a packaged install has no git, and says its commit in build-info.json
108
+ import { exitFor } from "../driver/surface-exit-verdict.mjs"; // — a could-not-look is not a drift, and they want different things done // — the name a reader is told to set is the one in force
107
109
 
108
110
  const HERE = dirname(fileURLToPath(import.meta.url));
109
111
  const asJson = process.argv.includes("--json");
@@ -128,12 +130,44 @@ if (!POOL_ROOT) {
128
130
  // fell through to a hardcoded default while the pool root correctly pointed at test. A cross-instance
129
131
  // probe that reports "ok" is worse than no probe at all.
130
132
  const at = (host, port) => `http://${host || "127.0.0.1"}:${port}`;
131
- const MCP_URL = process.env.TRADEMARK_MCP_URL
132
- || (process.env.TRADEMARK_MCP_HTTP_PORT ? at(process.env.TRADEMARK_MCP_HTTP_HOST, process.env.TRADEMARK_MCP_HTTP_PORT) : "http://127.0.0.1:18792");
133
- const PORTAL_URL = process.env.PORTAL_URL
134
- || (process.env.PORTAL_SERVICE_PORT ? at(process.env.PORTAL_SERVICE_HOST, process.env.PORTAL_SERVICE_PORT) : "http://127.0.0.1:18802");
135
- const CLIENT_MCP_URL = process.env.CLIENT_MCP_URL
136
- || (process.env.CLIENT_MCP_HTTP_PORT ? at(process.env.CLIENT_MCP_HTTP_HOST, process.env.CLIENT_MCP_HTTP_PORT) : "http://127.0.0.1:18811");
133
+
134
+ // AND THE THREE DOORS BELOW USED TO CONTRADICT THAT PARAGRAPH. Each fell back to a literal — 18792,
135
+ // 18802, 18811 — and all three of those are PRODUCTION's ports. So the note above described the defect
136
+ // rather than the behaviour: an instance that does not name its own ports was checked against
137
+ // production's doors and told the result was its own. The reports directory two blocks up had already
138
+ // been given the cure, for the identical reason, and the doors were left behind.
139
+ //
140
+ // The failure is worst where it looks best. On a box with nothing on those ports the arms fail with
141
+ // ECONNREFUSED, which is loud and gets read. On a box that HAS something listening there — this one
142
+ // runs several instances — the probe succeeds and the check reports another instance's health as this
143
+ // instance's, in a PASS. A cross-instance probe that reports "ok" is worse than no probe at all.
144
+ //
145
+ // So: no literal, and unset refuses by name rather than guessing. Every missing variable is named in
146
+ // one refusal, because a reader fixing these one exit code at a time runs the check four times.
147
+ const doorUrl = (explicit, portName, hostName) => {
148
+ const direct = (process.env[explicit] ?? "").trim();
149
+ if (direct) return { url: direct, missing: null };
150
+ const port = (process.env[portName] ?? "").trim();
151
+ if (port) return { url: at(process.env[hostName], port), missing: null };
152
+ return { url: null, missing: `${explicit} or ${portName}` };
153
+ };
154
+
155
+ const DOORS = {
156
+ "ops-MCP": doorUrl("TRADEMARK_MCP_URL", "TRADEMARK_MCP_HTTP_PORT", "TRADEMARK_MCP_HTTP_HOST"),
157
+ portal: doorUrl("PORTAL_URL", "PORTAL_SERVICE_PORT", "PORTAL_SERVICE_HOST"),
158
+ "client door": doorUrl("CLIENT_MCP_URL", "CLIENT_MCP_HTTP_PORT", "CLIENT_MCP_HTTP_HOST"),
159
+ };
160
+
161
+ // An unconfigured door is NOT PROBED and says so by name. It is deliberately NOT a refusal: this check
162
+ // is driven against boxes that legitimately do not run every door, and an arrival check that aborts
163
+ // because one door is unnamed reports nothing about the seven surfaces it could have read. The
164
+ // `trigger lane reachable` arm below has answered exactly this way since it was written — an unset
165
+ // origin is skipped, never passed and never failed — and this is that rule applied to the other three.
166
+ const DOORS_UNSET = Object.entries(DOORS).filter(([, d]) => d.missing);
167
+
168
+ const MCP_URL = DOORS["ops-MCP"].url;
169
+ const PORTAL_URL = DOORS.portal.url;
170
+ const CLIENT_MCP_URL = DOORS["client door"].url;
137
171
 
138
172
  // The bundled demo roster, as `list_profiles` REPORTS it. A door that resolves exactly this set is a
139
173
  // door with no CLEAROTRON_CUSTOMERS_DIR — #83. Compared as a set, not a count: a deployment may legitimately
@@ -158,11 +192,37 @@ const OPS_TOKEN = (() => {
158
192
  })();
159
193
 
160
194
  const results = [];
161
- const record = (name, state, detail) => { results.push({ name, state, detail }); return state === "pass"; };
195
+ const record = (name, state, detail, blocked = false) => { results.push({ name, state, detail, blocked }); return state === "pass"; };
162
196
  const pass = (n, d) => record(n, "pass", d);
163
197
  const fail = (n, d) => record(n, "fail", d);
164
198
  const skip = (n, d) => record(n, "skip", d); // could not be reached — never counted as a pass
165
199
 
200
+ /**
201
+ * COULD NOT LOOK — a skip, and one that moves the exit code.
202
+ *
203
+ * Two arms used to call `fail` while their own message said "This is a failure to look, never a pass".
204
+ * The text was honest and the verdict was not: FAIL is what a genuine drift also produces, so a reader
205
+ * could not tell "this box has drifted" from "I was unable to look" without reading to the end of the
206
+ * message. A drift is fixed by redeploying; a could-not-look is fixed by pointing the check at something
207
+ * it can read. Until somebody does, nothing is known either way.
208
+ *
209
+ * IT IS NOT THE SAME AS AN ORDINARY SKIP, which is why this exists rather than reusing `skip`. Several
210
+ * surfaces are deliberately not probed — the client door answers behind an access proxy, and a door this
211
+ * instance does not name has no address to dial. Those are by design and are the resting state of a
212
+ * healthy box. If every skip moved the exit code, the new code would fire on every good run and be
213
+ * ignored inside a week, which is the failure this whole issue is about repeated one level up.
214
+ */
215
+ const blocked = (n, d) => record(n, "skip", d, true);
216
+
217
+ // Named here rather than where the URLs are resolved, because `skip` does not exist yet up there. Each
218
+ // unconfigured door is on the report as its own line, so a reader sees WHICH surface went unread instead
219
+ // of inferring it from arms that are quietly missing.
220
+ for (const [name, d] of DOORS_UNSET) {
221
+ skip(`${name} configured`, `neither ${d.missing} is set, so this instance does not say where its ${name} is `
222
+ + "— NOT PROBED. It used to fall through to production's port and report that door's answer as this "
223
+ + "instance's own.");
224
+ }
225
+
166
226
  // ── helpers ──────────────────────────────────────────────────────────────────────────────────────────
167
227
 
168
228
  function getJson(url, timeoutMs = 5000) {
@@ -196,13 +256,8 @@ function tcpAlive(url, timeoutMs = 3000) {
196
256
  // just written explaining itself, leaving `null` to mean "no clone here" and "could not ask" equally.
197
257
  //
198
258
  // `gitTry` keeps the reason; `git` stays exactly as it was for the callers that only want the value.
199
- const gitTry = (repo, ...args) => {
200
- try {
201
- return { ok: true, out: execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(), err: null };
202
- } catch (e) {
203
- return { ok: false, out: null, err: String(e?.stderr || e?.message || e).replace(/\s+/g, " ").trim().slice(0, 120) };
204
- }
205
- };
259
+ // It now lives in shared/tree-commit.mjs beside `treeOf`, which asks it first and falls back to a
260
+ // packaged install's build-info.json.
206
261
  const git = (repo, ...args) => gitTry(repo, ...args).out;
207
262
 
208
263
  // — `systemctl --user` NEEDS A USER BUS, and a caller without one gets an error, not an empty list.
@@ -260,7 +315,7 @@ function declaredTimers() {
260
315
  function serviceClones() {
261
316
  // — the list is DECLARED, not written here. It used to be eight names inline, which put a unit
262
317
  // inside the drift guarantee or outside it by omission: `client-access` was live on production and in
263
- // no list at all, and `prelim-outbox` was tracked, live on production, and equally invisible. Both are
318
+ // no list at all, and `clearance-outbox` was tracked, live on production, and equally invisible. Both are
264
319
  // in the inventory now, and so is the reason each untracked unit is untracked.
265
320
  const units = [...CHECKED_UNITS];
266
321
  const env = userBusEnv();
@@ -307,9 +362,9 @@ function serviceClones() {
307
362
  const parsed = unitWorkingDirectory(wd);
308
363
  if (!parsed.path) declaredWhy = parsed.why;
309
364
  else {
310
- const top = gitTry(parsed.path, "rev-parse", "--show-toplevel");
311
- if (top.ok) declaredTree = top.out;
312
- else declaredWhy = `git could not read ${parsed.path}: ${top.err}`;
365
+ const t = treeOf(parsed.path);
366
+ if (t.root) declaredTree = t.root;
367
+ else declaredWhy = t.why;
313
368
  }
314
369
  }
315
370
 
@@ -338,9 +393,9 @@ function serviceClones() {
338
393
  const { tree, why } = treeOfRunning(cmdline, rel);
339
394
  if (!tree) runningWhy = why;
340
395
  else {
341
- const top = gitTry(tree, "rev-parse", "--show-toplevel");
342
- if (top.ok) runningTree = top.out;
343
- else runningWhy = `git could not read ${tree}, which pid ${pid} is running from: ${top.err}`;
396
+ const t = treeOf(tree);
397
+ if (t.root) runningTree = t.root;
398
+ else runningWhy = `pid ${pid} is running from ${tree}, and ${t.why}`;
344
399
  }
345
400
  }
346
401
  }
@@ -357,11 +412,11 @@ function serviceClones() {
357
412
  unreadable: idle ? null : chosen.why });
358
413
  continue;
359
414
  }
360
- const head = gitTry(chosen.clone, "rev-parse", "HEAD");
415
+ const head = treeOf(chosen.clone);
361
416
  out.push({ unit: u, active, load, type, since, clone: chosen.clone, source: chosen.source,
362
417
  disagreement: chosen.disagreement,
363
- head: head.ok ? head.out : null,
364
- unreadable: head.ok ? null : `git could not read HEAD in ${chosen.clone}: ${head.err}` });
418
+ head: head.head,
419
+ unreadable: head.head ? null : head.why });
365
420
  }
366
421
  const probe = reached > 0
367
422
  ? { ok: true, why: null }
@@ -487,6 +542,7 @@ else skip("register wired", "the snapshot carries no register block");
487
542
  // it from what the door itself resolved is what keeps a customer key out of this source file.
488
543
  let probeProfileKey = null;
489
544
  try {
545
+ if (!MCP_URL) throw new Error("__door_unset__");
490
546
  const listed = await mcpToolCall({ url: MCP_URL, token: OPS_TOKEN, tool: "list_profiles", args: {}, timeoutMs: 15000 });
491
547
  const keys = (Array.isArray(listed?.clients) ? listed.clients : []).map((p) => p.key ?? p.profileKey).filter(Boolean).sort();
492
548
  probeProfileKey = keys[0] ?? null;
@@ -547,7 +603,8 @@ try {
547
603
  }
548
604
  }
549
605
  } catch (e) {
550
- fail("roster resolves", `list_profiles failed: ${e.message}`);
606
+ if (e?.message === "__door_unset__") skip("roster resolves", "this instance does not say where its ops-MCP is, so the roster was NOT PROBED");
607
+ else fail("roster resolves", `list_profiles failed: ${e.message}`);
551
608
  }
552
609
 
553
610
  // 3. THE LOAD-BEARING CHECK — every door's availability answer vs the engine's own, recomputed here
@@ -555,6 +612,7 @@ try {
555
612
  // from its own process environment instead of from this file.
556
613
  let mcpOptions = null;
557
614
  try {
615
+ if (!MCP_URL) throw new Error("__door_unset__");
558
616
  mcpOptions = await mcpToolCall({ url: MCP_URL, token: OPS_TOKEN, tool: "describe_options", args: {}, timeoutMs: 20000 });
559
617
  pass("ops-MCP reachable", `${MCP_URL} answered describe_options`);
560
618
  // ── — REACHABLE IS NOT THE SAME AS DELIBERATE ─────────────────────────────
@@ -584,7 +642,8 @@ try {
584
642
  record("the ops door's auth mode was chosen for it", posture.state, posture.message);
585
643
  }
586
644
  } catch (e) {
587
- fail("ops-MCP reachable", `${MCP_URL}: ${e.message}`);
645
+ if (e?.message === "__door_unset__") skip("ops-MCP reachable", "this instance does not say where its ops-MCP is — NOT PROBED");
646
+ else fail("ops-MCP reachable", `${MCP_URL}: ${e.message}`);
588
647
  }
589
648
 
590
649
  // ── 3b. THE TRIGGER LANE, AS ITS OWN SURFACE ─────────────────────────────────────────────────────────
@@ -702,15 +761,17 @@ if (mcpOptions) {
702
761
 
703
762
  // 6. The portal. Only /portal/health is reachable without a Cloudflare Access JWT — everything else is
704
763
  // NOT PROBED, and says so rather than passing by omission.
705
- const health = await getJson(`${PORTAL_URL}/portal/health`);
706
- if (health.status !== 200 || !health.json) fail("portal health", `${PORTAL_URL}/portal/health → ${health.status || health.error}`);
764
+ const health = PORTAL_URL ? await getJson(`${PORTAL_URL}/portal/health`) : null;
765
+ if (!PORTAL_URL) skip("portal health", "this instance does not say where its portal is — NOT PROBED");
766
+ else if (health.status !== 200 || !health.json) fail("portal health", `${PORTAL_URL}/portal/health → ${health.status || health.error}`);
707
767
  else if (health.json.ui !== "built") fail("portal health", `ui="${health.json.ui}" — the portal is serving a stale or missing bundle (never add --omit=dev to the deploy)`);
708
768
  else pass("portal health", `ok=${health.json.ok} ui="${health.json.ui}"`);
709
769
  skip("portal gates agree", "every portal route but /portal/health is behind Cloudflare Access — not callable from a script, so NOT probed");
710
770
 
711
771
  // 7. client-MCP liveness only, for the same reason. Its API-key door's secret is a crown jewel and is
712
772
  // never read, let alone logged, by this script.
713
- skip("client-MCP", (await tcpAlive(CLIENT_MCP_URL)) ? "listening; behind CF Access so its answers are NOT probed" : `not listening on ${CLIENT_MCP_URL}`);
773
+ skip("client-MCP", !CLIENT_MCP_URL ? "this instance does not say where its client door is — NOT PROBED"
774
+ : (await tcpAlive(CLIENT_MCP_URL)) ? "listening; behind CF Access so its answers are NOT probed" : `not listening on ${CLIENT_MCP_URL}`);
714
775
 
715
776
  // 8. Every service on the commit you think it is. This is the straddle check.
716
777
  const { clones, probe: unitProbe } = serviceClones();
@@ -733,7 +794,10 @@ const heads = [...new Set(running.map((c) => c.head))];
733
794
  // demanded commit agreement from services this deploy must not move, and reddened on every prod deploy
734
795
  // for units behaving correctly. The discriminator is the CLONE, which `serviceClones()` has read all
735
796
  // along; see `serviceCommitVerdict` for why it is not `tracked`.
736
- const deployClone = git(HERE, "rev-parse", "--show-toplevel");
797
+ // A PACKAGED INSTALL NAMES ITS OWN COMMIT IN build-info.json, and this script runs from inside one on
798
+ // production and pre-prod. Asking git alone reported both as unreadable on every deploy.
799
+ const deployTree = treeOf(HERE);
800
+ const deployClone = deployTree.root;
737
801
  if (!unitProbe.ok)
738
802
  skip("services share one commit", `could not enumerate systemd --user units, so the commit was NOT compared — ${unitProbe.why}. This is a failure to look, not a finding about the deployment`);
739
803
  // — THE SAME DEFECT ONE BRANCH UP, found reviewing this change rather than in the issue. This
@@ -744,7 +808,7 @@ if (!unitProbe.ok)
744
808
  // unreadable disclosure, so the decision belongs there and not in a pre-filter.
745
809
  else {
746
810
  const ownedClone = running.find((c) => String(c.clone ?? "").replace(/\/+$/, "") === String(deployClone ?? "").replace(/\/+$/, ""));
747
- const ahead = ownedClone ? (git(ownedClone.clone, "status", "-sb")?.includes("ahead") ?? false) : false;
811
+ const ahead = ownedClone && deployTree.source === "git" ? (git(ownedClone.clone, "status", "-sb")?.includes("ahead") ?? false) : false;
748
812
  // — ALL the clones, not just the ones with a head. The verdict scopes to owned units itself;
749
813
  // passing it the pre-filtered list is what made the units it could not read invisible to the sentence
750
814
  // it prints.
@@ -774,9 +838,10 @@ else {
774
838
  catch (e) { resolveError = String(e?.message ?? e).slice(0, 160); }
775
839
 
776
840
  if (!workspaceRoot) {
777
- fail("the process that executes runs is on the deployed commit",
841
+ blocked("the process that executes runs is on the deployed commit",
778
842
  `the workspace root could not be resolved (${resolveError ?? "no value"}), so the drainer's stamp could not be found. `
779
- + "This is a failure to look, never a pass.");
843
+ + "This is a failure to look, never a pass — and it is not a drift either: nothing is known about "
844
+ + "this surface until the check can be pointed at something it can read.");
780
845
  } else {
781
846
  // `null` from processTable is UNREADABLE, not empty — the verdict distinguishes them, because
782
847
  // "no drainer is running" and "I could not see the process table" are the same empty array and only
@@ -785,7 +850,7 @@ else {
785
850
  try { processes = processTable(); } catch { processes = null; }
786
851
  const v = drainerVerdict({
787
852
  stamp: readDrainerStamp(workspaceRoot),
788
- headCommit: git(deployClone ?? HERE, "rev-parse", "HEAD"),
853
+ headCommit: deployTree.head,
789
854
  isAlive: claimerIsAlive,
790
855
  processes,
791
856
  // second criterion — an orphaned drainer in a closed login session is a
@@ -798,7 +863,10 @@ else {
798
863
  // to an unprobed posture — a failure to look, never a pass.
799
864
  posture: { worker: probeWorker(), timer: probeTimer() },
800
865
  });
801
- record("the process that executes runs is on the deployed commit", v.state, v.message);
866
+ // — AND THE VERDICT'S OWN could-not-look MARKER, carried through. The verdict distinguishes a
867
+ // process table it could not read from one that answered; dropping that here would put the
868
+ // distinction back where it was, one layer down.
869
+ record("the process that executes runs is on the deployed commit", v.state, v.message, v.blocked === true);
802
870
  }
803
871
  }
804
872
 
@@ -857,9 +925,10 @@ else {
857
925
  }
858
926
 
859
927
  if (!resolveUpdaterStampPath(deployDir)) {
860
- fail("the updater that deploys this box is the current one",
928
+ blocked("the updater that deploys this box is the current one",
861
929
  `where the updater stamps itself could not be resolved (${resolveWhy ?? "no value"}), so `
862
- + `${UPDATER_STAMP_BASENAME} was not looked for. This is a failure to look, never a pass.`);
930
+ + `${UPDATER_STAMP_BASENAME} was not looked for. This is a failure to look, never a pass — and not `
931
+ + "a drift: redeploying would change nothing, because nothing was compared.");
863
932
  } else {
864
933
  const v = updaterVerdict({
865
934
  stamp: readUpdaterStamp(deployDir),
@@ -951,7 +1020,7 @@ else {
951
1020
  //
952
1021
  // The check above compares a unit against its tracked file. It can only do that for units it was told
953
1022
  // to look at, and the list was eight names written inline — so `client-access` ran on production, in no
954
- // list, compared against nothing, and reported by nothing. `prelim-outbox` was the mirror: tracked and
1023
+ // list, compared against nothing, and reported by nothing. `clearance-outbox` was the mirror: tracked and
955
1024
  // live on production, and equally absent from the list, so its drift was never checked either.
956
1025
  //
957
1026
  // This arm asks the question one level up. It is deliberately NOT the drift comparison: a unit can be
@@ -1054,6 +1123,11 @@ else {
1054
1123
 
1055
1124
  const failed = results.filter((r) => r.state === "fail");
1056
1125
  const skipped = results.filter((r) => r.state === "skip");
1126
+ const couldNotLook = results.filter((r) => r.blocked);
1127
+
1128
+ // The three answers this check can give, and which outranks which, are in driver/surface-exit-verdict.mjs
1129
+ // — extracted for the reason roster-verdict and unit-state-verdict were: this file is a program, and a
1130
+ // decision that can only be reached by running it is a decision nobody can drive.
1057
1131
 
1058
1132
  if (asJson) {
1059
1133
  console.log(JSON.stringify({ ok: failed.length === 0, poolRoot: POOL_ROOT, register: wiredRegister, results }, null, 2));
@@ -1061,8 +1135,17 @@ if (asJson) {
1061
1135
  const mark = { pass: " ok ", fail: " FAIL ", warn: " warn ", skip: " skip " };
1062
1136
  console.log(`\n== live surface check — ${POOL_ROOT} ==\n`);
1063
1137
  for (const r of results) console.log(`[${mark[r.state]}] ${r.name}\n ${r.detail}`);
1064
- console.log(`\n${failed.length === 0 ? "PASS" : `FAIL — ${failed.length} disagreement(s)`}`
1138
+ const headline = failed.length ? `FAIL — ${failed.length} disagreement(s)`
1139
+ : couldNotLook.length ? `COULD NOT LOOK — ${couldNotLook.length} surface(s) unreadable, and none of the rest disagreed`
1140
+ : "PASS";
1141
+ console.log(`\n${headline}`
1065
1142
  + `${skipped.length ? ` · ${skipped.length} surface(s) NOT probed (see above — not probed is not passed)` : ""}\n`);
1143
+ // Named, not left to the exit code alone: whoever reads this on a terminal never sees `$?`.
1144
+ if (couldNotLook.length) {
1145
+ console.log(" could not be read — nothing is known about these either way, and redeploying changes nothing:");
1146
+ for (const r of couldNotLook) console.log(` ${r.name}`);
1147
+ console.log("");
1148
+ }
1066
1149
  }
1067
1150
 
1068
- process.exit(failed.length === 0 ? 0 : 1);
1151
+ process.exit(exitFor({ failed: failed.length, couldNotLook: couldNotLook.length }));
@@ -31,7 +31,7 @@
31
31
  // subtly wrong makes this loud in the one direction that trains people to override it.
32
32
  // · Destinations containing `<` or `>`. The skills corpus writes prose templates with
33
33
  // angle-bracket placeholders — `[<register> · <id>](<composed record URL>)` in
34
- // driver/skills/prelim-search/delivery-contract.md is an instruction to a model, not a path.
34
+ // driver/skills/clearance-search/delivery-contract.md is an instruction to a model, not a path.
35
35
  // No real path contains those characters, so the exclusion costs nothing.
36
36
  //
37
37
  // ── THE ALLOWLIST IS SELF-INVALIDATING ───────────────────────────────────────────────────────────
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Copyright 2026 Cordillera Sarl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
4
+ //
5
+ // WHAT A MERGE DID TO THE SHAPE OF THE TREE, which no other check here asks.
6
+ //
7
+ // Every gate in this directory reads CONTENT: is this citation resolvable, does this commit owe a release
8
+ // note, does the census match the tree, is this variable declared. A merge's damage is to the SHAPE — what
9
+ // it deleted, what it brought back, what there are now two of — and a tree can be damaged in all three ways
10
+ // while every content gate passes, because each of them asks only about files that are there.
11
+ //
12
+ // Measured on 2026-09-17, on a tree carrying 26 duplicated release notes: `citation-line-check`,
13
+ // `release-note-required`, `env-audit` and `mint-suite-census --check` all exited 0. The arm that caught it
14
+ // ran in CI, after the push.
15
+ //
16
+ // ── THE INCIDENT THIS IS BUILT FROM ──────────────────────────────────────────────────────────────────
17
+ //
18
+ // A branch was rebased locally while the remote still carried its original commit, so the published tip was
19
+ // merged back rather than force-pushed — right for the history and wrong for the tree. That tip's lineage
20
+ // predated a release, so the merge restored the release notes the cut had consumed. Each then existed in
21
+ // both `.changeset/` and `.changeset/pre/`, and the next cut would have republished text a reader had
22
+ // already been shown.
23
+ //
24
+ // The merge was checked at the time for the things a merge is usually checked for: no work lost, no change
25
+ // applied twice, every test name still present. Nobody asked what else it brought back.
26
+ //
27
+ // AND IT WEAKENED A TEST IN ANOTHER FILE. A guard being written that week failed to fail when its author
28
+ // planted a defect, because the duplication meant the code under test found what it needed at the first
29
+ // path and never reached the branch being driven. A shape defect does not only break its own guard; it
30
+ // silently changes what other tests reach. That is why this asks about the tree rather than about a file.
31
+ //
32
+ // ── WHAT IT REFUSES, AND THE ONE IT DELIBERATELY DOES NOT ────────────────────────────────────────────
33
+ //
34
+ // RESURRECTED — refused, and ONLY under the paths named in RESURRECTION_SCOPE. A file the merge added to
35
+ // the target that the target's own history had deleted.
36
+ //
37
+ // THE SCOPE IS THE WHOLE DESIGN, not a first cut somebody forgot to widen. Judged over the whole tree this
38
+ // check refuses `git revert`: reverting a commit that deleted a file re-adds it, and the target's history
39
+ // carries the deletion, which is this condition word for word. A revert is an honest act and one of the
40
+ // most valuable ones on this repository — so a guard that reds on it is a guard somebody switches off, and
41
+ // then it is not there for the case it was built for.
42
+ //
43
+ // So it runs where resurrection has no honest explanation. A release note consumed by a cut has been shown
44
+ // to readers already; nothing legitimate brings it back. That is a property of `.changeset/`, not of files
45
+ // in general, and the list says so rather than pretending to a generality it does not have.
46
+ //
47
+ // WIDENING IT NEEDS THE REVERT QUESTION ANSWERED FIRST. The durable discriminator is that a revert's diff
48
+ // is the inverse of the deleting commit's, not anything in its message, which can say whatever was typed.
49
+ // Until something computes that, more paths means more honest reds.
50
+ //
51
+ // TWO OF ONE NAME — refused. `.changeset/` and `.changeset/pre/` holding the same note. ONE pair, named
52
+ // here, not a framework for pairs: this is the only one anybody has been bitten by, and a general
53
+ // mechanism for a population of one is a mechanism whose other members are imaginary.
54
+ //
55
+ // REMOVED — reported, never refused. A merge that deletes from the branch it is merging into is worth a
56
+ // look and is often correct: the removal that FIXED the incident above was exactly this shape. A guard
57
+ // that reds on an honest deletion is a guard somebody switches off, so this one prints and exits 0.
58
+
59
+ import { execFileSync } from "node:child_process";
60
+ import { dirname, basename } from "node:path";
61
+ import { fileURLToPath } from "node:url";
62
+
63
+ const HERE = dirname(fileURLToPath(import.meta.url));
64
+
65
+ /**
66
+ * Run git and hand back stdout, or null when it fails.
67
+ *
68
+ * HOOKS OFF, ALWAYS. This runs git inside repositories the arms below create, and a `core.hooksPath` set
69
+ * globally reaches into every one of them — which is how a suite ends up depending on whoever ran it last
70
+ * having the right git config. Measured the hard way on this box, 2026-09-17: a global hooks path broke two
71
+ * arms because the commit-message hook treats a repository with no origin as public, and a throwaway
72
+ * repository has no origin either.
73
+ */
74
+ export const gitIn = (repo) => (...args) => {
75
+ try {
76
+ return execFileSync("git", ["-c", "core.hooksPath=", "-C", repo, ...args],
77
+ { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
78
+ } catch { return null; }
79
+ };
80
+
81
+ /** The lines of a `--name-only` listing, empties dropped. A null read is an empty list, never a throw. */
82
+ const lines = (out) => String(out ?? "").split("\n").map((s) => s.trim()).filter(Boolean);
83
+
84
+ /**
85
+ * THE PAIRS THAT MAY NOT HOLD THE SAME NAME. A note in both places has its text republished by the next
86
+ * cut — the reader is shown an announcement they were shown in the last release.
87
+ *
88
+ * Deliberately a list of one. See the header.
89
+ */
90
+ export const UNIQUE_PAIRS = [
91
+ { a: ".changeset", b: ".changeset/pre", why: "a note in both would have its text republished by the next cut" },
92
+ ];
93
+
94
+ /**
95
+ * WHERE A FILE COMING BACK HAS NO HONEST EXPLANATION. See the header: judged over the whole tree, the
96
+ * resurrection question refuses `git revert`, which is why this is a list and not a wildcard.
97
+ *
98
+ * A release note the cut consumed has been shown to readers. There is no act that legitimately returns one,
99
+ * so here — and only here — a file arriving back through a merge is a finding rather than a question.
100
+ */
101
+ export const RESURRECTION_SCOPE = [".changeset/"];
102
+
103
+ /**
104
+ * Is this commit a merge, and what are its parents?
105
+ *
106
+ * The FIRST parent is the branch being merged INTO, which is the thing every question below is asked
107
+ * about: "what did this do to the target" only means something relative to what the target had.
108
+ */
109
+ export function parentsOf(git, commit) {
110
+ const out = git("rev-list", "--parents", "-n", "1", commit);
111
+ if (!out) return { ok: false, parents: [], why: `could not read ${commit}` };
112
+ const ids = out.split(/\s+/).filter(Boolean).slice(1);
113
+ return { ok: true, parents: ids, why: null };
114
+ }
115
+
116
+ /**
117
+ * The files a merge added to its first parent, and the ones it removed.
118
+ *
119
+ * `--diff-filter` on the two-dot form: what the target gained and lost by taking this merge. NOT the
120
+ * three-dot form, which answers a question about the merge base and would report the branch's own additions
121
+ * as though the merge had made them.
122
+ */
123
+ export function addedAndRemoved(git, firstParent, commit) {
124
+ return {
125
+ added: lines(git("diff", "--name-only", "--diff-filter=A", firstParent, commit)),
126
+ removed: lines(git("diff", "--name-only", "--diff-filter=D", firstParent, commit)),
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Of the files this merge ADDED, the ones the target had previously DELETED — and the commit that deleted
132
+ * each, so the finding is judgeable without a second command.
133
+ *
134
+ * A file the target never had is an ordinary addition and is not reported: a branch adding files is what a
135
+ * branch is for. What has no innocent explanation is a file the target decided to remove arriving back
136
+ * through a merge, because the branch did not delete it — its lineage was older than the deletion.
137
+ */
138
+ export function resurrectedBy(git, firstParent, added, scope = RESURRECTION_SCOPE) {
139
+ const out = [];
140
+ for (const path of added) {
141
+ if (!scope.some((p) => path.startsWith(p))) continue;
142
+ const deletedIn = git("log", "--diff-filter=D", "--format=%h %s", "-1", firstParent, "--", path);
143
+ if (deletedIn) out.push({ path, deletedIn });
144
+ }
145
+ return out;
146
+ }
147
+
148
+ /** Names present under BOTH halves of a pair. Compared by basename, which is what a cut reads. */
149
+ export function duplicatedAcross(git, commit, pairs = UNIQUE_PAIRS) {
150
+ const at = (dir) => {
151
+ const all = lines(git("ls-tree", "-r", "--name-only", commit, "--", `${dir}/`));
152
+ return all.filter((f) => f.endsWith(".md"));
153
+ };
154
+ const out = [];
155
+ for (const pair of pairs) {
156
+ const inB = new Set(at(pair.b).map((f) => basename(f)));
157
+ // The `a` half must EXCLUDE the `b` half, or every file under the nested directory counts as its own
158
+ // duplicate and the check reports the whole of `pre/` against itself.
159
+ const inA = at(pair.a).filter((f) => !f.startsWith(`${pair.b}/`)).map((f) => basename(f));
160
+ const both = [...new Set(inA.filter((n) => inB.has(n)))].sort();
161
+ if (both.length) out.push({ ...pair, names: both });
162
+ }
163
+ return out;
164
+ }
165
+
166
+ /**
167
+ * The whole reading for one commit. Never throws: an unreadable commit is a stated could-not-look, because
168
+ * a shape check that dies on a bad argument and exits non-zero is indistinguishable from one that found
169
+ * something.
170
+ */
171
+ export function mergeShape({ repo = HERE, commit = "HEAD", git = gitIn(repo) } = {}) {
172
+ const { ok, parents, why } = parentsOf(git, commit);
173
+ if (!ok) return { readable: false, why, isMerge: false, added: [], removed: [], resurrected: [], duplicated: [] };
174
+ if (parents.length < 2) {
175
+ return { readable: true, why: null, isMerge: false, added: [], removed: [], resurrected: [], duplicated: [] };
176
+ }
177
+ const { added, removed } = addedAndRemoved(git, parents[0], commit);
178
+ return {
179
+ readable: true, why: null, isMerge: true, firstParent: parents[0], added, removed,
180
+ resurrected: resurrectedBy(git, parents[0], added),
181
+ duplicated: duplicatedAcross(git, commit),
182
+ };
183
+ }
184
+
185
+ /** The exit code a reading earns. Resurrection and a duplicated name refuse; a removal is reported. */
186
+ export function verdictOf(shape) {
187
+ if (!shape.readable) return 2;
188
+ return shape.resurrected.length || shape.duplicated.length ? 1 : 0;
189
+ }
190
+
191
+ /**
192
+ * The repository and the commit a command line names. PURE, so an arm can hold it.
193
+ *
194
+ * The first version filtered out "the argument after --repo" as `i !== repoAt + 1`. With no --repo,
195
+ * `repoAt` is -1 and that filter drops position 0 — the commit the reader named — so the check examined
196
+ * HEAD instead, found no merge, and exited 0 saying nothing was examined: a clean answer about a commit
197
+ * nobody asked about.
198
+ */
199
+ export function parseArgs(args, here = HERE) {
200
+ const repoAt = args.indexOf("--repo");
201
+ const repo = repoAt >= 0 ? args[repoAt + 1] : here;
202
+ const commit = args.filter((a, i) => !a.startsWith("--") && (repoAt < 0 || i !== repoAt + 1))[0] ?? "HEAD";
203
+ return { repo, commit };
204
+ }
205
+
206
+ if (import.meta.url === `file://${process.argv[1]}`) {
207
+ const { repo, commit } = parseArgs(process.argv.slice(2));
208
+
209
+ const shape = mergeShape({ repo, commit });
210
+ const code = verdictOf(shape);
211
+
212
+ if (!shape.readable) {
213
+ console.error(`merge-shape-check: ${shape.why} — nothing was examined, which is not the same as nothing being wrong.`);
214
+ process.exit(code);
215
+ }
216
+ if (!shape.isMerge) {
217
+ console.log(`merge-shape-check: ${commit} is not a merge, so there is no merge to judge. Nothing examined.`);
218
+ process.exit(0);
219
+ }
220
+
221
+ console.log(`merge-shape-check: ${commit} against its first parent ${shape.firstParent.slice(0, 8)}`);
222
+ console.log(` ${shape.added.length} file(s) added to the target, ${shape.removed.length} removed from it`);
223
+
224
+ for (const r of shape.resurrected) {
225
+ console.error(`\n RESURRECTED ${r.path}`);
226
+ console.error(` the target deleted this in ${r.deletedIn}, and this merge brings it back. The branch did not`);
227
+ console.error(` re-add it — its lineage is older than the deletion, so the file rode in on the merge.`);
228
+ }
229
+ for (const d of shape.duplicated) {
230
+ console.error(`\n TWO OF ${d.names.length} NAME(S) ${d.a} and ${d.b}`);
231
+ console.error(` ${d.why}`);
232
+ for (const n of d.names) console.error(` ${n}`);
233
+ }
234
+ // Printed last and never fatal: this is the shape a correct fix also has.
235
+ if (shape.removed.length) {
236
+ console.log(`\n removed from the target by this merge — read them, they are often correct:`);
237
+ for (const p of shape.removed.slice(0, 40)) console.log(` ${p}`);
238
+ if (shape.removed.length > 40) console.log(` … and ${shape.removed.length - 40} more`);
239
+ }
240
+ if (code === 0) console.log("\n nothing resurrected and nothing duplicated.");
241
+ process.exit(code);
242
+ }
@@ -7,15 +7,15 @@
7
7
  // ── WHY THIS IS DERIVED AND NOT WRITTEN BY HAND ──────────────────────────────────────────────────
8
8
  //
9
9
  // It is the replacement half of the retired-spelling check. The DETECTION half
10
- // needs nothing from this file: no product code reads a `PRELIM_*` name any more, so a `PRELIM_*` that
10
+ // needs nothing from this file: no product code reads a `CLEARANCE_*` name any more, so a `CLEARANCE_*` that
11
11
  // is set is dead whatever this list says. What the list decides is the second half of the sentence —
12
12
  // which name to tell the operator to use instead — and that is where a hand list does damage. A hand
13
13
  // list stops at whoever was looking, and sending an operator to a variable nothing reads replaces one
14
14
  // silent failure with another.
15
15
  //
16
- // The rule is the rename's own: the sweep moved `PRELIM_<SUFFIX>` to `CLEAROTRON_<SUFFIX>`, suffix
17
- // untouched. So every `CLEAROTRON_<SUFFIX>` this build reads implies `PRELIM_<SUFFIX>` was its old
18
- // spelling, and a `PRELIM_*` name with no `CLEAROTRON_` partner is a setting that no longer exists at
16
+ // The rule is the rename's own: the sweep moved `CLEARANCE_<SUFFIX>` to `CLEAROTRON_<SUFFIX>`, suffix
17
+ // untouched. So every `CLEAROTRON_<SUFFIX>` this build reads implies `CLEARANCE_<SUFFIX>` was its old
18
+ // spelling, and a `CLEARANCE_*` name with no `CLEAROTRON_` partner is a setting that no longer exists at
19
19
  // all rather than one that moved. Both are worth saying and they are different sentences.
20
20
  //
21
21
  // ── THE ORACLE THIS DERIVATION IS CHECKED AGAINST ────────────────────────────────────────────────
@@ -110,7 +110,7 @@ function render(names) {
110
110
  // names-in-force.mjs — GENERATED by scripts/mint-names-in-force.mjs. Do not edit by hand.
111
111
  //
112
112
  // Every \`CLEAROTRON_*\` name this build reads. It exists so the retired-spelling check can name the
113
- // replacement for an old \`PRELIM_*\` line rather than only saying the old one is dead — see
113
+ // replacement for an old \`CLEARANCE_*\` line rather than only saying the old one is dead — see
114
114
  // shared/env-aliases.mjs. Re-mint with:
115
115
  //
116
116
  // node scripts/mint-names-in-force.mjs