clearotron 0.2.3 → 0.2.4

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 (76) hide show
  1. package/.env.example +36 -37
  2. package/CONTRIBUTING.md +8 -4
  3. package/INSTALL.md +24 -4
  4. package/README.md +7 -6
  5. package/bin/example.mjs +6 -5
  6. package/bin/onboard.mjs +175 -9
  7. package/bin/start.mjs +66 -4
  8. package/build-info.json +2 -2
  9. package/demo/README.md +1 -1
  10. package/docs/GLOSSARY.md +85 -0
  11. package/docs/README.md +1 -0
  12. package/docs/architecture/01-product-overview.md +21 -9
  13. package/docs/architecture/05-config-governance.md +5 -0
  14. package/docs/decisions/0006-what-the-public-repository-carries.md +30 -5
  15. package/driver/CHANGELOG.md +34 -0
  16. package/driver/README.md +25 -6
  17. package/driver/connotation-search.mjs +1 -1
  18. package/driver/contract-audit.mjs +5 -1
  19. package/driver/contract-e3-baseline.json +11 -11
  20. package/driver/doubt-selection.mjs +1 -1
  21. package/driver/drainer-identity.mjs +1 -1
  22. package/driver/effort-model.mjs +2 -2
  23. package/driver/engine/probe.mjs +45 -5
  24. package/driver/gateway.mjs +2 -2
  25. package/driver/outbox-backoff.mjs +1 -1
  26. package/driver/package.json +1 -1
  27. package/driver/pipeline.mjs +206 -101
  28. package/driver/plain-register.mjs +16 -2
  29. package/driver/portal-config-view.mjs +30 -1
  30. package/driver/portal-local-auth.mjs +5 -1
  31. package/driver/portal-service.mjs +53 -2
  32. package/driver/predelivery-lint.mjs +54 -25
  33. package/driver/publish/render.mjs +109 -14
  34. package/driver/search-policy.mjs +1 -1
  35. package/driver/stage-context.mjs +13 -0
  36. package/driver/stages.mjs +51 -4
  37. package/driver/suite-census.json +97 -31
  38. package/driver/systemd/clearotron-worker.service +3 -3
  39. package/driver/tokens.mjs +1 -1
  40. package/driver/unit-inventory.mjs +34 -4
  41. package/mcp-server/CHANGELOG.md +2 -0
  42. package/mcp-server/package.json +1 -1
  43. package/package.json +4 -10
  44. package/portal-ui/dist/assets/{index-CEYvXdqH.js → index-KFAHMgdT.js} +73 -34
  45. package/portal-ui/dist/index.html +1 -1
  46. package/portal-ui/package.json +1 -1
  47. package/providers/oauth-mcp-bridge/CHANGELOG.md +2 -0
  48. package/providers/oauth-mcp-bridge/package.json +1 -1
  49. package/scripts/added-reference-check.mjs +45 -89
  50. package/scripts/e2e.mjs +2 -2
  51. package/scripts/generated-files-are-current.mjs +125 -0
  52. package/scripts/mint-names-in-force.mjs +4 -2
  53. package/scripts/mint-public-residue.mjs +74 -0
  54. package/scripts/mint-reference-strip-backlog.mjs +12 -1
  55. package/scripts/mint-suite-census.mjs +5 -2
  56. package/scripts/render-check.mjs +13 -2
  57. package/scripts/report-frame-check.mjs +8 -1
  58. package/scripts/report-screenshot.mjs +6 -3
  59. package/scripts/revisit-render-check.mjs +6 -3
  60. package/scripts/score.mjs +1 -1
  61. package/scripts/strip-tracker-citations.mjs +9 -1
  62. package/scripts/test-full.mjs +226 -0
  63. package/shared/README.md +2 -1
  64. package/shared/browser-temp-root.mjs +142 -0
  65. package/shared/checkout-move.mjs +1 -1
  66. package/shared/client-door.mjs +4 -3
  67. package/shared/identifier-scan.mjs +1 -1
  68. package/shared/install-auth.mjs +35 -0
  69. package/shared/invocation.mjs +5 -1
  70. package/shared/listen.mjs +55 -3
  71. package/shared/names-in-force.mjs +4 -1
  72. package/shared/os-advice.mjs +91 -0
  73. package/shared/reap-on-exit.mjs +42 -0
  74. package/shared/reference-guard-classes.mjs +351 -0
  75. package/shared/suite-census.mjs +31 -3
  76. package/shared/withheld-paths-access.mjs +36 -18
