clearotron 0.2.2 → 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 (161) hide show
  1. package/.env.example +64 -37
  2. package/.nvmrc +1 -1
  3. package/CONTRIBUTING.md +8 -4
  4. package/INSTALL.md +36 -14
  5. package/README.md +18 -22
  6. package/THIRD-PARTY-NOTICES.md +1 -1
  7. package/bin/clearotron.mjs +22 -0
  8. package/bin/example.mjs +10 -5
  9. package/bin/onboard.mjs +370 -28
  10. package/bin/start.mjs +220 -12
  11. package/build-info.json +2 -2
  12. package/demo/README.md +1 -1
  13. package/demo/full-country-search/run/_driver/framework.json +1 -1
  14. package/demo/global-preliminary-search/run/_driver/framework.json +1 -1
  15. package/demo/knockout-search/run/_driver/framework.json +1 -1
  16. package/demo/multi-country-focus-search/run/_driver/framework.json +1 -1
  17. package/docs/E2E.md +5 -5
  18. package/docs/GLOSSARY.md +85 -0
  19. package/docs/PORTAL.md +2 -2
  20. package/docs/README.md +6 -4
  21. package/docs/SECURITY.md +17 -0
  22. package/docs/architecture/01-product-overview.md +21 -9
  23. package/docs/architecture/04-configuration-reference.md +2 -0
  24. package/docs/architecture/05-config-governance.md +19 -0
  25. package/docs/architecture/05-customer-profiles.md +2 -2
  26. package/docs/configuration.md +4 -3
  27. package/docs/decisions/0006-what-the-public-repository-carries.md +30 -5
  28. package/driver/CHANGELOG.md +90 -0
  29. package/driver/README.md +25 -6
  30. package/driver/common-law-receipts.mjs +1 -1
  31. package/driver/config-staleness.mjs +1 -1
  32. package/driver/connotation-search.mjs +1 -1
  33. package/driver/contract-audit.mjs +5 -1
  34. package/driver/contract-e3-baseline.json +11 -11
  35. package/driver/dev-portal.mjs +2 -2
  36. package/driver/doubt-selection.mjs +1 -1
  37. package/driver/drainer-identity.mjs +1 -1
  38. package/driver/driver.config.mjs +1 -1
  39. package/driver/effort-model.mjs +2 -2
  40. package/driver/engine/cli-version.mjs +103 -0
  41. package/driver/engine/probe.mjs +45 -5
  42. package/driver/enqueue-schema.mjs +8 -8
  43. package/driver/feedback-store.mjs +1 -1
  44. package/driver/findings-model.mjs +52 -2
  45. package/driver/flag-snapshot.mjs +20 -0
  46. package/driver/framework.mjs +1 -1
  47. package/driver/gateway.mjs +20 -3
  48. package/driver/outbox-backoff.mjs +1 -1
  49. package/driver/package.json +3 -3
  50. package/driver/party-facts.mjs +2 -2
  51. package/driver/phase0.mjs +1 -1
  52. package/driver/pipeline.mjs +211 -106
  53. package/driver/plain-register.mjs +134 -0
  54. package/driver/portal-access.mjs +1 -1
  55. package/driver/portal-config-view.mjs +70 -2
  56. package/driver/portal-families.mjs +1 -1
  57. package/driver/portal-local-auth.mjs +5 -1
  58. package/driver/portal-report.mjs +1 -1
  59. package/driver/portal-service.mjs +79 -9
  60. package/driver/portal-upstream.mjs +2 -2
  61. package/driver/predelivery-lint.mjs +151 -2
  62. package/driver/profile-page.html +10 -2
  63. package/driver/profile-service.mjs +4 -4
  64. package/driver/profiles/README.md +3 -3
  65. package/driver/profiles.mjs +144 -12
  66. package/driver/publish/index.mjs +2 -2
  67. package/driver/publish/knockout.mjs +7 -1
  68. package/driver/publish/render-knockout.mjs +453 -118
  69. package/driver/publish/render.mjs +111 -16
  70. package/driver/reference-strip-signatures.mjs +20 -0
  71. package/driver/search-policy.mjs +3 -3
  72. package/driver/skills/blind-frame/SKILL.md +1 -1
  73. package/driver/skills/knockout-assess/SKILL.md +84 -0
  74. package/driver/skills/matter-frame/SKILL.md +6 -6
  75. package/driver/skills/matter-frame/watchlist-reference.md +2 -2
  76. package/driver/skills/prelim-common-law/SKILL.md +1 -1
  77. package/driver/skills/prelim-search/SKILL.md +1 -1
  78. package/driver/skills/prelim-search/report-prose.md +82 -0
  79. package/driver/skills/prelim-search/risk-framework-demo.manifest.json +1 -1
  80. package/driver/skills/prelim-search/risk-framework-triage.manifest.json +1 -1
  81. package/driver/skills/prelim-search/risk-framework.manifest.json +1 -1
  82. package/driver/skills/prelim-search/synthesis-rules.md +1 -1
  83. package/driver/skills/prelim-variants/SKILL.md +3 -3
  84. package/driver/stage-context.mjs +13 -0
  85. package/driver/stages.mjs +54 -7
  86. package/driver/suite-census.json +220 -58
  87. package/driver/systemd/clearotron-worker.service +3 -3
  88. package/driver/tokens.mjs +1 -1
  89. package/driver/unit-inventory.mjs +214 -27
  90. package/driver/verify.mjs +1 -1
  91. package/driver/whatif-memo-run.mjs +26 -4
  92. package/driver/whatif-memo.mjs +19 -1
  93. package/examples/README.md +2 -2
  94. package/examples/grants.example.json +6 -7
  95. package/mcp-server/CHANGELOG.md +4 -0
  96. package/mcp-server/lib/scrub.mjs +1 -1
  97. package/mcp-server/lib/whatif.mjs +2 -2
  98. package/mcp-server/package.json +1 -1
  99. package/mcp-server/packs/ops/EXAMPLES.md +2 -2
  100. package/mcp-server/server.mjs +1 -1
  101. package/package.json +31 -19
  102. package/portal-ui/dist/assets/{index-Ct3iQtZ2.css → index-1ziUJX1E.css} +43 -2
  103. package/portal-ui/dist/assets/{index-BUGgiYtF.js → index-KFAHMgdT.js} +289 -73
  104. package/portal-ui/dist/index.html +2 -2
  105. package/portal-ui/package.json +1 -1
  106. package/providers/_shared/term-shape.mjs +1 -1
  107. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  108. package/providers/oauth-mcp-bridge/package.json +1 -1
  109. package/providers/uspto-local/src/index-store.js +2 -2
  110. package/scripts/added-reference-check.mjs +45 -89
  111. package/scripts/citation-line-check.mjs +121 -2
  112. package/scripts/e2e.mjs +2 -2
  113. package/scripts/env-classify.mjs +67 -3
  114. package/scripts/generated-files-are-current.mjs +125 -0
  115. package/scripts/live-surface-check.mjs +38 -1
  116. package/scripts/mint-names-in-force.mjs +4 -2
  117. package/scripts/mint-public-residue.mjs +74 -0
  118. package/scripts/mint-reference-strip-backlog.mjs +12 -1
  119. package/scripts/mint-suite-census.mjs +5 -2
  120. package/scripts/nightly-notice.mjs +0 -2
  121. package/scripts/preinstall-node-check.mjs +24 -0
  122. package/scripts/render-check.mjs +13 -2
  123. package/scripts/report-frame-check.mjs +8 -1
  124. package/scripts/report-screenshot.mjs +6 -3
  125. package/scripts/revisit-render-check.mjs +6 -3
  126. package/scripts/score.mjs +1 -1
  127. package/scripts/strip-tracker-citations.mjs +38 -7
  128. package/scripts/test-full.mjs +226 -0
  129. package/scripts/test-run.mjs +16 -0
  130. package/shared/README.md +2 -1
  131. package/shared/anon-overlay.mjs +3 -3
  132. package/shared/browser-temp-root.mjs +142 -0
  133. package/shared/checkout-move.mjs +1 -1
  134. package/shared/client-door.mjs +4 -3
  135. package/shared/customer-store.mjs +1 -1
  136. package/shared/identifier-scan.mjs +1 -1
  137. package/shared/install-auth.mjs +35 -0
  138. package/shared/invocation.mjs +33 -1
  139. package/shared/listen.mjs +55 -3
  140. package/shared/names-in-force.mjs +6 -1
  141. package/shared/node-floor.mjs +97 -0
  142. package/shared/os-advice.mjs +91 -0
  143. package/shared/reap-on-exit.mjs +42 -0
  144. package/shared/reference-guard-classes.mjs +351 -0
  145. package/shared/staff-domain.mjs +154 -0
  146. package/shared/store-in-repo.mjs +23 -2
  147. package/shared/suite-census.mjs +31 -3
  148. package/shared/withheld-paths-access.mjs +36 -18
  149. package/skills/clearotron-ops/SKILL.md +1 -1
  150. package/driver/recipes/README.md +0 -49
  151. package/driver/skills/prelim-search/risk-framework-aurora.manifest.json +0 -19
  152. package/driver/skills/prelim-search/risk-framework-aurora.md +0 -77
  153. package/driver/skills/prelim-search/risk-framework-zephyr.manifest.json +0 -14
  154. package/driver/skills/prelim-search/risk-framework-zephyr.md +0 -33
  155. package/driver/skills/prelim-search/worked-examples-aurora.md +0 -10
  156. package/driver/skills/prelim-search/worked-examples-zephyr.md +0 -10
  157. package/scripts/ai-page-render-check.mjs +0 -532
  158. package/scripts/clearances-render-check.mjs +0 -480
  159. package/scripts/composer-render-check.mjs +0 -1027
  160. package/scripts/home-render-check.mjs +0 -526
  161. package/scripts/portal-lifecycle-check.mjs +0 -639
