clearotron 0.3.3 → 0.4.0-beta.1

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 (142) hide show
  1. package/.env.example +9 -0
  2. package/INSTALL.md +1 -14
  3. package/bin/brandowner.mjs +5 -5
  4. package/bin/connect.mjs +4 -4
  5. package/bin/onboard.mjs +5 -7
  6. package/bin/start.mjs +20 -5
  7. package/bin/update.mjs +6 -1
  8. package/build-info.json +2 -2
  9. package/docs/architecture/04-configuration-reference.md +2 -6
  10. package/driver/CHANGELOG.md +28 -0
  11. package/driver/ask-ledger.mjs +2 -2
  12. package/driver/band-shape.mjs +8 -8
  13. package/driver/blind-frame-model.mjs +1 -1
  14. package/driver/common-law-receipts.mjs +2 -2
  15. package/driver/commonlaw-carry.mjs +2 -2
  16. package/driver/company-bundle.mjs +11 -18
  17. package/driver/connotation-search.mjs +4 -4
  18. package/driver/contract-e3-backlog.mjs +3 -3
  19. package/driver/declination-call.mjs +1 -1
  20. package/driver/declination-tool.mjs +1 -1
  21. package/driver/dev-portal.mjs +1 -1
  22. package/driver/door-call-verdict.mjs +27 -0
  23. package/driver/driver.config.mjs +17 -11
  24. package/driver/e2e/README.md +1 -1
  25. package/driver/engine/mcp/clarivate-server.mjs +2 -1
  26. package/driver/engine/mcp/corsearch-server.mjs +1 -0
  27. package/driver/engine/mcp/euipo-server.mjs +1 -0
  28. package/driver/engine/mcp/free-tier-server.mjs +1 -1
  29. package/driver/engine/mcp/gather-config.mjs +1 -1
  30. package/driver/engine/mcp/perplexity-server.mjs +1 -1
  31. package/driver/engine/mcp/recording-server.mjs +1 -1
  32. package/driver/engine/mcp/signa-server.mjs +1 -0
  33. package/driver/engine/mcp/supplemental.mjs +1 -1
  34. package/driver/engine/mcp/uspto-local-server.mjs +1 -0
  35. package/driver/enqueue-schema.mjs +2 -2
  36. package/driver/feedback-issues.mjs +1 -1
  37. package/driver/feedback-store.mjs +1 -1
  38. package/driver/findings-model.mjs +3 -3
  39. package/driver/flag-snapshot.mjs +1 -1
  40. package/driver/floor-duty.mjs +2 -2
  41. package/driver/form-neighbourhood.mjs +47 -15
  42. package/driver/frame-diff-model.mjs +3 -3
  43. package/driver/gateway.mjs +5 -5
  44. package/driver/jx-lanes.mjs +1 -1
  45. package/driver/known-conflicts.mjs +18 -0
  46. package/driver/log.mjs +2 -2
  47. package/driver/package.json +1 -1
  48. package/driver/pipeline-knockout.mjs +129 -95
  49. package/driver/pipeline.mjs +69 -32
  50. package/driver/placement-carry.mjs +2 -2
  51. package/driver/placement-form.mjs +1 -1
  52. package/driver/portal-mcp-client.mjs +1 -1
  53. package/driver/portal-request-origin.mjs +79 -0
  54. package/driver/portal-service.mjs +45 -17
  55. package/driver/predelivery-lint.mjs +10 -10
  56. package/driver/profile-page.html +9 -13
  57. package/driver/profile-service.mjs +25 -13
  58. package/driver/profiles.mjs +17 -4
  59. package/driver/progress.mjs +1 -1
  60. package/driver/provider-usage.mjs +24 -1
  61. package/driver/publish/index.mjs +17 -5
  62. package/driver/publish/knockout.mjs +3 -2
  63. package/driver/publish/render-knockout.mjs +1 -1
  64. package/driver/publish/render.mjs +14 -3
  65. package/driver/publish/search-depth.mjs +4 -2
  66. package/driver/recall-reconciliation.mjs +1 -1
  67. package/driver/record-carry.mjs +6 -6
  68. package/driver/recording-agreement.mjs +2 -2
  69. package/driver/reference-score.mjs +27 -27
  70. package/driver/register-count.mjs +56 -1
  71. package/driver/register-digest-record.mjs +1 -1
  72. package/driver/register-plan.mjs +5 -5
  73. package/driver/register-records.mjs +10 -1
  74. package/driver/registry-fidelity.mjs +4 -4
  75. package/driver/repair-composers.mjs +6 -6
  76. package/driver/run-economics.mjs +8 -19
  77. package/driver/screen-gate.mjs +1 -1
  78. package/driver/skills/blind-frame/SKILL.md +2 -2
  79. package/driver/skills/clearance-common-law/SKILL.md +2 -2
  80. package/driver/skills/clearance-common-law/perplexity-prompts.md +4 -4
  81. package/driver/skills/clearance-register/digest.md +1 -1
  82. package/driver/skills/clearance-register/unit.md +1 -1
  83. package/driver/skills/clearance-search/report-prose.md +5 -5
  84. package/driver/skills/clearance-search/synthesis-rules.md +4 -4
  85. package/driver/skills/clearance-variants/SKILL.md +2 -2
  86. package/driver/skills/clearance-variants/transliteration-scripts.md +1 -1
  87. package/driver/skills/frame-diff/SKILL.md +2 -2
  88. package/driver/skills/knockout-assess/SKILL.md +9 -9
  89. package/driver/skills/matter-frame/watchlist-reference.md +1 -1
  90. package/driver/skills/narrative-refutation/SKILL.md +2 -2
  91. package/driver/skills/placement-inquiry/SKILL.md +1 -1
  92. package/driver/stage-context.mjs +4 -4
  93. package/driver/stages.mjs +23 -15
  94. package/driver/suite-census.json +138 -42
  95. package/driver/systemd/clearotron-client-mcp.service +24 -0
  96. package/driver/systemd/clearotron-mcp-face.service +24 -0
  97. package/driver/systemd/clearotron-portal.service +24 -0
  98. package/driver/systemd/clearotron-worker.service +24 -0
  99. package/driver/tokens.mjs +26 -17
  100. package/driver/turnaround-bands.mjs +1 -1
  101. package/driver/unit-inventory.mjs +3 -3
  102. package/driver/variant-manifest-model.mjs +1 -1
  103. package/driver/verify.mjs +2 -2
  104. package/driver/whatif-memo-run.mjs +1 -1
  105. package/mcp-server/CHANGELOG.md +8 -0
  106. package/mcp-server/lib/audit.mjs +9 -2
  107. package/mcp-server/lib/http-handler.mjs +7 -3
  108. package/mcp-server/mint-token.mjs +8 -6
  109. package/mcp-server/package.json +1 -1
  110. package/mcp-server/server.mjs +11 -1
  111. package/package.json +1 -1
  112. package/portal-ui/dist/assets/{index-GBbbyQxc.js → index-D_O_55vK.js} +59 -9
  113. package/portal-ui/dist/index.html +1 -1
  114. package/portal-ui/package.json +1 -1
  115. package/providers/_shared/README.md +1 -1
  116. package/providers/_shared/answer-memory.mjs +199 -0
  117. package/providers/_shared/ledger-path.mjs +1 -1
  118. package/providers/_shared/ledger.mjs +47 -5
  119. package/providers/_shared/script-form.mjs +24 -5
  120. package/providers/_shared/term-shape.mjs +5 -5
  121. package/providers/clarivate/src/capabilities.js +11 -0
  122. package/providers/clarivate/src/core.js +140 -13
  123. package/providers/jx-subclass/lookup.mjs +1 -1
  124. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  125. package/providers/oauth-mcp-bridge/package.json +1 -1
  126. package/providers/signa/src/capabilities.js +24 -0
  127. package/providers/signa/src/core.js +66 -0
  128. package/scripts/deprecate-below.mjs +114 -2
  129. package/scripts/freeze-example-run.mjs +1 -1
  130. package/scripts/live-surface-check.mjs +11 -2
  131. package/scripts/release-entry-catch-up.mjs +211 -0
  132. package/scripts/release-note-required.mjs +102 -6
  133. package/scripts/release-rehearsal-version.mjs +60 -0
  134. package/scripts/release-sbom.mjs +104 -0
  135. package/scripts/release-visible-check.mjs +7 -5
  136. package/scripts/score.mjs +3 -3
  137. package/shared/brand.mjs +1 -1
  138. package/shared/client-door.mjs +15 -8
  139. package/shared/driver-dir.mjs +20 -9
  140. package/shared/names-in-force.mjs +2 -0
  141. package/shared/scope.mjs +25 -10
  142. package/shared/store-in-repo.mjs +38 -17