package/bin/onboard.mjs CHANGED
@@ -671,24 +671,114 @@ export async function askSignIn(io, { localAccount = "user", staffLabel = "Staff
671
671
  * Was `resolveClaudeBin`. The body never had anything claude-specific in it; the NAME was the last place
672
672
  * this file still assumed one engine, and a name that lies is how the second adapter stayed invisible.
673
673
  */
674
- export function resolveEngineBin(bin) {
674
+ export function resolveEngineBin(bin, { env = process.env, wsl = null, onWindowsDrive = null } = {}) {
675
+ // The predicate, not just the pattern, because /mnt/c cannot be created on a Linux runner without
676
+ // root — so an arm that could only supply a PATH could never drive the skip, and the branch would
677
+ // ship asserted by nobody. `ON_A_WINDOWS_DRIVE` is held to real paths by its own arm.
678
+ const onDrive = onWindowsDrive ?? ((x) => ON_A_WINDOWS_DRIVE.test(x));
675
679
  // driver/engine/anthropic-agent.mjs — `CLEAROTRON_CLAUDE_PATH || "claude"`; openai-agent.mjs — the same
676
680
  // shape on `CLEAROTRON_CODEX_PATH || "codex"`. A RELATIVE path is the trap for BOTH: stage subprocesses are
677
681
  // spawned with cwd set to the RUN DIRECTORY (driver/engine/common.mjs resolveSpawnCwd, shared by the
678
682
  // two adapters), so a relative binary resolves against a directory that did not exist at setup time.
683
+ const underWsl = wsl ?? isWsl({ env });
679
684
  if (bin.includes("/")) {
680
685
  const abs = resolve(bin);
681
- if (!isAbsolute(bin)) return { path: abs, executable: isExec(abs), relative: true };
682
- return { path: abs, executable: isExec(abs), relative: false };
686
+ // A PATH SOMEBODY TYPED IS NOT OVERRULED, only reported. The reader stated this one, and silently
687
+ // resolving somewhere else would be the launcher moving a door off a port that was asked for.
688
+ return { path: abs, executable: isExec(abs), relative: !isAbsolute(bin), windowsShim: underWsl && onDrive(abs), skipped: [] };
683
689
  }
684
- for (const dir of (process.env.PATH || "").split(delimiter).filter(Boolean)) {
690
+ // ── UNDER WSL, THE WINDOWS PATH IS APPENDED TO THIS ONE ────────────────────────────────────────
691
+ //
692
+ // So `claude` on a fresh WSL2 Ubuntu resolves to /mnt/c/…/claude — the WINDOWS shim — before any
693
+ // Linux install is reached, and it is executable by every test this makes. It then fails the proof
694
+ // turn as "not signed in", because the credential it is looking for is the Linux one, and a reader
695
+ // is sent to fix a sign-in that was never the problem. Reported from a real WSL2 attempt.
696
+ //
697
+ // SKIPPED, AND NAMED. Passing over a candidate silently would leave the reader with "no binary
698
+ // found" on a machine where `which claude` prints one, so the skips travel back for the caller to
699
+ // say out loud. A missing Linux install then reports as missing, which is the true answer.
700
+ const skipped = [];
701
+ for (const dir of (env.PATH || "").split(delimiter).filter(Boolean)) {
685
702
  const p = join(dir, bin);
686
- if (isExec(p)) return { path: p, executable: true, relative: false };
703
+ if (!isExec(p)) continue;
704
+ if (underWsl && onDrive(p)) { skipped.push(p); continue; }
705
+ return { path: p, executable: true, relative: false, windowsShim: false, skipped };
687
706
  }
688
- return { path: null, executable: false, relative: false };
707
+ return { path: null, executable: false, relative: false, windowsShim: false, skipped };
708
+ }
709
+
710
+ /** A path on a Windows drive as WSL mounts it. */
711
+ export const ON_A_WINDOWS_DRIVE = /^\/mnt\/[a-z]\//i;
712
+
713
+ /**
714
+ * Whether this is a Linux running under Windows.
715
+ *
716
+ * BOTH SIGNALS INJECTABLE, for the reason `platformEngineRefusal` gives: the readers this protects
717
+ * are the ones who cannot run this suite to find out, so a Linux runner has to be able to drive both
718
+ * answers rather than read the source and agree with it.
719
+ *
720
+ * A READ THAT FAILS ANSWERS "NOT WSL", and that is the direction that changes nothing: it leaves the
721
+ * resolution exactly as it was before this existed. Claiming WSL on a could-not-read would start
722
+ * refusing candidates under /mnt on an ordinary Linux box with an ordinary mount.
723
+ */
724
+ export function isWsl({ env = process.env, procVersion = null } = {}) {
725
+ if (String(env.WSL_DISTRO_NAME ?? "").trim()) return true;
726
+ if (String(env.WSL_INTEROP ?? "").trim()) return true;
727
+ const v = procVersion ?? (() => { try { return readFileSync("/proc/version", "utf8"); } catch { return ""; } })();
728
+ return /microsoft|wsl/i.test(v);
729
+ }
730
+
731
+ /**
732
+ * What to say about candidates passed over because they sit on a Windows drive — or `null` when none
733
+ * were. Plural because a reader can have both a shim and a wrapper on PATH.
734
+ */
735
+ export function windowsShimNote(skipped, bin) {
736
+ if (!skipped?.length) return null;
737
+ return `ignored ${skipped.length === 1 ? "a `" + bin + "` on a Windows drive" : skipped.length + " `" + bin + "` binaries on Windows drives"} `
738
+ + `(${skipped.join(", ")}): WSL appends the Windows PATH to this one, and a Windows build cannot run a `
739
+ + `stage here — it fails the proof turn as "not signed in" while holding a credential this side never `
740
+ + `wrote. Install it inside the Linux distribution instead.`;
689
741
  }
690
742
  const isExec = (p) => { try { accessSync(p, constants.X_OK); return statSync(p).isFile(); } catch { return false; } };
691
743
 
744
+ /**
745
+ * What doctor says about the engine on a platform the run door refuses outright, or `null` where the
746
+ * ordinary binary checks apply.
747
+ *
748
+ * `platform` IS INJECTABLE, and for the reason `preflightEngineBinary` gives at its own refusal: the
749
+ * population this protects is the one that cannot run this suite to find out. An arm on a Linux runner
750
+ * has to be able to ask what a Windows reader is shown, or the Windows text is asserted by nobody —
751
+ * and reading `process.platform` inside the caller would make that arm read source instead of driving
752
+ * the answer.
753
+ */
754
+ export function platformEngineRefusal({ platform = process.platform } = {}) {
755
+ if (platform !== "win32") return null;
756
+ return "this engine does not run on native Windows — stage subprocesses are spawned with POSIX path "
757
+ + "and process semantics, so the run door refuses before it reads PATH. Run it under WSL2, or in "
758
+ + "the devcontainer. The demo works here as it is: it replays finished reports and needs no engine.";
759
+ }
760
+
761
+ /**
762
+ * How to leave demo mode, which is not the same instruction everywhere.
763
+ *
764
+ * On the platform the run door refuses, the standard advice is a loop: install a CLI the reader may
765
+ * already have, then restart a service — neither of which can change the answer, because the refusal
766
+ * is about the platform rather than the program. Naming WSL2 is the only instruction that ends it.
767
+ */
768
+ export function leaveDemoAdvice(engSpec, { platform = process.platform } = {}) {
769
+ if (platform === "win32") {
770
+ return [`To leave demo on Windows: run the product under WSL2, or in the devcontainer. Installing `
771
+ + `${engSpec.vendor}'s CLI natively will not change this — the run door refuses on the platform, `
772
+ + "not on the program."];
773
+ }
774
+ return [
775
+ `To leave demo: install ${engSpec.vendor}'s CLI (\`${engSpec.fallback}\`)`
776
+ + `${engSpec.install ? ` with \`${engSpec.install}\`` : ""}, then ${engSpec.signIn}.`,
777
+ "Restart any running engine service afterwards so it re-reads its PATH: the portal reports what the "
778
+ + "engine saw when it last started, and it will not notice a new install until then.",
779
+ ];
780
+ }
781
+
692
782
  /**
693
783
  * The engine menu, built from the driver's registry so the wizard cannot offer an adapter that does not
694
784
  * exist — or hide one that does. Same guarantee the register-provider list has.
@@ -1201,7 +1291,28 @@ export async function runCheck() {
1201
1291
  const binSet = !!binEff;
1202
1292
  const binSetting = binEff?.v || engSpec.fallback;
1203
1293
  const bin = resolveEngineBin(binSetting);
1204
- if (bin.executable && !bin.relative) ok(`${bin.path}`);
1294
+ // ── NATIVE WINDOWS IS ANSWERED HERE, BEFORE ANY PATH IS RESOLVED OR REPORTED ──────────────────
1295
+ //
1296
+ // `resolveEngineBin` tests a candidate with `accessSync(X_OK)` and `isFile()`. Windows has no
1297
+ // execute bit, so X_OK is satisfied by any file that exists — and an npm global install writes
1298
+ // BOTH `claude`, an extensionless shell script for Git Bash, and `claude.cmd`. The POSIX test
1299
+ // passes on the shell script, so this line reported the engine FOUND at a path CreateProcess
1300
+ // cannot start, and the probe then failed with `spawn claude ENOENT`. Reported from a real
1301
+ // Windows run, 2026-09-09.
1302
+ //
1303
+ // RESOLVING `claude.cmd` INSTEAD WOULD MOVE THE CONTRADICTION, NOT REMOVE IT.
1304
+ // `preflightEngineBinary` refuses native Windows by name, and the run door calls it
1305
+ // unconditionally — so a clearance cannot run here whatever this line finds. Making the probe
1306
+ // succeed would produce a doctor that says ready, a probe that passes, and a run that refuses
1307
+ // anyway: the same disagreement one step later, and more convincing for having spawned something.
1308
+ //
1309
+ // So doctor says what the run door says. That is the property — the two agree — and the reader
1310
+ // gets one refusal they can act on instead of a found-then-failed sequence that sends them
1311
+ // looking for a PATH problem they do not have. The demo is unaffected: it replays finished runs
1312
+ // and needs no engine, which is why four reports published on that same Windows box.
1313
+ const platformRefusal = platformEngineRefusal();
1314
+ if (platformRefusal) problem(platformRefusal);
1315
+ else if (bin.executable && !bin.relative) ok(`${bin.path}`);
1205
1316
  // The FACT only. It used to carry "install it for a real run (`npm run example` needs no engine)",
1206
1317
  // which is the absence framing was filed about — and it now says half of what the MODE line
1207
1318
  // below says, in worse words. One statement of a state, in the place that states states.
@@ -1212,6 +1323,15 @@ export async function runCheck() {
1212
1323
  else if (bin.relative) problem(`${engSpec.env}="${binSetting}" is RELATIVE — stage subprocesses run with cwd set to the run directory, so it will not resolve there. Use an absolute path (${bin.path} from here)`);
1213
1324
  else if (!bin.path) problem(`${engSpec.env}="${binSetting}" resolves to nothing on PATH`);
1214
1325
  else problem(`${engSpec.env}="${binSetting}" → ${bin.path} is not an executable file`);
1326
+ // SAID AFTER THE CHAIN ABOVE, AND OUTSIDE IT. This block is one if/else-if ladder, so a statement
1327
+ // placed between two of its clauses re-parents every clause below onto the new `if` — measured:
1328
+ // it made doctor report an executable mock binary as "not an executable file", because the ladder's
1329
+ // tail became the else of THIS condition. Said whether or not a binary was found: under WSL a
1330
+ // Windows build on the appended PATH is passed over, and if a Linux one was found the reader still
1331
+ // needs to know which of the two they have, while if none was, "no binary" over a machine whose own
1332
+ // `which` prints one is a refusal nobody can act on.
1333
+ const shimNote = windowsShimNote(bin.skipped, engSpec.fallback);
1334
+ if (shimNote) info(shimNote);
1215
1335
 
1216
1336
  // ── — WHICH MODE THIS INSTALL IS IN, said as a mode rather than as a list of absences ──────
1217
1337
  //
@@ -1242,7 +1362,15 @@ export async function runCheck() {
1242
1362
  if (installMode === ENGINE_MODES.DEMO) {
1243
1363
  info("MODE: demo — everything works except starting a NEW search. The example report, its audit trail "
1244
1364
  + "and the MCP connection are live right now; `npm run example` needs no engine.");
1245
- info(`To leave demo: install ${engSpec.vendor}'s CLI (\`${engSpec.fallback}\`), then ${engSpec.signIn}.`);
1365
+ // NAMES THE COMMAND, AND THE RESTART. The settings page says both now, and a doctor that named
1366
+ // the program but not how to install it — or that left out the restart, which is what actually
1367
+ // unsticks a reader who has just installed it — would be the third opinion this issue exists to
1368
+ // remove. Same words on all three surfaces, taken from the same table.
1369
+ // THE WAY OUT IS NOT THE SAME ON EVERY PLATFORM, and on the one where it differs the standard
1370
+ // advice is a loop: a Windows reader is told to install a CLI they may already have, and then to
1371
+ // restart a service — neither of which can change the answer, because the refusal is about the
1372
+ // platform rather than the binary. Naming WSL2 is the only instruction that ends this state.
1373
+ for (const line of leaveDemoAdvice(engSpec)) info(line);
1246
1374
  } else {
1247
1375
  info("The engine program is installed. Whether it is signed in cannot be read from disk: run "
1248
1376
  + "clearotron doctor --probe-engine to find out.");
@@ -2257,7 +2385,7 @@ export async function runCheck() {
2257
2385
  //
2258
2386
  // `describeDoorState` was written for exactly this block and then never called from it: the split
2259
2387
  // it encodes sat in `shared/client-door.mjs` with no caller in `bin/` or `driver/`, while doctor
2260
- // went on printing the three sentences the split replaces. The defect role-e2e measured is one of
2388
+ // went on printing the three sentences the split replaces. The defect testing measured is one of
2261
2389
  // them — a `connect` that died at `daemon-reload` had already written the fence and placed both
2262
2390
  // units, and doctor said "the client door is on" over a unit that was inactive with nothing on its
2263
2391
  // port. Every angle read as configured, because configured is all anything asked.
@@ -2730,7 +2858,32 @@ try {
2730
2858
  if (!pick.id) { sayNoEngine(); break; }
2731
2859
  const eng = ENGINE_BINARIES[pick.id];
2732
2860
 
2861
+ // ── NATIVE WINDOWS IS ANSWERED HERE TOO, AND THE ESCAPE IS OFFERED RATHER THAN LEFT ON A MENU ──
2862
+ //
2863
+ // Doctor learned this and the wizard did not, and the gap is worse than it sounds: the two "no
2864
+ // engine" escapes below both sit behind "no usable binary", and on Windows that is FALSE. X_OK is
2865
+ // satisfied by any file that exists, so the wizard says "found C:\...\claude", walks past both
2866
+ // escapes, asks which lane pays, spends a proof turn, and meets `spawn claude ENOENT`. The only
2867
+ // prompt left then defaults to No and returns to the engine menu — with nothing changed between
2868
+ // iterations, on either lane. Reported from a real Windows run: the menu's last row was the one
2869
+ // way out and nothing on screen said so.
2870
+ //
2871
+ // So the refusal is stated before a candidate is resolved, and finishing with no engine is OFFERED
2872
+ // rather than being a row the reader has to notice. Declining leaves them on the menu exactly as
2873
+ // before, which is the branch that was already there.
2874
+ const wizardRefusal = platformEngineRefusal();
2875
+ if (wizardRefusal) {
2876
+ problem(wizardRefusal);
2877
+ if (await confirm("Finish setup with no engine configured?", true)) { sayNoEngine(); break engine; }
2878
+ continue;
2879
+ }
2880
+
2733
2881
  let bin = resolveEngineBin(process.env[eng.env] || eng.fallback);
2882
+ // Under WSL the Windows build on the appended PATH has been passed over. Said before the install
2883
+ // offer below, because otherwise a reader whose Linux install is genuinely missing is asked to
2884
+ // install a binary their own `which` already prints — and would decline for the wrong reason.
2885
+ const shimNote = windowsShimNote(bin.skipped, eng.fallback);
2886
+ if (shimNote) info(shimNote);
2734
2887
  if (!(bin.executable && !bin.relative) && eng.install) {
2735
2888
  // ── — INSTALLING IT IS ONE COMMAND, AND WE USED TO STOP AT A SENTENCE ───────────────────
2736
2889
  //
@@ -2929,7 +3082,20 @@ try {
2929
3082
  break engine;
2930
3083
  }
2931
3084
  problem(probeFailureText(v));
3085
+ // ── THE ENGINE'S OWN LAST LINES, UNDER EVERY VERDICT, ON BOTH LANES ──────────────────────────
3086
+ //
3087
+ // This used to be `if (v.detail)`, so a verdict carrying no detail printed a headline and nothing
3088
+ // else — and the reader had a classification with no evidence to check it against. That is the
3089
+ // worst shape for a heuristic: the signed-out test is a text match over the engine's output, it
3090
+ // matches broadly on purpose, and a reader shown only its conclusion cannot tell a genuinely
3091
+ // signed-out CLI from an unrelated failure whose words happened to match.
3092
+ //
3093
+ // SAY WHICH IT IS, INCLUDING WHEN THERE IS NOTHING. "The engine printed nothing" is itself the
3094
+ // diagnosis on a binary that died before it opened its mouth, and printing no line at all leaves
3095
+ // a reader unable to tell that from a wizard that decided not to show them.
2932
3096
  if (v.detail) info(`engine said: ${v.detail}`);
3097
+ else info("engine said: nothing — it produced no output on either stream before it stopped.");
3098
+ if (v.basis) info(`(that reading is ${v.basis === "text-match" ? "matched out of the text above, not a signal from the provider" : v.basis})`);
2933
3099
  // — THE HAND-OFF. Signing in is the one step of this sequence nobody here can perform for
2934
3100
  // someone, so the wizard names the command, waits, and re-probes rather than ending at a
2935
3101
  // description of what is wrong. The text comes from ENGINE_BINARIES so the two adapters cannot
package/bin/start.mjs CHANGED
@@ -116,7 +116,7 @@ import { SERVER_INSTALL_SET, unitsToRestartOnRefresh, unitHealthVerdict } from "
116
116
  // tolerate and never manage"; settled point 2 superseded that.)
117
117
  import { defaultDenylistPath, denylistPathFor, denylistFor, ensureDenylistFile, CLIENT_DOOR_UNIT, enablePlan, clientDoorPort } from "../shared/client-door.mjs"; // — one owner for the revocation list's path
118
118
  import { createServer } from "node:net";
119
- import { listenErrorMessage } from "../shared/listen.mjs";
119
+ import { listenErrorMessage, nextFreePort } from "../shared/listen.mjs";
120
120
  import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
121
121
  import { randomBytes } from "node:crypto";
122
122
  import { invocationPrefix, invoke } from "../shared/invocation.mjs"; // — the banner names the verb
@@ -133,6 +133,8 @@ import { rebuildIfStale } from "../shared/bundle-rebuild.mjs"; // never serve
133
133
  // ONE CLASSIFIER FOR WHAT A STAFF RULE ADMITS, shared with the setup wizard. Two copies of this
134
134
  // judgement would be a wizard that asks about one rule and a launcher that writes another.
135
135
  import { classifyStaffDomain, staffDomainRefusal, staffGrantSentence } from "../shared/staff-domain.mjs";
136
+ import { backgroundManager } from "../shared/os-advice.mjs";
137
+ import { frontingVariablesSet } from "../shared/install-auth.mjs"; // — one owner for what counts as a proxy in front of a door
136
138
 
137
139
  const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
138
140
  const ENV_PATH = envLocalPath({ repoRoot: REPO }); // resolved, never composed: one resolver, so moving this file later is one line
@@ -962,7 +964,24 @@ if (isMain) {
962
964
  // loop because both paths below have to act on it: the background path must RESTART the door it
963
965
  // adopted, and the foreground path must not spawn a second one beside it.
964
966
  let adoptedClientDoor = false;
965
- for (const [what, port, portVar, doorUnit = null] of [["portal", ports.portal, "PORTAL_SERVICE_PORT"], ["engine door", ports.mcp, "TRADEMARK_MCP_HTTP_PORT"], ["client door", ports.client, "CLIENT_MCP_HTTP_PORT", CLIENT_DOOR_UNIT]]) {
967
+ // ── WHETHER ANYTHING OUTSIDE THIS PROCESS IS ADDRESSED TO THESE NUMBERS ────────────────────────
968
+ //
969
+ // A door may only be moved off a port nobody chose when nothing fronts it. A proxy, an Access team
970
+ // or an OIDC issuer means something external resolves to these numbers, and a door that quietly
971
+ // moved would be up and unreachable — which looks like success and is the worst of the three
972
+ // outcomes. The auth-mode half of this is already closed further up: a foreground start refuses
973
+ // outright when PORTAL_AUTH_MODE names a hosted door, and again when the units are installed and
974
+ // serving. What is left to check is the settings that can be present with the mode unset.
975
+ //
976
+ // PORTAL_AUTH_MODE IS ABSENT FROM THAT LIST ON PURPOSE, and only a rule in another file makes that
977
+ // safe: `driver/portal-service.mjs` refuses to start in auth-proxy without CF_ACCESS_TEAM or
978
+ // PORTAL_OIDC_ISSUER, so a portal fronted by the mode alone cannot come up at all. Relaxing that
979
+ // refusal without adding the mode here would let this under-report — which is why the list has one
980
+ // owner in `shared/install-auth.mjs` with an arm holding it to the doors themselves.
981
+ const fronted = frontingVariablesSet(process.env);
982
+ const claimedPorts = new Set([ports.portal, ports.mcp, ports.client]);
983
+ const movedDoors = [];
984
+ for (const [what, port, portVar, doorUnit = null, key = null] of [["portal", ports.portal, "PORTAL_SERVICE_PORT", null, "portal"], ["engine door", ports.mcp, "TRADEMARK_MCP_HTTP_PORT", null, "mcp"], ["client door", ports.client, "CLIENT_MCP_HTTP_PORT", CLIENT_DOOR_UNIT, "client"]]) {
966
985
  // A --background REFRESH runs over its own healthy units, which hold these ports on purpose;
967
986
  // systemd's restart is the handover. Probing would refuse the flag exactly once it has worked.
968
987
  // The narrow carve above already proved every installed unit is ours.
@@ -987,8 +1006,38 @@ if (isMain) {
987
1006
  // on a privileged port and from an address this host does not have, and names the way out of each;
988
1007
  // the launcher having its own shorter sentence for one of the three would mean a user meets two
989
1008
  // different answers to the same question depending on which door refused first.
1009
+ // ── A PORT NOBODY CHOSE IS MOVED RATHER THAN REFUSED (owner ruling, 2026-09-09) ─────────────
1010
+ //
1011
+ // Three conditions, and each is a different reason:
1012
+ // · the address is genuinely taken — anything else is not this case;
1013
+ // · the reader did not state this port, so no one is addressed to it. An explicitly set port is
1014
+ // a stated address and is refused exactly as before;
1015
+ // · nothing fronts these doors, so nothing outside resolves to the old number.
1016
+ // With any of those false the refusal below fires unchanged, which is the whole of the previous
1017
+ // behaviour kept intact underneath this.
1018
+ if (code === "EADDRINUSE" && key && !fronted.length && !String(process.env[portVar] ?? "").trim()) {
1019
+ const next = await nextFreePort(port, async (p) => (await probe(p)) === null, { claimed: claimedPorts });
1020
+ // NULL IS NOT A FALLBACK. Nothing free in range means the reader is told the truth about the
1021
+ // port they asked for, rather than sent to one this could not prove was free either.
1022
+ if (next) {
1023
+ claimedPorts.delete(port);
1024
+ claimedPorts.add(next);
1025
+ ports[key] = next;
1026
+ movedDoors.push({ what, portVar, from: port, to: next });
1027
+ continue;
1028
+ }
1029
+ }
990
1030
  if (code) fatal(listenErrorMessage({ code }, { what, host: HOST, port, portVar, portFile }));
991
1031
  }
1032
+ // SAID OUT LOUD, EVERY TIME. A door that moved is at an address the reader did not ask for and will
1033
+ // not find by memory — and the portal's own URL is printed from `ports.portal` further down, so a
1034
+ // silent move would leave the two disagreeing with nothing explaining it.
1035
+ if (movedDoors.length) {
1036
+ say("");
1037
+ say(` ${movedDoors.length === 1 ? "One door was" : `${movedDoors.length} doors were`} already in use, so ${movedDoors.length === 1 ? "it" : "they"} moved:`);
1038
+ for (const d of movedDoors) say(` the ${d.what}: ${d.from} → ${d.to} (set ${d.portVar} to pin it)`);
1039
+ say(" Nothing outside this machine is addressed to these, so nothing else needed changing.");
1040
+ }
992
1041
 
993
1042
  // ── 2. the two secrets, generated once and kept ────────────────────────────────────────────────────
994
1043
 
@@ -1981,8 +2030,21 @@ if (isMain) {
1981
2030
  // run". Saying it here, before the commands go by, is the whole fix.
1982
2031
  say(" This terminal is now the product: it runs only while this command does, and Ctrl-C — or closing");
1983
2032
  say(" the window — stops everything it started. So the commands above need a SECOND terminal.");
1984
- say(` To get your prompt back instead, stop this and run ${invoke("start")} --background`);
1985
- say(" — same product, managed by systemd, and it survives logout.");
2033
+ // ── THE BACKGROUND ROUTE IS NOT OFFERED WHERE IT CANNOT WORK ────────────────────────────────────
2034
+ //
2035
+ // `--background` installs and enables service units. There are none on Windows, so both the offer
2036
+ // and the sentence naming what manages them were wrong there — a reader was told to run a flag that
2037
+ // cannot succeed and given a service manager that is not on the machine and cannot be put there.
2038
+ // Reported from a real run. Same rule as the engine refusal above: do not name a route this platform
2039
+ // does not have.
2040
+ const manager = backgroundManager();
2041
+ if (manager) {
2042
+ say(` To get your prompt back instead, stop this and run ${invoke("start")} --background`);
2043
+ say(` — same product, managed by ${manager}, and it survives logout.`);
2044
+ } else {
2045
+ say(" There is no background form on this platform: the product runs as long as this window does.");
2046
+ say(" Leave it open and use a second terminal for the commands above.");
2047
+ }
1986
2048
  say("");
1987
2049
  }
1988
2050
 
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "5c8eca66a5f226dde9e1d01affd63da4eda61bbd",
3
- "version": "0.2.3"
2
+ "commit": "3c2277f7fab7452490c86d19802dd60a886a2b06",
3
+ "version": "0.2.4"
4
4
  }
package/demo/README.md CHANGED
@@ -37,7 +37,7 @@ they are machine output with a fixed shape, described here instead:
37
37
  the search policy, the receipts, the pre-delivery lint. These are what the report is assembled from
38
38
  and what the audit surfaces read.
39
39
  - `<product-id>/run/_records/` — the register records the run actually retrieved, one file per record,
40
- named by register and number. Real EUIPO records for a fictional mark.
40
+ named by register and number. Real Clarivate Compumark records for a fictional mark.
41
41
 
42
42
  `<product-id>/PROVENANCE.md` says how each run was frozen, what was dropped, and how to regenerate it.
43
43
 
@@ -0,0 +1,85 @@
1
+ # Glossary
2
+
3
+ Words this codebase uses in a particular way. They are here because they are already in the tree —
4
+ in file names, comments and test titles — and a contributor meeting one should not have to reverse
5
+ it out of the code. Product vocabulary a client would meet is in [`../README.md`](../README.md); the
6
+ tenant, account and project model is in [`../INSTALL.md`](../INSTALL.md) under "The four things, and
7
+ what contains what".
8
+
9
+ Nothing here is a rule. Each line says what the word points at, and names the file that owns it.
10
+
11
+ ## The run
12
+
13
+ **Seat** — one model turn with one job, inside a stage that has several. The common-law grid runs
14
+ halves `a` and `b` and a meaning seat `m`; each has its own model tier and its own ledger, and one
15
+ seat failing is not the stage failing.
16
+
17
+ **Dispatch** — handing a seat its prompt and waiting for the turn. Recorded per attempt in
18
+ `_driver/<stage>.jsonl`, so a stage that ran three times has three dispatches and one result.
19
+
20
+ **Matter frame** — what the run understands the request to be: the mark, the goods, the sector, the
21
+ territories. Everything downstream derives from it, and it is told not to widen past the territories
22
+ the job named.
23
+
24
+ **Blind frame** — a second reading of the raw request that never sees the matter frame. It runs
25
+ beside the investigation as a non-fatal sibling, so a framing mistake shows up as a disagreement
26
+ rather than propagating quietly.
27
+
28
+ **Feedforward** — the reviewer's flags reaching the corrective pass as data rather than as prose.
29
+ `corrections-feedforward.mjs` decides which flags count as still open.
30
+
31
+ **Carry-through** — of every subject the run's findings surface, which ones reach none of the
32
+ delivered documents. A finding that is recorded and never carried is the thing this measures.
33
+
34
+ **Declination** — a record the run enumerated and then decided not to pursue, with the position of
35
+ its reason in `_driver/declination-spec.json`. A declination is a decision, not a gap.
36
+
37
+ **Refusal** — the product declining rather than failing. `terminalKind: "designed-refusal"`. Nothing
38
+ about a refusal is retried, parked or recovered; the remedy is always the operator's.
39
+
40
+ ## What a run leaves behind
41
+
42
+ **Ledger** — an append-only record of what was covered, kept so that "unsearched" can never be
43
+ reported as "clean". The coverage ledger appears in the report as prose and as JSON, from one source.
44
+
45
+ **Receipt** — a record that something happened, kept for observability and deliberately not used as a
46
+ gate. A receipt read as a gate is a Goodhart problem, and the reasoning-integrity receipt says so on
47
+ its own face.
48
+
49
+ **Register-digest** — the condensed register result a downstream stage reads instead of the raw
50
+ records: the same evidence, at the size a model turn can hold.
51
+
52
+ **Hit list** — one line per enumerated record and the fate it was given. It is not a read: a record
53
+ can be on the hit list and never opened, and the sign-off condition is about documents actually read.
54
+
55
+ **Predelivery** — the checks that run after the documents exist and before anyone receives them
56
+ (`predelivery-lint.mjs`, `_driver/predelivery-lint.json`). A failing predelivery withholds the
57
+ artifact; it never annotates it.
58
+
59
+ **Courier** — delivery as a self-contained packet handed to the integrator. The engine does not send;
60
+ what it produces is carried.
61
+
62
+ **Drift** — a value that is mirrored in two places by design, and the two disagreeing. The catalogue
63
+ of what is mirrored, and which copy owns it, is in the architecture docs.
64
+
65
+ ## Guards and tests
66
+
67
+ **Arm** — one `test(...)` block. A file has many; each is named for the property it holds, and a red
68
+ arm is read by its output rather than by its name.
69
+
70
+ **Plant** — deliberately breaking something to prove a check would catch it. A guard that has never
71
+ been shown failing has not been shown to work, so the hard version plants a NEW instance of the class
72
+ rather than the one the check was written against.
73
+
74
+ **Contract dictation** — the rule that a contract is stated once and everything else derives from it.
75
+ `contract-dictation.mjs` computes over a corpus; `scripts/contract-dictation-scan.mjs` builds that
76
+ corpus from the tracked tree, so a new authoring layer is caught structurally rather than because
77
+ somebody remembered to list it.
78
+
79
+ **Assert census** — counting the assertions that never executed. An assertion inside a branch nothing
80
+ reaches is a test that passes and guards nothing (`scripts/unexecuted-asserts.mjs`).
81
+
82
+ **Authority probe** — asking the live CLI whether it honours a boundary the driver sets, and whether
83
+ the turn survives being refused. CI has no CLI and no subscription, so it can only prove the decision
84
+ is made correctly; whether it is obeyed is a property of the real binary
85
+ (`scripts/authority-boundary-probe.mjs`).
package/docs/README.md CHANGED
@@ -38,6 +38,7 @@ the vendor flags approximate is UNKNOWN, never a number.
38
38
  | [`PORTAL.md`](PORTAL.md) | The portal: what it serves, and who sees what |
39
39
  | [`CLIENT-MCP.md`](CLIENT-MCP.md) | Publishing a connector your customers sign in to, and how their access is scoped. To connect *your own* app to *your own* runs, use [`../mcp-server/CONNECT.md`](../mcp-server/CONNECT.md) instead |
40
40
  | [`E2E.md`](E2E.md) | Proving a deployment works end to end |
41
+ | [`GLOSSARY.md`](GLOSSARY.md) | The words this codebase uses in a particular way — one line each, for a contributor meeting them for the first time |
41
42
  | [`SECURITY.md`](SECURITY.md) | The security envelope — what protects what, and where it is enforced in code. To report a vulnerability, use [`../SECURITY.md`](../SECURITY.md) |
42
43
 
43
44
  Access control is stated once, in [`SECURITY.md`](SECURITY.md): who may see which runs, what an unset
@@ -3,18 +3,30 @@
3
3
  > Part of the architecture pack (`docs/architecture/`). The driver's module tree and the headless
4
4
  > integrator contract are in [`driver/README.md`](../../driver/README.md).
5
5
 
6
- The clearotron driver turns a plain-language clearance request into a delivered, lawyer-vetted
7
- preliminary trademark clearance report. One matter in, one decision out — with the investigation,
8
- the reasoning, the challenge, and the paper trail in between run by deterministic code that treats
9
- the model as a reasoning step, never as the orchestrator.
6
+ The clearotron driver turns a clearance request into a delivered preliminary trademark clearance
7
+ report. One matter in, one decision out — with the investigation, the reasoning, the challenge, and
8
+ the paper trail in between run by deterministic code that treats the model as a reasoning step,
9
+ never as the orchestrator.
10
10
 
11
11
  ## What it does
12
12
 
13
- A lawyer forwards a clearance request by email. The system resolves the matter (mark, client,
14
- scope, deadline), investigates worldwide trademark registers and the live marketplace, in the
15
- variations a lawyer would try — reasons the risk in the client's own framework, challenges its own
16
- draft twice, and delivers a client-formatted report with every factual claim traceable to a fetched
17
- source record. A lawyer vets the result, then it moves.
13
+ **Who this is for.** Anyone who needs to know whether a name is free to use and is willing to run the
14
+ search themselves: a lawyer, a brand team, or an individual clearing their own mark. You need three
15
+ self-serve accounts: a reasoning CLI, a register, and web research.
16
+
17
+ **What you get.** Four searches at different depths. A knockout screens up to eight names in minutes.
18
+ A global preliminary, a multi-country focus, or a full country search takes one name deeper, and the
19
+ full country search adds case law. Each delivers a written report with the records behind every
20
+ finding. What you do with it is your call.
21
+
22
+ The four searches are declared in `driver/products.mjs`, and that declaration is what the rest of the
23
+ system reads: a knockout screens up to eight names and skips case law, and only the full country
24
+ search turns case law on. A knockout takes 5 to 10 minutes; the other three take 1.5 to 2.5 hours.
25
+
26
+ Under any of them the system resolves the matter (mark, scope, deadline), investigates the trademark
27
+ registers and the live marketplace in the variations a searcher would try, reasons the risk in the
28
+ matter's own framework, challenges its own draft twice, and delivers a report with every factual
29
+ claim traceable to a fetched source record.
18
30
 
19
31
  The driver is the machine that runs that whole distance. It is **not an agent**: it is a plain
20
32
  Node.js process, launched by systemd, that executes a fixed pipeline of stages. Each stage that
@@ -136,6 +136,11 @@ boxes rebuild.
136
136
 
137
137
  ## 5. Env-var register (by domain → tier)
138
138
 
139
+ **[`04-configuration-reference.md`](04-configuration-reference.md) is the variable table**, and it is
140
+ the one two instruments read — `driver/doc-constants.mjs` and `scripts/env-audit.mjs`. What follows
141
+ here is the governance view: which tier a name sits in and who may change it. When the two disagree
142
+ about what a variable *does*, 04 is right and this page is stale.
143
+
139
144
  Tiers: T3 = operator-tunable (candidate for read-only display); T4 = backend-only (secret,
140
145
  structural, or dev seam); [dev] = dev/test seam, never set in prod.
141
146
 
@@ -29,11 +29,36 @@ cut, not before: `cut/rules.mjs` treats a rule matching nothing as a refusal, so
29
29
  does not yet exist cannot be pre-added, and `driver/test/release-pipeline.test.mjs` reds the moment the
30
30
  file exists undecided.
31
31
 
32
- **The drop list is `shared/withheld-paths.mjs`, and it is the only place a cut decision is recorded.** Two
33
- tests read it: `driver/test/publication-scrub.test.mjs` fails a shipped file that cites a withheld path
34
- without a declared reason, and `driver/test/no-caveat-repair.test.mjs` treats an absent-and-declared file
35
- as a stated consequence rather than damage. A decision recorded anywhere else — an issue, a chat, a comment
36
- is not recorded.
32
+ **The drop list is `shared/withheld-paths.mjs`, and it is the only place a cut decision is recorded.** A
33
+ decision recorded anywhere else an issue, a chat, a comment is not recorded.
34
+
35
+ **Amended 2026-09-09.** This paragraph named two tests as the enforcement: `publication-scrub` and
36
+ `no-caveat-repair`. Neither exists. Measured across both repositories that day: neither file is tracked,
37
+ neither is on disk, and neither was ever added or deleted in either repository's history. A decision
38
+ record that names enforcement which was never written is worse than one that names none, because a
39
+ reader stops looking. What follows is what actually holds the line.
40
+
41
+ **The drop list itself does not ship.** It stays with the archive by owner ruling, 2026-08-31, so the
42
+ published tree carries no copy — and neither, today, does any other tree. `CUT_RECORD_PRESENT` is false
43
+ everywhere the code runs.
44
+
45
+ **What enforces the cut today is `shared/withheld-paths-access.mjs` and its three readers.** The accessor
46
+ is the only thing that reads the drop list, and it is written to work where the list is absent:
47
+
48
+ | Reader | With the list | Without it, which is every tree today |
49
+ |---|---|---|
50
+ | `scripts/citation-line-check.mjs` | a withheld file's citations need not resolve | every file crosses the cut, so every citation must resolve |
51
+ | `scripts/mint-suite-census.mjs` | a withheld test file is a stated absence | a removed test file is a LOSS |
52
+ | `shared/reference-guard-classes.mjs` | withheld paths are skipped | nothing is skipped, so the whole tree is counted |
53
+
54
+ Every one degrades **stricter**, never weaker, which is the property that makes the absence safe. A
55
+ separate record of what was cut is kept with the archive rather than published, so this repository can be
56
+ checked without it.
57
+
58
+ **The gap, stated rather than left to be found.** Because no tree carries the list, the with-the-list
59
+ column above has never run. What is enforced today is the strict fallback, not the record — and a check
60
+ that has never executed its other branch is a check whose other branch is unproven. The accessor
61
+ announces which mode it is in, once, so a reader of any run can tell which column applies.
37
62
 
38
63
  **What survives a withheld document is the fact itself, moved to where the code enforces it:**
39
64
 
@@ -1,5 +1,39 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.2.4
4
+
5
+ ### Patch Changes
6
+
7
+ - d35dd9b: Fixed: A search that was planned and never run is disclosed on the report again, even when another search mentions the same word.
8
+
9
+ One row on the coverage section says a planned search never reached the register. That row was removed whenever another row's heading carried the same words. A row for a search that had run and found nothing could remove it.
10
+
11
+ So a report could mention a term in its coverage section and say nothing was left undone. The only disclosure of the gap had been dropped. A completed search no longer stands in for an uncompleted one.
12
+
13
+ Re-rendering an archived report restores the row where this had removed it.
14
+ - b47be41: Fixed: Seven fixed sentences on the clearance report are now written for the person reading it.
15
+
16
+ These lines print on every report and none of them was written for a client. The footer explained the risk-band vocabulary to a developer. A paragraph defined a label most readers never saw on their page. An internal coverage note ran to about a thousand characters of the engine's own search names and ended mid-word.
17
+
18
+ Registration numbers no longer carry "(placeholder)" where the register has no per-record link — the number stands on its own. A gap that was disclosed twice, once in the model's words and once in the engine's, is disclosed once. Three section captions say what the section is rather than how it was produced. And a use check that found nothing no longer prints an evidence tag beside it, which read as a contradiction.
19
+
20
+ The page also no longer calls itself a working draft for legal review. That sentence printed on every report and went with the footer rewrite; it is a deliberate removal, not a casualty of one.
21
+
22
+ Nothing here changes what was searched, counted, rated or judged. Every fact about where a record came from is still there: the registration numbers, the dates they were read, the year each was registered.
23
+ - a4c9045: Fixed: The demo now names the register its example run was captured against.
24
+
25
+ `clearotron demo` said the run came from a production EU register. It did not. All four frozen runs were captured against Clarivate Compumark, which is what their own records and the reports say. The label also carried a capture date that matches none of the four runs, so it has gone.
26
+
27
+ The line now says what it is: a real run against Clarivate Compumark, and replaying it needs no account, no key and no network. That last part was always true and is worth saying where a reader meets it.
28
+ - f3bab16: Fixed: The lines a client reads first are now checked for the profession's vocabulary and for sentences carrying more than one idea. The reviewing pass rewrites them before delivery. Nothing about the check is shown to the client, and no run fails over it. A report clearing a name like PREVAIL is unaffected, because the mark being cleared is never read as a legal term.
29
+ - 862f76a: Fixed: The screen that will not start a search now gives advice that fits your machine.
30
+
31
+ An install with the engine program present, but invisible to the engine service, was told to install a program it already had. That advice cannot work. Following it changes nothing, because the engine reads its PATH when it starts. Until it is restarted, every screen reports what it saw at startup. Nothing said so.
32
+
33
+ The New clearance notice now tells those two states apart. Where the program is absent it gives the install advice as before. It adds that the service has to be restarted afterwards before it will notice. Where the program is present and the engine cannot see it, the notice says that instead, and names the restart as the remedy. Staff also get a link from that notice to the configuration page.
34
+
35
+ The configuration page's engine row now names the program it could not find. It names the command that installs it too, and `clearotron doctor` says the same, in the same words.
36
+
3
37
  ## 0.2.3
4
38
 
5
39
  ### Patch Changes