package/bin/onboard.mjs CHANGED
@@ -64,11 +64,16 @@ import { stdin as input, stdout as output } from "node:process";
64
64
  import { accessSync, constants, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, chmodSync } from "node:fs"; // read the process table here; moved that to shared/process-table.mjs
65
65
  import { homedir, userInfo } from "node:os";
66
66
  import { invocationPrefix } from "../shared/invocation.mjs"; // — one rule for how the reader invokes us
67
+ import { nodeFloorVerdict } from "../shared/node-floor.mjs"; // — the floor is package.json engines, not a constant here
67
68
  import { invocationForm } from "../shared/invocation.mjs"; // — and WHY that form
68
69
  import { standFrom } from "../shared/invocation.mjs"; // is this tree one npm replaces?
69
70
  import { installShim } from "../shared/verb-shim.mjs"; // — the verb goes on PATH
70
71
  import { styleFor, banner } from "../shared/tty-style.mjs"; // — weight where the meaning is
71
- import { bracketAsciiCells } from "../shared/brand.mjs"; // F18 — the mark, from the geometry the SVG already uses
72
+ import { bracketAsciiCells, BRAND } from "../shared/brand.mjs"; // F18 — the mark, from the geometry the SVG already uses
73
+ // ONE CLASSIFIER, shared with `bin/start.mjs`. The wizard asks the question; the launcher enforces the
74
+ // answer. Two copies of "what does this domain admit" is a wizard that consents to one rule and a
75
+ // launcher that builds another.
76
+ import { classifyStaffDomain, domainOfEmail, staffDomainRefusal, staffGrantSentence } from "../shared/staff-domain.mjs";
72
77
  import { join, dirname, isAbsolute, resolve } from "node:path";
