clearotron 0.3.0-beta.4 → 0.3.0-beta.6

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 (39) hide show
  1. package/.env.example +9 -0
  2. package/INSTALL.md +13 -0
  3. package/bin/connect.mjs +3 -1
  4. package/bin/onboard.mjs +80 -28
  5. package/bin/start.mjs +82 -18
  6. package/bin/update.mjs +38 -6
  7. package/build-info.json +2 -2
  8. package/docs/architecture/04-configuration-reference.md +1 -0
  9. package/driver/CHANGELOG.md +36 -10
  10. package/driver/contract-e3-backlog.mjs +1 -1
  11. package/driver/demo-container.mjs +30 -2
  12. package/driver/engine/openai-agent.mjs +60 -2
  13. package/driver/enqueue-schema.mjs +3 -1
  14. package/driver/gateway.mjs +12 -0
  15. package/driver/package.json +1 -1
  16. package/driver/portal-local-auth.mjs +35 -3
  17. package/driver/portal-service.mjs +80 -18
  18. package/driver/portal-upstream.mjs +20 -1
  19. package/driver/search-policy.mjs +7 -2
  20. package/driver/suite-census.json +71 -23
  21. package/mcp-server/CHANGELOG.md +12 -2
  22. package/mcp-server/CONNECT.md +3 -2
  23. package/mcp-server/lib/options.mjs +13 -5
  24. package/mcp-server/package.json +1 -1
  25. package/package.json +1 -1
  26. package/portal-ui/dist/assets/{index-DWYCsOCJ.js → index-DBUVdQT-.js} +6 -0
  27. package/portal-ui/dist/index.html +1 -1
  28. package/portal-ui/package.json +1 -1
  29. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  30. package/providers/oauth-mcp-bridge/package.json +1 -1
  31. package/scripts/e2e.mjs +84 -12
  32. package/scripts/engine-probe.mjs +2 -2
  33. package/scripts/env-classify.mjs +3 -0
  34. package/shared/connect-clients.mjs +8 -0
  35. package/shared/names-in-force.mjs +1 -0
  36. package/shared/permanent-install.mjs +167 -0
  37. package/shared/stdio-connect.mjs +32 -14
  38. package/shared/verb-shim.mjs +10 -1
  39. package/shared/wsl.mjs +23 -0
@@ -135,6 +135,12 @@
135
135
  "skips": 0,
136
136
  "todos": 0
137
137
  },
138
+ "a-codex-login-survives-its-own-refresh.test.mjs": {
139
+ "tests": 4,
140
+ "asserts": 18,
141
+ "skips": 0,
142
+ "todos": 0
143
+ },
138
144
  "a-company-can-be-given-a-framework-after-it-exists.test.mjs": {
139
145
  "tests": 8,
140
146
  "asserts": 19,
@@ -153,6 +159,12 @@
153
159
  "skips": 0,
154
160
  "todos": 0
155
161
  },
162
+ "a-connect-line-says-where-it-runs.test.mjs": {
163
+ "tests": 4,
164
+ "asserts": 14,
165
+ "skips": 0,
166
+ "todos": 0
167
+ },
156
168
  "a-credential-file-is-written-one-way.test.mjs": {
157
169
  "tests": 4,
158
170
  "asserts": 13,
@@ -381,6 +393,12 @@
381
393
  "skips": 0,
382
394
  "todos": 0
383
395
  },
396
+ "a-local-install-hands-its-stores-to-every-reader.test.mjs": {
397
+ "tests": 1,
398
+ "asserts": 10,
399
+ "skips": 0,
400
+ "todos": 0
401
+ },
384
402
  "a-mark-name-cannot-lie-about-how-it-reads.test.mjs": {
385
403
  "tests": 9,
386
404
  "asserts": 37,
@@ -490,8 +508,8 @@
490
508
  "todos": 0
491
509
  },
492
510
  "a-printed-command-runs-from-where-the-reader-stands.test.mjs": {
493
- "tests": 14,
494
- "asserts": 59,
511
+ "tests": 15,
512
+ "asserts": 62,
495
513
  "skips": 0,
496
514
  "todos": 0
497
515
  },
@@ -747,6 +765,12 @@
747
765
  "skips": 0,
748
766
  "todos": 0
749
767
  },