package/.env.example CHANGED
@@ -265,6 +265,15 @@ CLARIVATE_API_KEY=changeme
265
265
  # Signa.
266
266
  SIGNA_API_KEY=changeme
267
267
  #
268
+ # The run memory: within one run, reuse an answer the register already gave instead of asking again.
269
+ # Signa's starts off: `watch` still sends every request and records whether a held answer would have
270
+ # matched, and `on` reuses held answers. Clarivate's starts on, and `off` asks every time. Read when a run
271
+ # starts, by providers/_shared/answer-memory.mjs.
272
+ # effect: tuning
273
+ CLEAROTRON_SIGNA_ANSWER_MEMORY=off
274
+ # effect: tuning
275
+ CLEAROTRON_CLARIVATE_ANSWER_MEMORY=on
276
+ #
268
277
  # USPTO, local — the free US source, and the only one here that is not a paid vendor. TWO
269
278
  # variables, and they are needed at different moments, which is why neither substitutes for the other.
270
279
  #
package/INSTALL.md CHANGED
@@ -647,24 +647,11 @@ matches, the neutral Generic default applies.
647
647
  config store and the engine loads *those* companies instead. **Same engine, different config path** —
648
648
  the code carries no company identities.
649
649
 
650
- Two things go with it, and both are refusals rather than preferences:
650
+ One thing goes with it, and it is a refusal rather than a preference:
651
651
 
652
652
  - **`PROFILE_REPO_ROOT` moves too.** The company directory has to sit inside the repository that
653
653
  variable names, because editing a profile is a commit. Point one somewhere new and leave the other
654
654
  behind and the portal and the profile service both refuse to start, naming both variables.
655
- - **That repository needs a `user.name` and a `user.email` of its own.** Saves are committed under
656
- the name of whoever asked for them, but git also records who *made* the commit, and it will not
657
- commit at all without one. A service account usually has no global git identity, so a store created
658
- by hand needs its own:
659
-
660
- ```bash
661
- git init -b main /srv/clearotron-store
662
- git -C /srv/clearotron-store config user.name "clearotron local install"
663
- git -C /srv/clearotron-store config user.email "clearotron@example.com"
664
- ```
665
-
666
- `clearotron start` does this for the store it creates. A store you make yourself does not get it,
667
- and the symptom is the first save failing at a commit rather than anything about profiles.
668
655
  - **Run data is external too.** Published reports, audits, and per-run state go to the archive pool at
669
656
  `CLEAROTRON_REPORTS_DIR`. Nothing company-specific is committed to the repository.
670
657
 