73
78
  import { fileURLToPath, pathToFileURL } from "node:url";
74
79
  import { delimiter } from "node:path";
@@ -121,7 +126,6 @@ const ENV_PATH = envLocalPath({ repoRoot: REPO }); // resolved, never composed
121
126
  // command is applying. Writes stay on ENV_PATH: a writer that followed the file backwards would keep an
122
127
  // install in the directory npm replaces forever.
123
128
  const READ_ENV_PATH = () => activeEnvPath({ repoRoot: REPO });
124
- const NODE_FLOOR = 22;
125
129
 
126
130
  const argv = process.argv.slice(2);
127
131
  const has = (n) => argv.includes(n);
@@ -587,30 +591,194 @@ export const AMBIENT_KEYS = [
587
591
  // ── helpers ──────────────────────────────────────────────────────────────────────────────────────────
588
592
  const present = (v) => typeof v === "string" && v.trim() !== "";
589
593
 
594
+ /**
595
+ * Ask who signs in, and take a real yes for the access rule that address implies.
596
+ *
597
+ * ── WHY THIS QUESTION EXISTS AT ALL ─────────────────────────────────────────────────────────────────
598
+ *
599
+ * Setup never asked for an address. `clearotron start` therefore took one from `--user`, from
600
+ * `PORTAL_LOCAL_USER`, or from the local account as `<account>@localhost` — and derived a STAFF RULE
601
+ * from everything after its `@`. On the local-account default that rule is `localhost`: one machine,
602
+ * one identity, nothing granted to anyone. Given a real address it is that address's whole domain, and
603
+ * the settings page then reports it back as "Anyone at <domain> — a rule, not a person".
604
+ *
605
+ * An outside install reached exactly that state. Nobody typed the address into this wizard, because
606
+ * this wizard had no prompt for it; an assistant filled one into the environment file on the operator's
607
+ * behalf, and the install granted a documentation domain. They read their own settings page and
608
+ * reported it as a back door. They were right to: a grant to a group had been made, by nobody.
609
+ *
610
+ * So the address is asked for here, where a person is definitionally present, and the grant it implies
611
+ * is shown in the words the settings page uses BEFORE it is written. `start` no longer guesses: it
612
+ * refuses a rule wider than one machine unless `PORTAL_STAFF_DOMAINS` says so in writing, which is what
613
+ * a yes here writes.
614
+ *
615
+ * THE DEFAULT IS THE LOCAL ACCOUNT AND NOTHING ELSE — not the git author, not the hostname, not a shell
616
+ * variable. An identity that becomes an access rule comes from the person, or from the one source that
617
+ * cannot name a second person.
618
+ *
619
+ * `io` IS INJECTED for the reason `offerUsptoSync`'s is: the branch that matters is the one where a
620
+ * reader pressed Enter at the grant question and NOTHING must be granted, and closed over a terminal
621
+ * that branch is asserted nowhere. Returns the `.env` keys to write and nothing else.
622
+ */
623
+ export async function askSignIn(io, { localAccount = "user", staffLabel = "Staff", envPath = "" } = {}) {
624
+ const { askValue, confirm, say = () => {}, ok = () => {}, info = () => {},
625
+ warn = () => {}, problem = () => {} } = io;
626
+ const localDefault = `${localAccount}@localhost`;
627
+ prose("The portal admits one address on this install, and that address is also what decides who is an",
628
+ "administrator. Enter accepts the local-account form, which is this machine and nobody else.");
629
+ for (;;) {
630
+ const typed = String(await askValue("Sign-in address:", { def: localDefault })).trim().toLowerCase();
631
+ if (!typed.includes("@") || typed.indexOf("@") !== typed.lastIndexOf("@")) {
632
+ problem(`"${typed}" is not a single email address. The portal refuses a multi-@ identity outright, `
633
+ + "so this would sign in and then be denied at the door.");
634
+ continue;
635
+ }
636
+ const domain = domainOfEmail(typed);
637
+ const verdict = classifyStaffDomain(domain);
638
+ if (verdict === "public" || verdict === "reserved") {
639
+ // The classifier's own sentence, never a second copy: `clearotron start` prints these same words
640
+ // when it meets the same domain, and two wordings of one refusal is how a reader comes to believe
641
+ // they have met two different problems.
642
+ problem(staffDomainRefusal(domain));
643
+ continue;
644
+ }
645
+ if (verdict === "narrow") {
646
+ ok(`${typed} — this machine only. No domain rule is written, and nobody else is granted anything.`);
647
+ return { PORTAL_LOCAL_USER: typed };
648
+ }
649
+ // `wide`: a real domain, so the grant is a grant. Stated first, in the settings page's own words,
650
+ // and Enter is NOT a yes — the default is no, because this is the one answer in the wizard that
651
+ // admits people the reader has never met.
652
+ say("");
653
+ warn(`${typed} makes this a rule about ${domain}, not about you.`);
654
+ say(` ${staffGrantSentence(domain, { staffLabel })}`);
655
+ say("");
656
+ say(` Everyone at ${domain} who gets past this install's sign-in door would see every brand owner`);
657
+ say(" on it — every clearance, every report, every configuration. On a laptop that is only you,");
658
+ say(" because only one address can sign in. Behind a company login it is the whole domain.");
659
+ say("");
660
+ if (await confirm(`Grant ${domain} that, and write it down as PORTAL_STAFF_DOMAINS?`, false))
661
+ return { PORTAL_LOCAL_USER: typed, PORTAL_STAFF_DOMAINS: domain };
662
+ info(`nothing granted${envPath ? `, and nothing written to ${envPath}` : ""}. ${typed} would sign in `
663
+ + "and every page would refuse it, because signing in is not being enrolled — so choose the "
664
+ + "local-account form, or answer yes above.");
665
+ }
666
+ }
667
+
590
668
  /**
591
669
  * An engine binary, resolved the way the engine resolves it — and the trap that resolution carries.
592
670
  *
593
671
  * Was `resolveClaudeBin`. The body never had anything claude-specific in it; the NAME was the last place
594
672
  * this file still assumed one engine, and a name that lies is how the second adapter stayed invisible.
595
673
  */
596
- 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));
597
679
  // driver/engine/anthropic-agent.mjs — `CLEAROTRON_CLAUDE_PATH || "claude"`; openai-agent.mjs — the same