768
+ "a-store-refusal-reaches-the-person-who-can-fix-it.test.mjs": {
769
+ "tests": 5,
770
+ "asserts": 21,
771
+ "skips": 0,
772
+ "todos": 0
773
+ },
750
774
  "a-stored-territory-that-does-nothing-is-named.test.mjs": {
751
775
  "tests": 6,
752
776
  "asserts": 12,
@@ -945,6 +969,12 @@
945
969
  "skips": 0,
946
970
  "todos": 0
947
971
  },
972
+ "an-install-from-npx-moves-out-of-the-cache.test.mjs": {
973
+ "tests": 8,
974
+ "asserts": 45,
975
+ "skips": 4,
976
+ "todos": 0
977
+ },
948
978
  "an-mcp-route-says-which-challenge-it-answers-with.test.mjs": {
949
979
  "tests": 8,
950
980
  "asserts": 25,
@@ -1804,9 +1834,9 @@
1804
1834
  "todos": 0
1805
1835
  },
1806
1836
  "doctor-reads-the-units-not-the-shell.test.mjs": {
1807
- "tests": 30,
1808
- "asserts": 85,
1809
- "skips": 0,
1837
+ "tests": 33,
1838
+ "asserts": 96,
1839
+ "skips": 1,
1810
1840
  "todos": 0
1811
1841
  },
1812
1842
  "doctor-refuses-what-cannot-run.test.mjs": {
@@ -2080,9 +2110,9 @@
2080
2110
  "todos": 0
2081
2111
  },
2082
2112
  "enqueue-schema-class-inheritance.test.mjs": {
2083
- "tests": 11,
2084
- "asserts": 28,
2085
- "skips": 0,
2113
+ "tests": 12,
2114
+ "asserts": 31,
2115
+ "skips": 1,
2086
2116
  "todos": 0
2087
2117
  },
2088
2118
  "enqueue-schema-unreadable-store.test.mjs": {
@@ -3022,8 +3052,8 @@
3022
3052
  "todos": 0
3023
3053
  },
3024
3054
  "portal-local-login.test.mjs": {
3025
- "tests": 22,
3026
- "asserts": 89,
3055
+ "tests": 28,
3056
+ "asserts": 111,
3027
3057
  "skips": 0,
3028
3058
  "todos": 0
3029
3059
  },
@@ -3058,9 +3088,9 @@
3058
3088
  "todos": 0
3059
3089
  },
3060
3090
  "portal-service.test.mjs": {
3061
- "tests": 123,
3062
- "asserts": 662,
3063
- "skips": 0,
3091
+ "tests": 124,
3092
+ "asserts": 670,
3093
+ "skips": 1,
3064
3094
  "todos": 0
3065
3095
  },
3066
3096
  "portal-static.test.mjs": {
@@ -4018,9 +4048,9 @@
4018
4048
  "todos": 0
4019
4049
  },
4020
4050
  "search-policy.test.mjs": {
4021
- "tests": 34,
4022
- "asserts": 177,
4023
- "skips": 0,
4051
+ "tests": 35,
4052
+ "asserts": 180,
4053
+ "skips": 1,
4024
4054
  "todos": 0
4025
4055
  },
4026
4056
  "seat-outputs-outside-driver-tree.test.mjs": {
@@ -4455,6 +4485,12 @@
4455
4485
  "skips": 0,
4456
4486
  "todos": 0
4457
4487
  },
4488
+ "the-demo-hands-its-runs-to-the-assistant.test.mjs": {
4489
+ "tests": 2,
4490
+ "asserts": 11,
4491
+ "skips": 0,
4492
+ "todos": 0
4493
+ },
4458
4494
  "the-demo-help-names-every-product-it-ships.test.mjs": {
4459
4495
  "tests": 4,
4460
4496
  "asserts": 8,
@@ -4480,8 +4516,8 @@
4480
4516
  "todos": 0
4481
4517
  },
4482
4518
  "the-doors-comparison-knows-an-admit-case.test.mjs": {
4483
- "tests": 7,
4484
- "asserts": 20,
4519
+ "tests": 12,
4520
+ "asserts": 38,
4485
4521
  "skips": 0,
4486
4522
  "todos": 0
4487
4523
  },
@@ -4659,6 +4695,12 @@
4659
4695
  "skips": 1,