@@ -160,11 +160,10 @@ export async function add(argv, {
160
160
  contextPack = readFileSync(packFile, "utf8");
161
161
  }
162
162
 
163
- // THE ROSTER IS READ BEFORE THE CANDIDATE IS BUILT, because the Generic default lives in it. The
164
- // candidate is still judged as a member of the roster and not alone — see assertRosterAccepts.
163
+ // The candidate is judged as a member of the roster and not alone — see assertRosterAccepts.
165
164
  const { loadProfiles } = await import("../driver/profiles.mjs");
166
165
  const load = loadProfilesFn ?? loadProfiles;
167
- const platforms = resolvePlatforms(args.platforms, rosterAsItStands(store, load));
166
+ const platforms = resolvePlatforms(args.platforms);
168
167
 
169
168
  const profile = buildProfile({
170
169
  key: args.key, name: args.name.trim(), domains: args.domains,
@@ -180,10 +179,11 @@ export async function add(argv, {
180
179
  : `framework: ${framework.path} — THE GENERIC DEFAULT, applied because none was supplied. `
181
180
  + `Their matters will be rated under it until they give us theirs.`;
182
181
 
182
+ // NONE, when none was supplied (the owner's ruling of 2026-09-23): the brand owner starts with no
183
+ // marketplaces, and the line says what their searches still cover.
183
184
  const platformsLine = platforms.source === "supplied"
184
185
  ? `platforms: ${platforms.platforms.join(", ")} — as supplied`
185
- : `platforms: ${platforms.platforms.join(", ")} — THE GENERIC DEFAULT, applied because none was supplied. `
186
- + `Their searches cover these marketplaces until someone changes them in the portal.`;
186
+ : `platforms: none — searches use the general web, plus any stores chosen for each matter.`;
187
187
 
188
188
  if (args.dryRun) {
189
189
  out(`would create ${join(store, `${args.key}.json`)}`);
package/bin/connect.mjs CHANGED
@@ -135,10 +135,10 @@ export function systemdFailure(e, { step, unit = null } = {}) {
135
135
  const UNIT_DIR = join(homedir(), ".config", "systemd", "user");
136
136
  const ENV_PATH = join(homedir(), ".env");
137
137
  // Where the revocation list lives when this install has never named one — created by connect so the
138
- // door is BORN consulting it (; measured: no denylist is configured on production,
139
- // and `isRevoked()` returns false on an UNSET path — still true, and deliberately so: a deployment that
140
- // never asked for a denylist is not taken down by one. It is an unreadable list that now refuses
141
- //. Assuming a path nobody set still makes every issued key unrevokable).
138
+ // door is BORN consulting it (; measured: no denylist is configured on production).
139
+ // `isRevoked()` now reads this same default when the setting is unset, and an absent default reads as
140
+ // "nothing revoked"; a list that is there and unreadable refuses. Naming the path is still what makes
141
+ // every door, whatever home it runs under, read the file a revocation is written to.
142
142
  // — one owner for this path. It was written out here, in disconnect and twice
143
143
  // in start; `start` named it and created nothing, which is how a revoked key kept answering 200.
144
144
  const DENYLIST_PATH = defaultDenylistPath(homedir());
package/bin/onboard.mjs CHANGED
@@ -3453,10 +3453,8 @@ export async function runCheck() {
3453
3453
  if (r.state === "valid") ok(line);
3454
3454
  else info(line);
3455
3455
  }
3456
- if (report.valid && !String(process.env.TRADEMARK_MCP_TOKEN_DENYLIST ?? "").trim()) {
3457
- problem("a valid key is on record but NO revocation list is configured (TRADEMARK_MCP_TOKEN_DENYLIST unset) "
3458
- + `— \`${invoke("disconnect")}\` could not actually revoke it. \`${invoke("connect")}\` arms one; set the variable or reconnect.`);
3459
- }
3456
+ // No "revocation list not configured" problem any more: with TRADEMARK_MCP_TOKEN_DENYLIST unset, every
3457
+ // door reads the install's default list (isRevoked, shared/scope.mjs), which is where disconnect writes.
3460
3458
  // THE PUBLISHED ADDRESS, AND WHETHER IT ANSWERS (acceptance 2). Reported here
3461
3459
  // rather than beside the unit, because the unit running and the address being reachable are
3462
3460
  // different facts and the second is the one a client depends on.
@@ -4496,9 +4494,9 @@ try {
4496
4494
  candidate.PROFILE_REPO_ROOT = cfg; // no alias row — this name is current
4497
4495
  for (const k of ["CLEAROTRON_CUSTOMERS_DIR", "PROFILE_REPO_ROOT"]) ok(`${k}=${candidate[k]}`);
4498
4496
  // A REPOSITORY ALREADY THERE IS ASKED NOW, while the operator is still here, whether it can record a
4499
- // save. `clearotron start` gives a store it creates an identity of its own; one made by hand has none
4500
- // unless somebody set it, and on a machine with no global identity the first company created in the
4501
- // portal is refused. Said here, with the command, rather than discovered on that first company.
4497
+ // save: one this account cannot use refuses the first company created in the portal. Said here, with
4498
+ // the command, rather than discovered on that first company. A missing git identity is not asked
4499
+ // about, because every save supplies the product's own committer.
4502
4500
  if (existsSync(join(cfgAbs, ".git"))) {
4503
4501
  const cannot = storeCommitRefusal(cfgAbs);
4504
4502
  if (cannot) warn(`${cannot.message}. Until then, creating a company in the portal is refused.`);
package/bin/start.mjs CHANGED
@@ -111,7 +111,7 @@ export async function runTables() {
111
111
  return { registers: PROVIDERS, engines: ENGINE_BINARIES, defaultEngine: RUN_DEFAULT_ENGINE, resolveEngine: resolveEngineProgram };
112
112
  }
113
113
  import { spawn, spawnSync, execFileSync } from "node:child_process";
114
- import { storeInRepo, storeOutsideRepoMessage, storeCommitRefusal } from "../shared/store-in-repo.mjs"; //
114
+ import { storeInRepo, storeOutsideRepoMessage, storeCommitRefusal, storeCommitEnv } from "../shared/store-in-repo.mjs"; //
115
115
  import { stdioConnectOffer } from "../shared/stdio-connect.mjs";
116
116
  import { wslTarget } from "../shared/wsl.mjs"; // — and which distribution a row should start the server in
117
117
  import { ensureDemoProgram } from "../shared/permanent-install.mjs"; // — a demo from npx keeps its own copy
@@ -1531,9 +1531,9 @@ if (isMain) {
1531
1531
  err(` WARNING: could not initialise the saved-search store at ${paths.configStore} (${String(e?.message ?? e)}). Searches will list and run; SAVING one will fail until this is a git repository.`);
1532
1532
  }
1533
1533
  } else {
1534
- // AN ADOPTED STORE IS ASKED WHETHER IT CAN RECORD A SAVE, HERE, not at the first save. A repository
1535
- // made by hand has no identity unless somebody gave it one, and on a machine with no global identity
1536
- // the first company created in the portal is then refused. The one created above sets its own.
1534
+ // AN ADOPTED STORE IS ASKED WHETHER IT CAN RECORD A SAVE, HERE, not at the first save: a directory
1535
+ // that is not a repository this account can use refuses every company created in the portal. A store
1536
+ // with no git identity is not refused; every save supplies the product's own committer.
1537
1537
  const cannot = storeCommitRefusal(paths.configStore);
1538
1538
  if (cannot) err(` WARNING: ${cannot.message}. Until then, creating a company or saving a search is refused.`);
1539
1539
  }
@@ -1556,7 +1556,7 @@ if (isMain) {
1556
1556
  // and the first save commits them with its own change.
1557
1557
  try {
1558
1558
  execFileSync("git", ["-C", paths.configStore, "add", "-A", "--", "profiles"], { stdio: "ignore" });
1559
- execFileSync("git", ["-C", paths.configStore, "commit", "-q", "-m", "the demo's company"], { stdio: "ignore" });
1559
+ execFileSync("git", ["-C", paths.configStore, "commit", "-q", "-m", "the demo's company"], { stdio: "ignore", env: storeCommitEnv(paths.configStore) });
1560
1560
  } catch { /* see above */ }
1561
1561
  say(` demo store ${paths.profiles} — ${copied.join(", ")}`);
1562
1562
  }
@@ -2298,6 +2298,7 @@ if (isMain) {
2298
2298
  // the `stopping` flag is what stops an orderly stop being reported as a crash.
2299
2299
  process.on("SIGINT", () => { void shutdown(0); });
2300
2300
  process.on("SIGTERM", () => { void shutdown(0); });
2301
+ for (const sig of windowCloseSignals()) process.on(sig, () => { void shutdown(0); });
2301
2302
 
2302
2303
  const healthy = async (url, rec) => {
2303
2304
  const deadline = Date.now() + 30_000;
@@ -2678,6 +2679,20 @@ export function backgroundOfferLines({ demo = false, keep = false, manager = nul
2678
2679
  ];
2679
2680
  }
2680
2681
 
2682
+ /**
2683
+ * The signal that means the window was closed, which a foreground start treats as a stop.
2684
+ *
2685
+ * SIGHUP, on every platform. Closing a terminal sends it, and with no handler Node's default ended this
2686
+ * process on the spot: the teardown never ran, and the children, which lead sessions of their own, never
2687
+ * received the hangup. The portal, the door and the worker went on running with nothing supervising them
2688
+ * and a search in flight carried on (measured on Linux, 2026-09-23), while the banner above says closing
2689
+ * the window stops everything this command started. Windows reports closing the console window as SIGHUP
2690
+ * too.
2691
+ */
2692
+ export function windowCloseSignals() {
2693
+ return ["SIGHUP"];
2694
+ }
2695
+
2681
2696
  /**
2682
2697
  * What a reader is told when a child this install cannot run without exits.
2683
2698
  *
package/bin/update.mjs CHANGED
@@ -383,7 +383,12 @@ export async function update(argv = process.argv.slice(2)) {
383
383
  return pulled;
384
384
  }
385
385
 
386
- const installed = runInCheckout("npm", ["ci"]);
386
+ // NO INSTALL SCRIPTS. A dependency's install script runs with everything this account can reach, and an
387
+ // update fetches whatever the registry serves that day. None is needed here: the only dependency that
388
+ // carries one is optional and macOS-only, and this package's own two are the Node floor, which the CLI
389
+ // checks at every start, and a contributor's git hooks. Dev dependencies stay, because the portal
390
+ // bundle below is rebuilt from them.
391
+ const installed = runInCheckout("npm", ["ci", "--ignore-scripts"]);
387
392
  if (installed !== 0) {
388
393
  console.error("\n npm ci failed. The code is updated; its dependencies are not, so this install is");
389
394
  console.error(" in a half-updated state and should not be run until that command succeeds.\n");
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "80f14b0925b8052cb70d5173a6f664bd2a2510a0",
3
- "version": "0.3.3"
2
+ "commit": "e60329390d653439053b900cf97b65e082e06622",
3
+ "version": "0.4.0-beta.1"
4
4
  }
@@ -138,12 +138,6 @@ id is what a dispatch row and a token-rollup row carry as the model *asked for*;
138
138
  the turn is recorded beside it, and the report names that. A version here would be a claim about a
139
139
  request nobody made, and wrong the day a newer model of the tier shipped.
140
140
 
141
- One consequence, accepted when this was decided: per-model totals are keyed on what was asked for,
142
- and the native-language lanes call the API directly, where a model id is required and a tier word is
143
- not accepted. So one model reached by a stage and by those lanes appears under two keys —
144
- `anthropic/claude-haiku` and `anthropic/claude-haiku-4-5`. They are different requests, and the split
145
- says so.
146
-
147
141
  The bottom four are **legacy names that no stage declares and no engine can run** — they resolve at
148
142
  level 1 and then throw at level 2 (below). They are catalogue entries, not available tiers.
149
143
 
@@ -248,6 +242,8 @@ deployment may override (verify live values per deployment).
248
242
  | `CLEAROTRON_MAX_CLAIM_AGE_MS` | 172800000 (48 h; 0 disables) | Hard ceiling on a claim's age (from the `.pid` sidecar mtime) — beyond it, re-claim regardless of liveness. |
249
243
  | `CLEAROTRON_KNOCKOUT_VARIANT_CAP` | unset (⇒ the lane's own cap) | Ceiling on variants a knockout screens per name. Set only to bound an unusually wide batch; absent means the lane decides. |
250
244
  | `CLEAROTRON_KNOCKOUT_RECORD_CAP` | unset (⇒ the lane's own cap) | Ceiling on records a knockout fetches per hit. Same shape as the variant cap: absent is the normal state. |
245
+ | `CLEAROTRON_SIGNA_ANSWER_MEMORY` | `off` | On the Signa register, whether a run reuses an answer it already holds instead of asking again. `off` asks every time. `watch` also asks every time, and records whether a held answer would have matched. `on` reuses held answers. |
246
+ | `CLEAROTRON_CLARIVATE_ANSWER_MEMORY` | `on` | On the Clarivate register, whether a run reuses a count, a search, an owner lookup or a record it already holds instead of asking again. `off` asks every time. |
251
247
 
252
248
  ### Retries, timeouts, watchdogs
253
249
 
@@ -1,5 +1,33 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.4.0-beta.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Fixed: Clarivate clearances no longer count look-alike spellings that mix Latin with Greek or Cyrillic letters as searches that could not be completed.
8
+ - For operators: Clarivate clearances no longer fetch a record, or repeat a count, search or owner lookup, that the same run already made.
9
+ - For operators: Knockouts on Signa and Clarivate take their name and close-variation counts from the listing, so each is asked of the register once.
10
+ - Fixed: Knockouts on the Signa register now say how many register hits the listed filings were drawn from.
11
+ - Fixed: Clearances on the Signa register no longer search look-alike spellings that mix Latin with Greek or Cyrillic letters; those searches found only unrelated marks.
12
+
13
+ ## 0.4.0-beta.0
14
+
15
+ ### Minor Changes
16
+
17
+ - Before you upgrade: a new operator key must now name the tools it may use, for example `--verbs start_run,stop_run`. Keys already issued keep working.
18
+
19
+ For operators: a revoked key stops working even where no revocation list was set up, because every connector now reads the install's own list.
20
+
21
+ Fixed: over the network, an operator key can no longer start a what-if that its assistant is not shown.
22
+
23
+ Fixed: the portal refuses a change sent from another website, or from another app on the same computer, including a sign-in.
24
+
25
+ ### Patch Changes
26
+
27
+ - Fixed: closing the window that runs `clearotron start` now stops Clearotron. Before, it kept running in the background.
28
+ - Fixed: Creating a company in the portal no longer stops with a request to run a git command.
29
+ - Fixed: A new company starts with no marketplaces. The company page offers the usual ones to add, and a company with none is still searched on the general web.
30
+
3
31
  ## 0.3.3
4
32
 
5
33
  ### Patch Changes
@@ -179,7 +179,7 @@ function recallEnding(name, qid, join, ts) {
179
179
  if (discharges) return { ending, handoff: null };
180
180
  return {
181
181
  ending: null,
182
- handoff: `the probe ran and ${why} — a recall probe discharges on what came back, not on having run (#1349). `
182
+ handoff: `the probe ran and ${why} — a recall probe discharges on what came back, not on having run. `
183
183
  + `Ships OPEN in the audit's ask ledger for the reviewing lawyer.`,
184
184
  };
185
185
  }
@@ -601,7 +601,7 @@ const normMark = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
601
601
  * as immaterial only when the mark it went looking for is in the client's findings. Otherwise the mark
602
602
  * is owed a finding and the ask stays open.
603
603
  *
604
- * The defect this answers: a recall probe found OSLER DELPHI, the closure stage pointed at
604
+ * The defect this answers: a recall probe found HALVER KORPHI, the closure stage pointed at
605
605
  * `register-findings.md` — the run's own working sheet, where the mark genuinely IS reasoned — and the
606
606
  * ask closed. The sheet is a citable file and the citation was true. It is simply not the document the
607
607
  * client reads, and nothing checked that.
@@ -65,7 +65,7 @@ const tokensOf = (s) => String(s ?? "").split(/[^\p{L}\p{N}]+/u).map((t) => norm
65
65
 
66
66
  /**
67
67
  * The ALTERNATIVE NAMES a label carries — `VENZAL / VENZALMONO / VENZALKOMB` is one relabelled entry,
68
- * `CHROMA & Device` is one mark plus a device note.
68
+ * `LUMIVANE & Device` is one mark plus a device note.
69
69
  *
70
70
  * The separator class is reference-score's `labelAliases`, character for character, and the two are
71
71
  * meant to stay that way: the scorer and the engine must agree about what a relabelling is, or a record
@@ -175,7 +175,7 @@ export function classifyRecord(markText, targets) {
175
175
 
176
176
  // ── member 2 — A RELABELLED RECORD IS THE SAME MARK, AND IT WAS TIERING BELOW THE FLOOR ────────
177
177
  //
178
- // Measured against the scorer on `origin/main`: `VENZAL / VENZALMONO / VENZALKOMB` and `CHROMA &
178
+ // Measured against the scorer on `origin/main`: `VENZAL / VENZALMONO / VENZALKOMB` and `LUMIVANE &
179
179
  // Device` are `alias` to reference-score and `same-family / token-identical` here. Not dropped —
180
180
  // tiered BELOW the line. The floors take `identical` and `near-identical` only, so a register record
181
181
  // that IS the mark under a relabelling never became a row a lawyer had to answer. Same consequence as
@@ -184,7 +184,7 @@ export function classifyRecord(markText, targets) {
184
184
  // ALIAS SEPARATORS ONLY, and the distinction is reference-score's, kept deliberately identical
185
185
  // because the two must agree about what a relabelling IS: `/ , · & |` separate ALTERNATIVE NAMES for
186
186
  // one record; whitespace and hyphens separate WORDS WITHIN one name. Conflating them makes every
187
- // multi-word mark match its own first word — `TIKI TWIST` would become `TIKI`, which is a different
187
+ // multi-word mark match its own first word — `WAVO TWIST` would become `WAVO`, which is a different
188
188
  // proprietor's different mark and the collision the scorer's own doc block warns about. Whitespace is
189
189
  // NOT in the class here for exactly that reason.
190
190
  //
@@ -213,7 +213,7 @@ export function classifyRecord(markText, targets) {
213
213
  // The candidate set of the retrieved→judgment reconciliation: every LIVE, in-scope, SCREEN-SURFACED
214
214
  // record whose mark carries the manifest's dominant element — as a standalone token
215
215
  // (`token-identical`), a token one edit away (`token-edit-1`), or CONCATENATED inside a longer
216
- // squashed word (`concatenation` — the TIKITONK class: same-family in substance, "other" to the
216
+ // squashed word (`concatenation` — the WAVOTONK class: same-family in substance, "other" to the
217
217
  // token classifier, and the proven silent-death seam). Screen-surfaced only (screen_verdict
218
218
  // surface:*): the obligation this feeds is "everything the screen surfaced ends somewhere a reader
219
219
  // can see"; drop:* records were already ended by the screen with a policed drop row.
@@ -227,7 +227,7 @@ export function classifyRecord(markText, targets) {
227
227
  // itself never spells. They rank last so they never reached the top slice, but they inflate the
228
228
  // residual a declared crowd count must cover — a count written one short blocks the run (the
229
229
  // reviewer reproduced 378 declared vs 379 residual → blocked). A concatenation is a SQUASHED WORD
230
- // (TIKITONK), which is a property of one token.
230
+ // (WAVOTONK), which is a property of one token.
231
231
  export const MIN_CONCAT_LEN = 4;
232
232
 
233
233
  /**
@@ -323,8 +323,8 @@ export function dominantElementComposites(band, { dominantElement, inScopeClasse
323
323
  // identity, and the arithmetic becomes a cross-reference instead.
324
324
  // (2) owner identity is compared on a CORE + LEGAL-FORM pair, not on the suffix-stripped core
325
325
  // alone. Stripping entity words is what makes "Fairmile Snack Foods Corp." = "Fairmile Snack Foods
326
- // Corporation" work, but on the same band it also collapsed "Tiki Group Limited", "Tiki AG"
327
- // and "Tiki Corporation" to the bare core "tiki" and merged three distinct registrants on the
326
+ // Corporation" work, but on the same band it also collapsed "Wavo Group Limited", "Wavo AG"
327
+ // and "Wavo Corporation" to the bare core "wavo" and merged three distinct registrants on the
328
328
  // mark-owner edge alone. So: same core AND compatible legal forms (one side's form set a
329
329
  // SUBSET of the other's — a register that simply omits the form still matches, two different
330
330
  // stated forms never do).
@@ -359,7 +359,7 @@ export function ownerIdentical(a, b) {
359
359
  }
360
360
 
361
361
  /** The union-find bucket key for the mark+owner edge — core + the SORTED form set, so
362
- * "Tiki AG" and "Tiki Corporation" land in different buckets while "X Corp."/"X Corporation" share
362
+ * "Wavo AG" and "Wavo Corporation" land in different buckets while "X Corp."/"X Corporation" share
363
363
  * one. The subset relaxation (a register omitting the form) is applied by the second pass below. */
364
364
  const ownerKey = (s) => { const { core, forms } = ownerIdentity(s); return core ? `${core}|${[...forms].sort().join("+")}` : ""; };
365
365
  const normOwner = (s) => ownerIdentity(s).core;
@@ -13,7 +13,7 @@
13
13
  // PURE (no node imports) → tests offline. Mirrors coverage-ledger.mjs / findings-model.mjs.
14
14
 
15
15
  // add/drop = the both-directions element neighbours (VELTRIN→VELTRI is "drop"); phonetic/homophone =
16
- // sound-alikes; neighbour = a one-keystroke real-word / famous-mark neighbour (CHROME on NOVAPULSE);
16
+ // sound-alikes; neighbour = a one-keystroke real-word / famous-mark neighbour (NOVAPULSO on NOVAPULSE);
17
17
  // composite = the element inside a larger mark (VELTRI-composite cluster).
18
18
  export const VARIANT_DIRECTIONS = ["add", "drop", "phonetic", "homophone", "neighbour", "composite"];
19
19
  // the spine is ranked by goods-overlap with the actual product, not by class number + registration status.
@@ -861,9 +861,9 @@ export function mergeCommonLawFindings(parts) {
861
861
  // a present row chose.
862
862
  //
863
863
  // The join bug is fixed independently, because a census nobody can trust is worse than none:
864
- // • TERM match is EXACT after stripping a trailing annotation ("提基冰沙 (ZH: …)"). It used to be
864
+ // • TERM match is EXACT after stripping a trailing annotation ("瓦波冰沙 (ZH: …)"). It used to be
865
865
  // `rowTerm.includes(cellTerm)`, so the row for "CORAL FREEZE" was credited to the cells for "SLUSH"
866
- // and even "TIK", and the row for "提基冰沙" to the cell for "冰沙" — 71 of the 221.
866
+ // and even "WAV", and the row for "瓦波冰沙" to the cell for "冰沙" — 71 of the 221.
867
867
  // • a platform ROLL-UP row ("Multiple platforms") counts only when the term has no platform-specific
868
868
  // row of its own; combined with the substring bug it used to match every platform at once.
869
869
  // `undisposed` (title containment over raw web-search titles) is GONE: it returned 1,986 rows on the
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // ── the loss this exists to catch ────────────────────────────────────────────────────────────────────
11
11
  //
12
- // TIKI TWIST and TIKI TROPICS. Both sat in R3's own common-law records and reached no findings list, and
12
+ // WAVO TWIST and WAVO TROPICS. Both sat in R3's own common-law records and reached no findings list, and
13
13
  // nothing anywhere said why. `common-law-receipts.mjs`'s `findGridCandidateOmissions` already reports one
14
14
  // class of this — a hit cell denied by the Negative-results matrix — but it is CELL-granular, and a cell
15
15
  // carries up to eight candidates. This is the per-candidate half.
@@ -39,7 +39,7 @@
39
39
  // ── THE NON-LATIN TRAP IS ANSWERED, NOT AVOIDED ──────────────────────────────────────────────────────
40
40
  //
41
41
  // `normalizeJoinText` folds every non-[A-Za-z0-9] run to a space, so it returns the EMPTY STRING for
42
- // 色度, 冰沙, 提基冰沙, ティキスラッシュ and 티키 슬러시. A mark-keyed trace is structurally blind to
42
+ // 色度, 冰沙, 瓦波冰沙, ワボスラッシュ and 와보 슬러시. A mark-keyed trace is structurally blind to
43
43
  // every one of them.
44
44
  //
45
45
  // NOTHING HERE TOUCHES IT. A Han-script candidate joins on its ASCII/percent-encoded URL. A Han-script
@@ -99,28 +99,21 @@ export function resolveFramework(requested, {
99
99
 
100
100
  // ── the profile this writes ────────────────────────────────────────────────────────────────────────
101
101
  /**
102
- * Which marketplaces this brand owner's searches cover — supplied, or the Generic default, SAID OUT LOUD.
102
+ * Which marketplaces this brand owner's searches cover: the ones supplied, or NONE.
103
103
  *
104
- * A customer bundle is a COMPLETE document in this design, not an overlay on generic: every shipped
105
- * profile carries its own `platforms`, and the loader requires a non-empty array on every file. The
106
- * command had no way to supply one and set none, so every bundle it wrote failed to load on this field
107
- * as well as on the `key` field above — two independent invalidities, and onboarding could not produce
108
- * a loadable brand owner at all.
104
+ * A customer bundle is a COMPLETE document in this design, not an overlay on generic: every profile
105
+ * carries its own `platforms` array, and the loader requires one on every file.
109
106
  *
110
- * Defaulting rather than refusing, and naming it, is this command's own established idiom: the same
111
- * ruling governs the framework one function down. Which marketplaces a client's clearance searches is
112
- * not a detail to decide silently, so an operator who supplies nothing is TOLD what they got and can
113
- * refine it in the portal.
107
+ * NONE, NOT THE GENERIC DEFAULT, by the owner's ruling of 2026-09-23. A company created with only a name
108
+ * used to be given the Generic default's marketplaces, which no screen listed and which the company page
109
+ * then refused to let anybody remove. A new company now starts with an empty list and somebody picks
110
+ * from the Generic default's marketplaces, which the portal offers as suggestions. With an empty list the
111
+ * general web search and the meaning checks still run, and so do any stores the engine chooses for a matter
112
+ * (pipeline.mjs deriveGridSpec).
114
113
  */
115
- export function resolvePlatforms(supplied, roster) {
114
+ export function resolvePlatforms(supplied) {
116
115
  if (supplied?.length) return { platforms: supplied, source: "supplied" };
117
- const house = roster?.get?.("generic")?.platforms ?? [];
118
- if (!house.length)
119
- throw new Refusal(
120
- "no --platforms was given and the Generic default carries none, so there is nothing to onboard this "
121
- + "brand owner with. Pass --platforms, or repair the generic profile in the store.",
122
- { code: "no_marketplaces" });
123
- return { platforms: [...house], source: "house default" };
116
+ return { platforms: [], source: "none" };
124
117
  }
125
118
 
126
119
  export function buildProfile({ key, name, domains, platforms, framework, industry,
@@ -4,7 +4,7 @@
4
4
  // searched-not-asserted enforcement (validator side). Sister of use-check.mjs (spec 11): a "clean" claim
5
5
  // is sayable ONLY when a search produced it.
6
6
  //
7
- // The bug this closes (live incident: a mark reading as a benign given name one letter off the Sureño
7
+ // The bug this closes (live incident: a mark reading as a benign given name one letter off the Mara
8
8
  // street-gang label): the deterministic grid runs term×platform marketplace cells only — it asks
9
9
  // "who SELLS this name?", never "what does this name MEAN?". A marketplace grid that finds no listing for
10
10
  // the mark is SILENT on the street-gang association; only a MEANING search surfaces it. With no machine slot
@@ -30,7 +30,7 @@
30
30
 
31
31
  // The meaning query shapes. NOT a banned-word list — these are SEARCH directions ("what does this name mean,
32
32
  // and to whom?"). Perplexity surfaces foreign-language / gang / slang meanings from English query shapes
33
- // (a benign given name one letter off "Sureño" → the gang label surfaces from "<mark> gang" / "<mark> urban dictionary").
33
+ // (a benign given name one letter off "Mara" → the gang label surfaces from "<mark> gang" / "<mark> urban dictionary").
34
34
 
35
35
  export const CONNOTATION_SHAPES = ["meaning slang", "gang", "offensive", "urban dictionary", "wikipedia"];
36
36
 
@@ -1125,7 +1125,7 @@ const RECURRENT_MIN = 4;
1125
1125
  // ── — THE REASON SCALE IS MEASURED HERE AND DELIBERATELY DOES NOT DECIDE ────────────────────────
1126
1126
  // was ruled "recurrence counts independent reasons, not query strings": a dictated query is
1127
1127
  // "<term> <shape>", the sweep asks every shape of every mark variant, so the seven queries reading
1128
- // BIODELFIN/BIODELFINO/BIODOLPHIN/ΒΙΟΔΕΛΦΙΣ/ΔΕΛΦΙΣ/ΜΠΙΟΔΕΛΦΙΣ/Дельфис + "offensive meaning" that all
1128
+ // BIOVELTRYN/BIOVELTRINO/BIOVELTRINE/ΒΙΟΒΕΛΤΡΙΝ/ΒΕΛΤΡΙΝ/ΜΠΙΟΒΕΛΤΡΙΝ/Вельтрин + "offensive meaning" that all
1129
1129
  // return the dictionary definition of the word "offensive" are one reason asked seven ways. That
1130
1130
  // reasoning is sound and the shape is the right way to express it. It is implemented below as
1131
1131
  // connotationReasonKey, and it is RECORDED on every run — but it is NOT what clears the floor.
@@ -1133,7 +1133,7 @@ const RECURRENT_MIN = 4;
1133
1133
  // IT WAS BUILT, MEASURED AGAINST THE THREE PRESERVED RUNS, AND REFUTED BY THEM. Making the floor read
1134
1134
  // the reason scale inverts the signal it exists to read:
1135
1135
  // - The 2026-07 evidence run (test/fixtures/coral-freeze-pr-risk.json, 54 recorded queries): the floor's
1136
- // obligation set goes 7 → 0. `punchdrink.com/articles/problem-with-tiki-bars-cultural-appropriation`
1136
+ // obligation set goes 7 → 0. `punchdrink.com/articles/problem-with-wavo-bars-cultural-appropriation`
1137
1137
  // — THE receipt this floor was invented to force onto the page — is surfaced by four queries that are
1138
1138
  // all shape "offensive", so it scores ONE reason and drops out. The gate stops firing on the run it
1139
1139
  // was designed for.
@@ -151,7 +151,7 @@ export const E3_BACKLOG = [
151
151
  kind: "literal-json-skeleton",
152
152
  where: "driver/skills/blind-frame/SKILL.md:62-83",
153
153
  surface: "skill-file",
154
- evidence: "A JSON OBJECT with EXACTLY these keys:\\n```json\\n{\\n \"schema_version\": 1,\\n \"dominant_element\": \"the spine, verbatim\",\\n \"variants\": [{\"value\": \"DELPHI\", \"direction\": \"drop\", \"rationale\": \"…\"}],\\n \"fields\": […],\\n \"sources\": […],\\n \"ranking_basis\": \"goods-overlap\"\\n}\\n``` … `direction` is EXACTLY on",
154
+ evidence: "A JSON OBJECT with EXACTLY these keys:\\n```json\\n{\\n \"schema_version\": 1,\\n \"dominant_element\": \"the spine, verbatim\",\\n \"variants\": [{\"value\": \"KORPHI\", \"direction\": \"drop\", \"rationale\": \"…\"}],\\n \"fields\": […],\\n \"sources\": […],\\n \"ranking_basis\": \"goods-overlap\"\\n}\\n``` … `direction` is EXACTLY on",
155
155
  reparsedBy: "driver/verify.mjs validators.blindFrame — emits invalid_file:…:blindframe_* on an off-enum or missing key (stages.mjs:952-957 doc block)",
156
156
  removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
157
157
  },
@@ -283,7 +283,7 @@ export const E3_BACKLOG = [
283
283
  {
284
284
  stage: "register-unit",
285
285
  kind: "exactly-these-keys",
286
- where: "driver/stages.mjs:4202 (the frame-reopen / scoped-retry message builder). A second number stood here and had been stale for some time: it pointed at a contract-element description rather than a builder, at its old line and at every mechanical shift of it. Two candidate builders sit beside 4157 and picking one would be a guess, so the wrong pointer is removed rather than moved a third time — one accurate citation beats one accurate and one invented.",
286
+ where: "driver/stages.mjs:4210 (the frame-reopen / scoped-retry message builder). A second number stood here and had been stale for some time: it pointed at a contract-element description rather than a builder, at its old line and at every mechanical shift of it. Two candidate builders sit beside 4157 and picking one would be a guess, so the wrong pointer is removed rather than moved a third time — one accurate citation beats one accurate and one invented.",
287
287
  surface: "stage-message",
288
288
  evidence: "Every block you append MUST carry \"state\":\"enumerated\" (ONLY if paged to has_more:false) or \"state\":\"incomplete\" — EXACTLY those two strings … (re-dispatch builders, which REPLACE def.message)",
289
289
  reparsedBy: "driver/named-band.mjs parseNamedBand. Scope warning: these builders replace def.message on every escalation / envelope-close / frame-reopen dispatch, so an E3 lint that walks STAGES[*].message only never sees them",
@@ -446,7 +446,7 @@ export const E3_BACKLOG = [
446
446
  kind: "literal-json-skeleton",
447
447
  where: "driver/skills/frame-diff/SKILL.md:44-48",
448
448
  surface: "skill-file",
449
- evidence: "A directive may carry a structured `remedy`:\\n```json\\n\"remedy\": { \"terms\": [\"TROPICAL TIKI\", \"ISLAND TIKI\"], \"nice_classes\": [\"5\", \"32\"], \"regions\": [] }\\n```",
449
+ evidence: "A directive may carry a structured `remedy`:\\n```json\\n\"remedy\": { \"terms\": [\"TROPICAL WAVO\", \"ISLAND WAVO\"], \"nice_classes\": [\"5\", \"32\"], \"regions\": [] }\\n```",
450
450
  reparsedBy: "driver/pipeline.mjs runSupplementalSweeps — the remedy lint refuses a label-shaped term; stages.mjs:2809 restates the same shape in the message (\"THE ASK CONTRACT, stated at BOTH levels\")",
451
451
  removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
452
452
  },
@@ -6,7 +6,7 @@
6
6
  //
7
7
  // `record-carry.json` on a full clearance test run read `unreasoned: 0` and was telling the truth in the weak
8
8
  // sense only: every drop had a LABEL, and on 102 of them the label was inferred from where the record
9
- // stopped rather than stated by the step that stopped it. One of those 102 was DELPHIC — a lawyer-named
9
+ // stopped rather than stated by the step that stopped it. One of those 102 was KORPHIC — a lawyer-named
10
10
  // gold mark, REGISTERED, placed on sheet-2, stamped `carry: "carried"`, carried onto the findings
11
11
  // surface, and then absent from the delivered report with nothing anywhere saying why.
12
12
  //
@@ -116,7 +116,7 @@ export function appendDeclinations(runDir, accepted, { now = () => new Date().to
116
116
  }
117
117
  ensureDriverDir(runDir);
118
118
  writeFileSync(ledger, JSON.stringify({
119
- _provenance: "synthesis's own stated decision not to deliver a record that reached the findings surface (#1117). "
119
+ _provenance: "synthesis's own stated decision not to deliver a record that reached the findings surface. "
120
120
  + "Written by the driver from typed calls — never hand-authored, never parsed out of prose.",
121
121
  ts: now(), count: byUri.size, declinations: [...byUri.values()],
122
122
  }, null, 2) + "\n");
@@ -78,7 +78,7 @@ export const DEV_COCKPIT_JOB_FIELDS = Object.freeze({
78
78
  + "is a form for composing REAL dev runs; a fixture run is declared by the job file that wants one, so "
79
79
  + "the fact travels with the run rather than with whoever filled in a form.",
80
80
  promptParts: "the requester's declaration that the prose rides as SIDECAR files. The cockpit composes a job "
81
- + "from its own form and writes no sidecars, so it may not claim that shape (#1085).",
81
+ + "from its own form and writes no sidecars, so it may not claim that shape.",
82
82
  forwarderDomain: "the cockpit's assembler defaults it; a dev form has no forwarding domain to state.",
83
83
  provider: "which register vendor answers is the dev instance's own configuration, not a form field.",
84
84
  name: "the pre-markName spelling of the search subject. The form posts `mark`.",
@@ -0,0 +1,27 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ //
4
+ // A CALL TO THE OPS DOOR THAT THE DEPLOYMENT CHECK COULD NOT MAKE — "roster resolves" (list_profiles) and
5
+ // "ops-MCP reachable" (describe_options). Extracted so it can be driven without a door to call, as
6
+ // plan-run-agreement-verdict.mjs was for the third call.
7
+ //
8
+ // Both rows used to FAIL on every error but an unset door, so the check exited 1, "drifted", when nothing
9
+ // had been compared. Measured on pre-prod 0.3.3-beta.0: both rows FAILed on a 401, the door refusing the
10
+ // check's own key. On the test box the same door's rate limit (429) refused the third call of a burst of
11
+ // checks; that was the plan_run call, and these two go to the same door.
12
+ //
13
+ // THREE ANSWERS MEAN THE CHECK DID NOT LOOK: a rate limit (429), a refusal of this check's own key (401,
14
+ // 403), and a request that got no answer in time. Each is a marked skip, which exits 3. Everything else
15
+ // stays a FAIL: a 5xx or a malformed answer is the door answering badly. A door with nothing listening
16
+ // at all stays a FAIL too, because whether that is a finding is not settled, and this does not settle it.
17
+
18
+ /**
19
+ * @param {Error & {status?: number|null, timedOut?: boolean}} e what the call threw
20
+ * @param {{ asked: string, notCompared: string }} what the tool asked, and what was therefore not compared
21
+ * @returns {{state: "skip", blocked: true, message: string} | null} null: report it as the FAIL it was
22
+ */
23
+ export function doorCallVerdict(e, { asked, notCompared }) {
24
+ const refused = e?.status === 429 || e?.status === 401 || e?.status === 403 || e?.timedOut === true;
25
+ if (!refused) return null;
26
+ return { state: "skip", blocked: true, message: `could not ask ${asked}, so ${notCompared} — ${String(e?.message ?? e).slice(0, 200)}` };
27
+ }