598
680
  // shape on `CLEAROTRON_CODEX_PATH || "codex"`. A RELATIVE path is the trap for BOTH: stage subprocesses are
599
681
  // spawned with cwd set to the RUN DIRECTORY (driver/engine/common.mjs resolveSpawnCwd, shared by the
600
682
  // two adapters), so a relative binary resolves against a directory that did not exist at setup time.
683
+ const underWsl = wsl ?? isWsl({ env });
601
684
  if (bin.includes("/")) {
602
685
  const abs = resolve(bin);
603
- if (!isAbsolute(bin)) return { path: abs, executable: isExec(abs), relative: true };
604
- 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: [] };
605
689
  }
606
- 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)) {
607
702
  const p = join(dir, bin);
608
- 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 };
609
706
  }
610
- 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.`;
611
741
  }
612
742
  const isExec = (p) => { try { accessSync(p, constants.X_OK); return statSync(p).isFile(); } catch { return false; } };
613
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
+
614
782
  /**
615
783
  * The engine menu, built from the driver's registry so the wizard cannot offer an adapter that does not
616
784
  * exist — or hide one that does. Same guarantee the register-provider list has.
@@ -998,9 +1166,9 @@ export async function runCheck() {
998
1166
  }
999
1167
 
1000
1168
  say("\n Node");
1001
- const major = Number(process.versions.node.split(".")[0]);
1002
- if (major >= NODE_FLOOR) ok(`node ${process.versions.node}`);
1003
- else problem(`node ${process.versions.node} — this engine needs >= ${NODE_FLOOR} (node:sqlite and TS type-stripping are load-bearing)`);
1169
+ const nodeV = nodeFloorVerdict();
1170
+ if (nodeV.ok) ok(`node ${nodeV.current}`);
1171
+ else problem(`node ${nodeV.current} — this engine needs ${nodeV.required} or newer; node:sqlite is not a built-in module before then`);
1004
1172
 
1005
1173
  // Read the file up here rather than at the `.env` heading below: the engine section is the first that
1006
1174
  // needs `effective()`, and which ENGINE is configured decides which binary variable to check. Reading
@@ -1123,7 +1291,28 @@ export async function runCheck() {
1123
1291
  const binSet = !!binEff;
1124
1292
  const binSetting = binEff?.v || engSpec.fallback;
1125
1293
  const bin = resolveEngineBin(binSetting);
1126
- 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}`);
1127
1316
  // The FACT only. It used to carry "install it for a real run (`npm run example` needs no engine)",