4660
4696
  "todos": 0
4661
4697
  },
4698
+ "the-open-line-says-what-to-do-when-the-page-is-not-ours.test.mjs": {
4699
+ "tests": 4,
4700
+ "asserts": 13,
4701
+ "skips": 0,
4702
+ "todos": 0
4703
+ },
4662
4704
  "the-owner-of-a-promoted-filing-is-searched.test.mjs": {
4663
4705
  "tests": 15,
4664
4706
  "asserts": 43,
@@ -4725,6 +4767,12 @@
4725
4767
  "skips": 0,
4726
4768
  "todos": 0
4727
4769
  },
4770
+ "the-proving-question-names-no-engines-model.test.mjs": {
4771
+ "tests": 2,
4772
+ "asserts": 5,
4773
+ "skips": 0,
4774
+ "todos": 0
4775
+ },
4728
4776
  "the-public-residue-is-a-floor.test.mjs": {
4729
4777
  "tests": 15,
4730
4778
  "asserts": 59,
@@ -5403,9 +5451,9 @@
5403
5451
  "todos": 0
5404
5452
  },
5405
5453
  "describe-options.test.mjs": {
5406
- "tests": 23,
5407
- "asserts": 98,
5408
- "skips": 0,
5454
+ "tests": 24,
5455
+ "asserts": 108,
5456
+ "skips": 1,
5409
5457
  "todos": 0
5410
5458
  },