1128
1317
  // which is the absence framing was filed about — and it now says half of what the MODE line
1129
1318
  // below says, in worse words. One statement of a state, in the place that states states.
@@ -1134,6 +1323,15 @@ export async function runCheck() {
1134
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)`);
1135
1324
  else if (!bin.path) problem(`${engSpec.env}="${binSetting}" resolves to nothing on PATH`);
1136
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);
1137
1335
 
1138
1336
  // ── — WHICH MODE THIS INSTALL IS IN, said as a mode rather than as a list of absences ──────
1139
1337
  //
@@ -1164,12 +1362,60 @@ export async function runCheck() {
1164
1362
  if (installMode === ENGINE_MODES.DEMO) {
1165
1363
  info("MODE: demo — everything works except starting a NEW search. The example report, its audit trail "
1166
1364
  + "and the MCP connection are live right now; `npm run example` needs no engine.");
1167
- 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);
1168
1374
  } else {
1169
1375
  info("The engine program is installed. Whether it is signed in cannot be read from disk: run "
1170
1376
  + "clearotron doctor --probe-engine to find out.");
1171
1377
  }
1172
1378
 
1379
+ // AND WHETHER THE ENGINE AGREES, which is a different question from the one above and the reason an
1380
+ // outside user gave up on this product. This command reads its OWN environment; the portal's New
1381
+ // clearance screen reads what the engine recorded when it last started. Both were right and they
1382
+ // said opposite things, so a reader who checked the confident-looking one first was told the engine
1383
+ // was fine while no search would start. Reported here in the same words the configuration page uses,
1384
+ // because a doctor that cannot see a contradiction the product ships with is the wrong doctor.
1385
+ try {
1386
+ const { readFlagSnapshot, postureDisagreement } = await import("../driver/flag-snapshot.mjs");
1387
+ // THE READ-SIDE ACCESSOR, because this is a read: `config.poolRoot` throws on a box with no pool
1388
+ // configured, and `readFlagSnapshot(null)` already answers "no capture" for exactly that case.
1389
+ const snap = readFlagSnapshot(config.poolRootOrNull);
1390
+ const rows = snap
1391
+ ? postureDisagreement(snap, { flags: {}, engine: engineInventory(invEnv) })
1392
+ : null;
1393
+ // NULL IS NOT AGREEMENT and neither is an empty pool — a box with no capture has nothing to
1394
+ // disagree with, and saying so beats printing a clean bill nobody measured.
1395
+ const clash = (rows ?? []).find((r) => r.what === "engine program");
1396
+ if (clash) {
1397
+ problem(`The engine that last ran and this machine disagree about the engine program: the last run `
1398
+ + `recorded it as ${clash.capture}, this machine reads it as ${clash.live}. A NEW search will `
1399
+ + `refuse while that is true. Restart the engine service so it re-reads its PATH, or install the `
1400
+ + `CLI where the service can see it.`);
1401
+ }
1402
+ } catch (e) {
1403
+ // WHAT ACTUALLY REACHES THIS CATCH, established by driving it rather than by reading it.
1404
+ //
1405
+ // Not a box with no pool: `poolRootOrNull` answers null and `readFlagSnapshot(null)` answers null,
1406
+ // neither throwing. And NOT an unreadable capture either — `readFlagSnapshot` has its own try and
1407
+ // returns null for a corrupt file, so a damaged capture is already indistinguishable from an absent
1408
+ // one by the time this code sees it. That is worth knowing and is not this change's to fix.
1409
+ //
1410
+ // So this catch covers an import that has broken or an unexpected throw out of the comparison —
1411
+ // the check having stopped running. A doctor silent about its own failure is the defect the rest of
1412
+ // this change is about, an absence rendered as a clean bill, so it says so. A caution rather than a
1413
+ // problem: the engine may be perfectly fine and it is this check that is broken.
1414
+ info(`Could not compare this machine against what the engine last recorded (${e?.message ?? e}). `
1415
+ + `That comparison is what catches a settings page reading healthy while a search will not start, `
1416
+ + `so this run has not checked it either way.`);
1417
+ }
1418
+
1173
1419
  // item 5 — WHICH BILLING LANE, reported rather than left to be inferred from a variable's
1174
1420
  // absence. `--check` named the engine and its binary and never said how the box pays, so the two
1175
1421
  // states that matter — metered per token, or drawn against a subscription — were indistinguishable
@@ -1366,7 +1612,7 @@ export async function runCheck() {
1366
1612
  // Both lines were honest about their own source and neither said what it was. The first reads this
1367
1613
  // command's env file; the second reads THIS PROCESS's resolution, and a CLI is started by a login
1368
1614
  // shell that carries none of the units' `EnvironmentFile`. The deployment was correct and served
1369
- // zephyr, aurora and generic throughout.
1615
+ // the configured roster throughout.
1370
1616
  //
1371
1617
  // THE UNITS' ENV IS NOT PASSED INTO `profileStoreResolution`, deliberately. `PROFILES_OVERLAY_DIR`
1372
1618
  // is captured when profiles.mjs LOADS, so handing it the units' value would set `live` with no
@@ -1418,17 +1664,38 @@ export async function runCheck() {
1418
1664
  const demo = keys.filter((k) => roster.get(k)?.demoData === true);
1419
1665
  // `generic` is the universal fallback the module requires by name, not a brand owner somebody
1420
1666
  // onboarded — counting it would tell an operator with an empty store that they have one.
1421
- const owners = keys.filter((k) => k !== "generic");
1667
+ //
1668
+ // A DEMO ACCOUNT IS NOT AN ONBOARDED OWNER EITHER, and until this line it was counted as one. A
1669
+ // fresh install used to ship the demo account into every roster, so `doctor` reported "1 brand
1670
+ // owner(s) resolve here: demo-brand-owner (DEMO DATA)" on a machine where nobody had onboarded
1671
+ // anything — and never named `generic`, which is the account that actually rates a run there.
1672
+ // The reader is told they have a customer and not told what they are running on. Both halves
1673
+ // wrong from one list.
1674
+ //
1675
+ // SINCE 2026-09-08 A FRESH INSTALL RESOLVES `generic` ALONE (owner ruling): nobody should have to
1676
+ // clean demo material out of an environment they just created. So the demo branch below no longer
1677
+ // fires on a plain install — it fires inside the demo, which asks for its own account. It is kept
1678
+ // rather than deleted because it is still reachable, and a reader who meets the demo account
1679
+ // there is owed the same two facts: it is fiction, and a real clearance under it is refused at
1680
+ // the admission wall.
1681
+ //
1682
+ // Three states, told apart, because they mean three different things to whoever is reading:
1683
+ // an onboarded roster, the house default alone, and the house default beside what the demo
1684
+ // brought with it.
1685
+ const owners = keys.filter((k) => k !== "generic" && !demo.includes(k));
1422
1686
  if (!owners.length) {
1423
- info(`no brand owners resolve here only the \`generic\` fallback. An empty store is a working `
1424
- + `install on Generic defaults; it is also what a store pointed at the wrong directory looks like`);
1687
+ const base = "`generic` is the account this install rates under the house default, and the "
1688
+ + "only one a clean install has";
1689
+ if (demo.length) {
1690
+ info(`${base}. The demo brought one with it, marked DEMO DATA: ${demo.join(", ")} — fiction `
1691
+ + "rather than an account anybody onboarded, and a real clearance under one is refused");
1692
+ } else {
1693
+ info(`${base}. An empty store is a working install on Generic defaults; it is also what a `
1694
+ + "store pointed at the wrong directory looks like");
1695
+ }
1425
1696
  } else {
1426
- const marked = owners.map((k) => (demo.includes(k) ? `${k} (DEMO DATA)` : k)).join(", ");
1427
- const line = `${owners.length} brand owner(s) resolve here: ${marked}`;
1428
- // The demo marker is the member-level half: naming the store is not the
1429
- // same as saying the accounts in it are fiction, and a real clearance under one is refused at
1430
- // the admission wall — which an operator should learn here rather than from that refusal.
1431
- if (demo.length) info(`${line} — accounts marked DEMO DATA cannot start a real clearance`);
1697
+ const line = `${owners.length} brand owner(s) resolve here: ${owners.join(", ")}`;
1698
+ if (demo.length) info(`${line}. The demo brought one with it, marked DEMO DATA: ${demo.join(", ")} — not counted above, and a real clearance under one is refused`);
1432
1699
  else ok(line);
1433
1700
  }
1434
1701
  try {
@@ -2118,7 +2385,7 @@ export async function runCheck() {
2118
2385
  //
2119
2386
  // `describeDoorState` was written for exactly this block and then never called from it: the split
2120
2387
  // it encodes sat in `shared/client-door.mjs` with no caller in `bin/` or `driver/`, while doctor
2121
- // 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
2122
2389
  // them — a `connect` that died at `daemon-reload` had already written the fence and placed both
2123
2390
  // units, and doctor said "the client door is on" over a unit that was inactive with nothing on its
2124
2391
  // port. Every angle read as configured, because configured is all anything asked.
@@ -2310,6 +2577,27 @@ export async function runCheck() {
2310
2577
  // unreachable; repeating it as a second failure teaches the reader that this section
2311
2578
  // double-counts. It still states what was NOT established, which is the whole job.
2312
2579
  else if (v.kind === "could-not-look") info(v.message);
2580
+ // NO ACCESS IN FRONT OF THE CLIENT DOOR IS A POSTURE, NOT A FAULT (owner ruling 2026-09-08).
2581
+ //
2582
+ // This is the CLIENT connector's address, and how a client reaches it is the client's decision:
2583
+ // "client access sitting behind OAuth is totally up to a client — plenty might just run it token
2584
+ // based on their own laptop." A door answering with its own Bearer challenge and no Access front
2585
+ // is a supported shape, not a misconfiguration, so raising it made `doctor` exit 1 on a healthy
2586
+ // deployment and taught its reader to skim the one command that must never be skimmed.
2587
+ //
2588
+ // It is still SAID rather than dropped, and it still states what was not established: an
2589
+ // audience that was never compared is not an audience that agreed. What changed is that the
2590
+ // sentence no longer calls a client's own arrangement a finding about this install.
2591
+ //
2592
+ // Scoped deliberately to `not-fronted` on THIS address. A configured audience that DISAGREES
2593
+ // with the one the edge issues is still a fault, and the portal and ops surfaces are untouched —
2594
+ // they are checked elsewhere and Access in front of them is not optional.
2595
+ else if (v.kind === "not-fronted") {
2596
+ info(`nothing is fronting this hostname with Access — ${read.why}. That is this client door's `
2597
+ + "posture rather than a fault: a client may reach it with its own token and no Access in "
2598
+ + "front, which is a supported shape. The configured audience was not compared against this "
2599
+ + "address, so nothing here says the two agree.");
2600
+ }
2313
2601
  else problem(v.message);
2314
2602
  }
2315
2603
  }
@@ -2534,13 +2822,13 @@ try {
2534
2822
 
2535
2823
  // 1 ── Node
2536
2824
  say(" Node");
2537
- const major = Number(process.versions.node.split(".")[0]);
2538
- if (major < NODE_FLOOR) {
2539
- problem(`node ${process.versions.node} — this engine needs >= ${NODE_FLOOR}. Upgrade Node and run setup again.`);
2825
+ const nodeV = nodeFloorVerdict();
2826
+ if (!nodeV.ok) {
2827
+ problem(`node ${nodeV.current} — this engine needs ${nodeV.required} or newer. Upgrade Node and run setup again.`);
2540
2828
  aborted = "node";
2541
2829
  throw new Error("node floor");
2542
2830
  }
2543
- ok(`node ${process.versions.node}`);
2831
+ ok(`node ${nodeV.current}`);
2544
2832
 
2545
2833
  // 2 ── the engine: which one, which binary, and PROOF that it can run a turn
2546
2834
  //
@@ -2570,7 +2858,32 @@ try {
2570
2858
  if (!pick.id) { sayNoEngine(); break; }
2571
2859
  const eng = ENGINE_BINARIES[pick.id];
2572
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
+
2573
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);
2574
2887
  if (!(bin.executable && !bin.relative) && eng.install) {
2575
2888
  // ── — INSTALLING IT IS ONE COMMAND, AND WE USED TO STOP AT A SENTENCE ───────────────────
2576
2889
  //
@@ -2769,7 +3082,20 @@ try {
2769
3082
  break engine;
2770
3083
  }
2771
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.
2772
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})`);
2773
3099
  // — THE HAND-OFF. Signing in is the one step of this sequence nobody here can perform for
2774
3100
  // someone, so the wizard names the command, waits, and re-probes rather than ending at a
2775
3101
  // description of what is wrong. The text comes from ENGINE_BINARIES so the two adapters cannot
@@ -3175,6 +3501,22 @@ try {
3175
3501
  say(" fallback — falls through by name, so an empty store is a working install. Your own customers");
3176
3502
  say(" are added here by name; the bundled demo customers never show through into your roster.");
3177
3503
 
3504
+ // 7c ── WHO SIGNS IN, AND WHAT THAT ADDRESS GRANTS
3505
+ //
3506
+ // Setup never asked for an address, so `clearotron start` derived one — and derived a staff-domain
3507
+ // rule from everything after its `@`. The whole reasoning, and the install that granted a
3508
+ // documentation domain to a reader who had never been asked, is on `askSignIn` above.
3509
+ section("Who signs in");
3510
+ const localAccount = (() => {
3511
+ try { return userInfo().username || "user"; } catch { return "user"; }
3512
+ })();
3513
+ // ONE CALL SITE. The loop itself lives in `askSignIn` so that the branch that matters — a reader who
3514
+ // pressed Enter at the grant question, and a staff rule that must therefore NOT be written — is
3515
+ // reachable without a terminal. The same seam and the same reason as `offerUsptoSync`.
3516
+ Object.assign(candidate, await askSignIn(
3517
+ { askValue, confirm, say, ok, info, warn, problem },
3518
+ { localAccount, staffLabel: `${BRAND.name} staff`, envPath: ENV_PATH }));
3519
+
3178
3520
  // 8 ── the engine's own preflight over the whole candidate
3179
3521
  //
3180
3522
  // — THIS STEP CHECKS THE REGISTER PROVIDER AND NOTHING ELSE. `preflightCandidate` pins