5411
5459
  "diff.test.mjs": {
@@ -5594,8 +5642,8 @@
5594
5642
  "ext": "ts",
5595
5643
  "perFile": {
5596
5644
  "api.test.ts": {
5597
- "tests": 43,
5598
- "asserts": 170,
5645
+ "tests": 44,
5646
+ "asserts": 176,
5599
5647
  "skips": 0,
5600
5648
  "todos": 0
5601
5649
  },
@@ -1,5 +1,15 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.3.0-beta.6
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.3.0-beta.5
8
+
9
+ ### Patch Changes
10
+
11
+ - e7cd9e1: Fixed: On an install run with `clearotron start`, a connected assistant now lists the same saved searches as the portal, including Generic's. `clearotron doctor` no longer says working saved searches are off, and it names one place profiles come from.
12
+
3
13
  ## 0.3.0-beta.4
4
14
 
5
15
  No changes in this release.
@@ -18,11 +28,11 @@ No changes in this release.
18
28
 
19
29
  ### Patch Changes
20
30
 
21
- - b8ec213: Fixed: a company you create in the browser now appears in your assistant's list of companies straight away.
31
+ - b8ec213: Fixed: A company you create in the browser now appears in your assistant's list of companies straight away.
22
32
 
23
33
  It used to appear only after the service restarted, although a search could already be started for it. Its projects were missing from the list in the same way, and so was its account in the search options.
24
34
 
25
- The live surface check no longer reports a company outside the portal's trigger key as the service and the store disagreeing. It names the key's gap on a line of its own, with the command to widen the key.
35
+ The check comparing the running service with the configuration store no longer calls a company the portal's key does not cover a disagreement. It names that gap on a line of its own, with the command that widens the key.
26
36
 
27
37
  ## 0.2.4
28
38
 
@@ -44,14 +44,15 @@ touches no network. If that works, everything below is configuration.
44
44
  One command. Substitute your clone's path and your workspace root:
45
45
 
46
46
  ```sh
47
- claude mcp add trademark-artifacts \
47
+ claude mcp add trademark-artifacts --scope user \
48
48
  -e CLEAROTRON_WORK_DIR=/path/to/your/workspace \
49
49
  -- node /path/to/clearotron/mcp-server/server.mjs
50
50
  ```
51
51
 
52
52
  Check it: `claude mcp list` prints `trademark-artifacts: … - √ Connected`.
53
53
 
54
- Add `-s user` to make it available in every project instead of just this one. Remove it with
54
+ `--scope user` makes it available in every project, not only the directory you ran it in. In Windows
55
+ PowerShell 5.1, write the separator as `"--"`: that shell drops a bare `--`. Remove it with
55
56
  `claude mcp remove trademark-artifacts`.
56
57
 
57
58
  ## Claude Desktop
@@ -193,7 +193,10 @@ function accountFor(key, { scope, now }) {
193
193
  profile = loadProfiles().get(key) ?? null;
194
194
  if (!profile) { fresh = loadProfiles({ force: true }); profile = fresh.get(key) ?? null; }
195
195
  } catch { return null; } // unreadable roster ⇒ say nothing
196
- if (!profile || profile.key === "generic") return null;
196
+ // GENERIC IS ANSWERED LIKE ANY COMPANY (owner ruling, 2026-09-11). It owns saved searches in the portal,
197
+ // and a fresh install holds Generic alone, so treating it as nobody's account told the assistant on
198
+ // every such install that none existed.
199
+ if (!profile) return null;
197
200
 
198
201
  let projects = [];
199
202
  try {
@@ -220,7 +223,7 @@ function accountFor(key, { scope, now }) {
220
223
  ? null
221
224
  : "This account names no default search. A request that names none is whichever search its resolved territories make it — worldwide is a Global preliminary search, one country is a Full country search, anything else is a Multi-country focus search.",
222
225
  },
223
- ...savedSearchesFor(key),
226
+ ...savedSearchesFor(key, scope),
224
227
  allowance: allowanceFor(profile, { scope, now }),
225
228
  };
226
229
  }
@@ -243,12 +246,17 @@ function accountFor(key, { scope, now }) {
243
246
  * repeats it to the client as the answer. The note names no variable and no path: its reader may be a
244
247
  * client, and the reason belongs to whoever runs the installation, whose `clearotron doctor` names it.
245
248
  */
246
- function savedSearchesFor(key) {
249
+ function savedSearchesFor(key, scope) {
247
250
  let recipes;
248
251
  try { recipes = loadRecipes({ force: true, proseGuard: recipeProseGuard }); }
249
- catch {
252
+ catch (e) {
253
+ // The store's own error, path included, goes only to a session POSITIVELY known to run the installation:
254
+ // an ops session, or a person with access to everything. Everyone else, a missing scope included, gets
255
+ // the sentence alone, because its reader may be a client.
256
+ const operator = scope?.kind === "ops" || scope?.everything === true;
257
+ const cause = operator ? ` (${String(e?.message ?? e).split("\n")[0].slice(0, 300)})` : "";
250
258
  return { savedSearches: [], savedSearchesNote: "Saved searches could not be read on this installation, so none are "
251
- + "listed. That is not the same as having none, and the people who run the installation can see why." };
259
+ + `listed. That is not the same as having none, and the people who run the installation can see why${cause}.` };
252
260
  }
253
261
  return {
254
262
  savedSearches: [...recipes.entries()]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.3.0-beta.4",
3
+ "version": "0.3.0-beta.6",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clearotron",
3
3
  "type": "module",
4
- "version": "0.3.0-beta.4",
4
+ "version": "0.3.0-beta.6",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",
@@ -10309,6 +10309,12 @@ function decodeStatus(status, body) {
10309
10309
  };
10310
10310
  return { kind: "notFound" };
10311
10311
  case 409: {
10312
+ const refused = refusalDetail(body);
10313
+ if (refused?.code.startsWith("store_")) return {
10314
+ kind: "reject",
10315
+ errors: errorsOf(body),
10316
+ detail: refused
10317
+ };
10312
10318
  const msg = errorsOf(body)[0] ?? "This action could not be completed.";
10313
10319
  return /version|conflict/i.test(msg) && !/confirmation|plan again|re-confirm/i.test(msg) ? {
10314
10320
  kind: "conflict",
@@ -49,7 +49,7 @@
49
49
  -->
50
50
  <link rel="preconnect" href="https://api.fontshare.com" crossorigin />
51
51
  <link href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700,900&display=swap" rel="stylesheet" />
52
- <script type="module" crossorigin src="/portal/assets/index-DWYCsOCJ.js"></script>
52
+ <script type="module" crossorigin src="/portal/assets/index-DBUVdQT-.js"></script>
53
53
  <link rel="stylesheet" crossorigin href="/portal/assets/index-Cv-E_agg.css">
54
54
  </head>
55
55
  <body>
@@ -2,7 +2,7 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.0-beta.4",
5
+ "version": "0.3.0-beta.6",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
8
8
  "engines": {
@@ -1,5 +1,13 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.3.0-beta.6
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.3.0-beta.5
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.3.0-beta.4
4
12
 
5
13
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.3.0-beta.4",
3
+ "version": "0.3.0-beta.6",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "OAuth 2.1 MCP stdio bridge used by the engine's case-law gather stage (courtlistener / legaldatahunter).",
package/scripts/e2e.mjs CHANGED
@@ -1057,6 +1057,57 @@ export function doorDisagreementLine(caseId, answers, { expectTerminal = null }
1057
1057
  + `and naming the accepter would blame the door that behaved. Declare \`doors\` on the case to say which doors can admit it.`;
1058
1058
  }
1059
1059
 
1060
+ /**
1061
+ * The doors ONE case goes through: the scenario's doors, narrowed by the case's own `doors` list when it
1062
+ * declares one. Pure, and settled for EVERY case before anything is queued, so a mistyped declaration
1063
+ * refuses the round instead of quietly shrinking it.
1064
+ *
1065
+ * WHY A CASE MAY NARROW THEM. `door: "all"` exists to catch a rule one door enforces and another does not,
1066
+ * and that only works when every door can ASK the case's question. R0e asks what a request naming no
1067
+ * customer falls back to. The ops door rides an accounts-scoped key, and its gate refuses such a request
1068
+ * before that question is reached, correctly. So comparing that door's answer with the cli door's reported
1069
+ * a disagreement between two doors that both behaved. A declared list says which doors can answer; a door
1070
+ * left out is reduced coverage the case chose, recorded with its reason, and never a disagreement.
1071
+ *
1072
+ * Refused, each naming the case: a list that is empty or not a list of names, a name that is no door at
1073
+ * all, and a door this scenario does not drive. Each of those would run the case through fewer doors than
1074
+ * its author meant, with nothing saying so.
1075
+ */
1076
+ export function caseDoors(scenarioDoors, kase = {}) {
1077
+ const declared = kase?.doors;
1078
+ if (declared == null) return { asked: [...scenarioDoors], notAsked: [] };
1079
+ const id = kase?.id ?? "(unnamed case)";
1080
+ if (!Array.isArray(declared) || !declared.length || declared.some((d) => typeof d !== "string" || !d))
1081
+ throw new Error(`${id}: \`doors\` must be a non-empty list of door names, got ${JSON.stringify(declared)}`);
1082
+ const unknown = declared.filter((d) => !Object.hasOwn(DOORS, d));
1083
+ if (unknown.length)
1084
+ throw new Error(`${id}: \`doors\` names ${unknown.map((d) => JSON.stringify(d)).join(", ")}, which is not a door (${Object.keys(DOORS).join(", ")})`);
1085
+ const outside = declared.filter((d) => !scenarioDoors.includes(d));
1086
+ if (outside.length)
1087
+ throw new Error(`${id}: \`doors\` names ${outside.join(", ")}, which this scenario does not drive (${scenarioDoors.join(", ")})`);
1088
+ const said = kase["why-doors"];
1089
+ const why = (Array.isArray(said) ? said.join(" ") : String(said ?? "")).replace(/\s+/g, " ").trim()
1090
+ || "the case declares which doors can answer it";
1091
+ return { asked: scenarioDoors.filter((d) => declared.includes(d)), notAsked: scenarioDoors.filter((d) => !declared.includes(d)).map((door) => ({ door, why })) };
1092
+ }
1093
+
1094
+ /**
1095
+ * One round's door coverage, read off its receipt: the cases whose doors disagreed, the cases that lost a
1096
+ * door to the transport, and the cases that left a door out by declaration. `reduced` counts a case once
1097
+ * however many of those it has. ONE reading for `report`, so the count on the summary line and the lines
1098
+ * under it cannot be two derivations of one fact.
1099
+ */
1100
+ export function doorCoverage(rec) {
1101
+ const cases = rec?.cases ?? [];
1102
+ const dis = cases.filter((c) => !c.agreed);
1103
+ const lost = cases
1104
+ .map((c) => ({ c, out: (c.answers || []).filter((a) => (a.answerClass ?? doorAnswerClass(a)) === DOOR_ANSWER.INFRA_UNAVAILABLE) }))
1105
+ .filter((x) => x.out.length);
1106
+ const chose = cases.filter((c) => Array.isArray(c.notSubmitted) && c.notSubmitted.length);
1107
+ const reduced = new Set([...lost.map((x) => x.c.id), ...chose.map((c) => c.id)]).size;
1108
+ return { dis, lost, chose, reduced };
1109
+ }
1110
+
1060
1111
  const enqueue = (job, door) =>
1061
1112
  door === "cli" ? enqueueViaCli(job)
1062
1113
  : door === "ops-mcp" ? enqueueViaMcp(job)
@@ -1912,13 +1963,20 @@ async function cmdRun(id) {
1912
1963
  // ever wants the launch to stop.
1913
1964
  printPreviousRoundNotice(s);
1914
1965
 
1915
- const jobs = s.job ? [{ id: s.id, job: s.job }] : (s.cases ?? []).map((c) => ({ id: c.id, job: c.job, what: c.what, oneMatterAcrossDoors: c.oneMatterAcrossDoors === true, expectTerminal: (c.expect ?? {}).terminal ?? null }));
1966
+ const jobs = s.job ? [{ id: s.id, job: s.job, kase: {} }] : (s.cases ?? []).map((c) => ({ id: c.id, job: c.job, what: c.what, oneMatterAcrossDoors: c.oneMatterAcrossDoors === true, expectTerminal: (c.expect ?? {}).terminal ?? null, kase: c }));
1916
1967
 
1917
1968
  // door: "all" is the point of R0 — a rule enforced in one door and not another is exactly the #98
1918
1969
  // asymmetry. Every case goes through EVERY drivable door and the answers are compared. runner.mjs's
1919
1970
  // claimAndPrep is the wall they all land on, so they must agree; if they ever do not, the door that
1920
- // admits is the bug, not the door that refuses.
1971
+ // admits is the bug, not the door that refuses. A case that declares `doors` goes through those alone
1972
+ // (caseDoors says why a case may).
1921
1973
  const doors = s.door === "all" ? Object.entries(DOORS).filter(([, d]) => d.real).map(([k]) => k) : [s.door];
1974
+ // Settled for every case BEFORE the first enqueue: a declaration that cannot be honoured refuses the
1975
+ // round here, not after half of it has been queued.
1976
+ const doorsFor = new Map();
1977
+ for (const { id, kase } of jobs) {
1978
+ try { doorsFor.set(id, caseDoors(doors, kase)); } catch (e) { die(`e2e: ${s.id}: ${e.message}. Nothing was queued.`); }
1979
+ }
1922
1980
 
1923
1981
  // `token` is what lets `report` — a later, separate process — read THIS round rather than every round
1924
1982
  // that ever used these refs. Without it the second same-day round's report mixes both rounds' queue
@@ -1933,11 +1991,15 @@ async function cmdRun(id) {
1933
1991
  // whether the doors are one matter or two. See refForRun and refForDoor.
1934
1992
  const roundRef = refForRun(job.ref);
1935
1993
  const answers = [];
1936
- for (const d of doors) {
1994
+ // The scenario's `doors` still decide the ref suffix, so a case asked at one door keeps the ref it
1995
+ // would have had at that door in a round that asked every door.
1996
+ const { asked, notAsked } = doorsFor.get(caseId);
1997
+ for (const d of asked) {
1937
1998
  const res = await enqueue({ ...job, ref: refForDoor(roundRef, d, { doors, oneMatterAcrossDoors }) }, d);
1938
1999
  answers.push({ door: d, ...res });
1939
2000
  }
1940
- const { agreed, unavailable, reducedCoverage } = doorAsymmetry(answers);
2001
+ const { agreed, unavailable, reducedCoverage: lostADoor } = doorAsymmetry(answers);
2002
+ const reducedCoverage = lostADoor || notAsked.length > 0;
1941
2003
  console.log(` ${agreed ? (answers[0].ok ? "queued " : "REFUSED") : "DISAGREE"} ${caseId}${what ? ` — ${what}` : ""}`);
1942
2004
  // — ONE LINE PER JOB, ALWAYS. This printed one line per CASE and, when the doors agreed and
1943
2005
  // accepted, nothing per door — so a 2-case round across 2 doors printed 2 lines while enqueuing 4
@@ -1979,7 +2041,12 @@ async function cmdRun(id) {
1979
2041
  // `expect.reasonMatches`, and a case refused for the WRONG reason read exactly like one refused for
1980
2042
  // the right one. Whitespace-collapsed and truncated because the two doors answer in different shapes
1981
2043
  // (the CLI returns JSON, the MCP door an error string) and the receipt is a record, not a transcript.
2044
+ // A door the case left out by declaration, named with the case's reason. Not a disagreement and not a
2045
+ // lost door: the case chose it, and the round's reader still needs to know the case compared fewer doors.
2046
+ for (const n of notAsked)
2047
+ console.log(` ⓘ [${n.door}] not submitted — ${n.why}; this case has reduced door coverage by its own declaration`);
1982
2048
  round.cases.push({ id: caseId, ref: job.ref, submittedRef: roundRef, agreed, reducedCoverage,
2049
+ ...(notAsked.length ? { notSubmitted: notAsked } : {}),
1983
2050
  answers: answers.map((a) => ({ door: a.door, accepted: a.ok,
1984
2051
  // — the job id this door queued, on the RECEIPT as well as on the screen. `report` runs in
1985
2052
  // a later process and could otherwise only re-derive the job set from refs; a watcher reading
@@ -3175,14 +3242,19 @@ async function cmdReport(id, { round: requestedToken = null } = {}) {
3175
3242
  console.log(`\ndoors: NO RECEIPT at ${receiptPath(POOL_ROOT, s.id)} — cannot tell whether the doors agreed`);
3176
3243
  toInvestigate.push(`no doors receipt — run this scenario with the current e2e.mjs to record what each door answered`);
3177
3244
  } else {
3178
- const dis = rec.cases.filter((c) => !c.agreed);
3179
- // — a door lost to the transport is REDUCED COVERAGE, and it is reported as its own line.
3180
- // Older receipts carry no answerClass; `doorAnswerClass` falls back to "answered" for them, which
3181
- // reproduces the previous reading rather than inventing a retrospective one.
3182
- const unavailable = rec.cases
3183
- .map((c) => ({ c, out: (c.answers || []).filter((a) => (a.answerClass ?? doorAnswerClass(a)) === DOOR_ANSWER.INFRA_UNAVAILABLE) }))
3184
- .filter((x) => x.out.length);
3185
- console.log(`\ndoors: ${rec.doors.join(", ") || "(not recorded)"} — ${rec.cases.length} case(s), ${dis.length} disagreement(s), ${unavailable.length} case(s) with reduced door coverage`);
3245
+ // — a door lost to the transport is REDUCED COVERAGE, and it is reported as its own line; so is a
3246
+ // door a case left out by declaration. Older receipts carry no answerClass; `doorAnswerClass` falls
3247
+ // back to "answered" for them, which reproduces the previous reading rather than inventing a
3248
+ // retrospective one. One reading of the receipt, doorCoverage, for the count and the lines under it.
3249
+ const { dis, lost: unavailable, chose, reduced } = doorCoverage(rec);
3250
+ console.log(`\ndoors: ${rec.doors.join(", ") || "(not recorded)"} — ${rec.cases.length} case(s), ${dis.length} disagreement(s), ${reduced} case(s) with reduced door coverage`);
3251
+ for (const c of chose) {
3252
+ const who = c.notSubmitted.map((n) => `${n.door} (${n.why})`).join(", ");
3253
+ console.log(` ⓘ ${c.id}: not submitted to ${who}`);
3254
+ // NOT toInvestigate, for the same reason as a lost door below: the case chose to compare fewer
3255
+ // doors, and that is a limit on what this round proved, not a finding about the product.
3256
+ notProbed.push(`${c.id}: not submitted to ${who} — the case declares which doors can answer it, so its door agreement covers fewer doors than the scenario drives`);
3257
+ }
3186
3258
  for (const c of dis) {
3187
3259
  // — the RECEIPT does not carry the case's contract, so it is joined back to the scenario by
3188
3260
  // id. A receipt written before this field existed reads null and gets the old sentence, which is
@@ -35,7 +35,7 @@
35
35
  // env at module top — and every static import evaluates before this file's own body runs, so applying the
36
36
  // aliases in the body would be too late for the capture it is meant to fix.
37
37
  import "../shared/env-local.mjs";
38
- import { probeEngineTurn, probeFailureText, PROBE_MODEL, PROBE_TIMEOUT_SEC } from "../driver/engine/probe.mjs";
38
+ import { probeEngineTurn, probeFailureText, PROBE_TIMEOUT_SEC } from "../driver/engine/probe.mjs";
39
39
  import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — realpath both sides, or a symlinked invocation exits 0 silently
40
40
 
41
41
  const argv = process.argv.slice(2);
@@ -62,7 +62,7 @@ if (isEntrypoint(import.meta.url)) {
62
62
  const env = engine ? { ...process.env, CLEAROTRON_AI: engine } : process.env;
63
63
  const asJson = argv.includes("--json");
64
64
  if (!asJson) {
65
- console.error(`engine-probe: one ${PROBE_MODEL}-tier turn, up to ${PROBE_TIMEOUT_SEC}s, on this box's own `
65
+ console.error(`engine-probe: one turn on the engine's cheapest model, up to ${PROBE_TIMEOUT_SEC}s, on this box's own `
66
66
  + `credential — real, and it costs whatever one cheap turn costs.`);
67
67
  }
68
68
  const verdict = await probeEngineTurn({ env });
@@ -346,6 +346,9 @@ export const DEPLOYMENT_NAMES = new Set([
346
346
  // by nothing else, so moving it to the house prefix dropped it through to `tuning` — the bucket step 3
347
347
  // deletes from. The audience did not change; only the spelling did, and this list is what says so.
348
348
  "CLEAROTRON_DEMO",
349
+ // Set by the install on the copy it starts after moving out of npx's cache, never by an operator. Its
350
+ // row declares `deployment`, and no shape matches it, so unlisted it would fall through to `tuning`.
351
+ "CLEAROTRON_RELOCATED",
349
352
  // Three names read only through optional chaining, so no catalogue check saw them until the scanner
350
353
  // learned `?.`. Each row declares `deployment`, and no shape matches them (`_PORT$` does not take
351
354
  // `_PORTS`), so unlisted each fell through to `tuning`, the bucket step 3 deletes from. The first is set
@@ -289,6 +289,10 @@ export const leadRouteFor = (id) => {
289
289
  * @param {{ stdioRoutes?: object, publicAddress?: string|null, operator?: string|null }} have
290
290
  * @param {"disk"|"public-http"} [route] defaults to the row's `lead`
291
291
  */
292
+ /** The first step of every "on this computer" route when the install runs under WSL. */
293
+ export const WSL_STEP = "Do this inside your WSL terminal, the Linux one where Clearotron is installed, not in PowerShell. "
294
+ + "An assistant running on Windows itself cannot start it from there.";
295
+
292
296
  export function whatItNeeds(client, have = {}, route = client?.lead) {
293
297
  if (!client) return null;
294
298
  const author = client.routes?.[route];
@@ -320,6 +324,10 @@ export function whatItNeeds(client, have = {}, route = client?.lead) {
320
324
  reason: "this copy of the software is incomplete, so there is nothing to hand your assistant",
321
325
  fix: "whoever installed it will need to install it again" };
322
326
  }
327
+ // ON WSL, "THIS COMPUTER" IS THE LINUX INSIDE WINDOWS. The line names WSL paths, so it runs in the WSL
328
+ // terminal where Clearotron is installed. Pasted into PowerShell it fails, and an assistant running on
329
+ // Windows itself could not start a program at a Linux path anyway.
330
+ if (have.wsl) steps.unshift({ text: WSL_STEP });
323
331
  const first = steps.find((s) => s.copy)?.copy;
324
332
  // `command` and `stdio` ride for the terminal, which prints a disk offer's copy by its shape.
325
333
  return { client, served: true, route, steps, launch: client.launch ?? null, enables: null, ...evidence,
@@ -142,6 +142,7 @@ export const NAMES_IN_FORCE = Object.freeze([
142
142
  "CLEAROTRON_REGISTER_PLAN",
143
143
  "CLEAROTRON_REGISTER_RECORD_LOG",
144
144
  "CLEAROTRON_RELEASE_WAIT_MS",
145
+ "CLEAROTRON_RELOCATED",
145
146
  "CLEAROTRON_REOPEN_MAX_FETCH",
146
147
  "CLEAROTRON_REPLAY_ROOTS",
147
148
  "CLEAROTRON_REPLAY_SNAPSHOT",