clearotron 0.3.0-beta.1 → 0.3.0-beta.2

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 (63) hide show
  1. package/.env.example +13 -2
  2. package/CONTRIBUTING.md +1 -1
  3. package/INSTALL.md +12 -10
  4. package/README.md +3 -2
  5. package/bin/brandowner.mjs +94 -1
  6. package/bin/onboard.mjs +156 -55
  7. package/bin/passphrase.mjs +23 -4
  8. package/bin/start.mjs +177 -38
  9. package/build-info.json +2 -2
  10. package/docs/architecture/04-configuration-reference.md +5 -2
  11. package/docs/architecture/05-config-governance.md +3 -2
  12. package/driver/CHANGELOG.md +95 -0
  13. package/driver/demo-posture.mjs +59 -8
  14. package/driver/driver.config.mjs +36 -7
  15. package/driver/engine/child-record.mjs +93 -0
  16. package/driver/findings-model.mjs +6 -1
  17. package/driver/knockout-assess-record.mjs +6 -3
  18. package/driver/package.json +1 -1
  19. package/driver/portal-local-auth.mjs +98 -3
  20. package/driver/portal-service.mjs +65 -29
  21. package/driver/publish/knockout.mjs +12 -1
  22. package/driver/publish/office-record-links.mjs +61 -10
  23. package/driver/publish/render-knockout.mjs +31 -11
  24. package/driver/publish/xlsx.mjs +33 -1
  25. package/driver/register-records.mjs +6 -0
  26. package/driver/run-requirements.mjs +24 -1
  27. package/driver/runner.mjs +10 -5
  28. package/driver/skills/knockout-assess/SKILL.md +1 -1
  29. package/driver/suite-census.json +174 -30
  30. package/driver/unit-inventory.mjs +109 -5
  31. package/driver/updater-identity.mjs +178 -0
  32. package/driver/usage-ledger.mjs +5 -5
  33. package/mcp-server/CHANGELOG.md +6 -0
  34. package/mcp-server/http-server.mjs +16 -13
  35. package/mcp-server/lib/driver.mjs +7 -0
  36. package/mcp-server/lib/knockout.mjs +14 -2
  37. package/mcp-server/lib/ops.mjs +38 -16
  38. package/mcp-server/package.json +2 -2
  39. package/mcp-server/server.mjs +1 -1
  40. package/package.json +1 -1
  41. package/portal-ui/dist/assets/{index-CWTHP0sH.js → index-CcFjgM78.js} +32 -17
  42. package/portal-ui/dist/assets/{index-KpytsmNH.css → index-CsCuPshD.css} +7 -2
  43. package/portal-ui/dist/index.html +2 -2
  44. package/portal-ui/package.json +3 -3
  45. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  46. package/providers/oauth-mcp-bridge/package.json +2 -2
  47. package/scripts/changelog-plain-language.mjs +7 -30
  48. package/scripts/e2e.mjs +13 -2
  49. package/scripts/env-audit.mjs +10 -3
  50. package/scripts/env-classify.mjs +205 -12
  51. package/scripts/live-surface-check.mjs +74 -2
  52. package/scripts/plain-language-rules.mjs +103 -0
  53. package/scripts/release-note-required.mjs +118 -24
  54. package/scripts/release-notes-lint.mjs +22 -43
  55. package/scripts/release-version.mjs +59 -0
  56. package/scripts/revisit-render-check.mjs +6 -2
  57. package/scripts/text-difference.mjs +22 -0
  58. package/shared/env-local.mjs +25 -2
  59. package/shared/names-in-force.mjs +1 -0
  60. package/shared/reap-on-exit.mjs +27 -14
  61. package/shared/store-in-repo.mjs +147 -0
  62. package/shared/withheld-paths-access.mjs +6 -6
  63. package/shared/yes-no-echo.mjs +35 -0
package/.env.example CHANGED
@@ -388,6 +388,17 @@ CLEAROTRON_SUITE_TELEMETRY_DIR=
388
388
  # effect: deployment
389
389
  CLEAROTRON_DEMO=
390
390
 
391
+ # How the reader typed this command, handed from the dispatcher to each verb it runs. `clearotron`
392
+ # runs every verb as a process of its own, so a verb's own argv[1] is `bin/<verb>.mjs` however the
393
+ # reader started it. The dispatcher passes its own argv[1] down in this name, and every command a verb
394
+ # prints for the reader to type next is spelled from it: `clearotron …` after a global install,
395
+ # `npx clearotron …` otherwise. Unset, a verb reads its own argv[1].
396
+ # Set by bin/clearotron.mjs on every verb it runs, never by an operator, so the row is left commented.
397
+ # It is listed for the reason CLEAROTRON_DEMO is: the catalogue is about what an operator can FIND.
398
+ # Read by shared/invocation.mjs.
399
+ # effect: deployment
400
+ # CLEAROTRON_INVOKED_AS=
401
+
391
402
  # How many client what-ifs the runner drains at once (default 1). A what-if re-runs ONE stage of a run
392
403
  # that already exists, in a sandbox, at a client's request — the owner opened it to clients on
393
404
  # 2026-08-27 and ruled SPEND controls out with it ("ignore the call spend"). This is not one.
@@ -538,8 +549,8 @@ CLEAROTRON_DEMO_PROFILES=
538
549
 
539
550
  # ── The organisation setup names ──────────────────────────────────────────────────────────────────
540
551
  #
541
- # Your organisation's name. `npm run setup` asks for it directly after the sign-in address and writes it
542
- # here, quoted, so a name holding a `#` survives. The first `clearotron start` files it as the first
552
+ # Your organisation's name. `npm run setup` asks for it (and, on a local install, not for a sign-in
553
+ # address) and writes it here, quoted, so a name holding a `#` survives. The first `clearotron start` files it as the first
543
554
  # organisation in the grants file (CLEAROTRON_ACCESS_FILE) when that file holds none; from then on the
544
555
  # grants file holds the name, renaming is an edit there, and this is not read. Unset or empty, no
545
556
  # organisation is invented. `clearotron start --organisation <name>` supplies it for one start, and a
package/CONTRIBUTING.md CHANGED
@@ -12,7 +12,7 @@ the rest — with the file that owns each.
12
12
 
13
13
  ## What you can run, with nothing but a clone
14
14
 
15
- **Node 22 is a hard floor.** `package.json` declares it, `.nvmrc` pins it, and the free US register
15
+ **Node 22.13 is a hard floor.** `package.json` declares it, `.nvmrc` pins it, and the free US register
16
16
  runs on `node:sqlite`. Node 20 will fail in ways that look like your change.
17
17
 
18
18
  ```bash
package/INSTALL.md CHANGED
@@ -48,7 +48,8 @@ run is [mcp-server/CONNECT.md](mcp-server/CONNECT.md), and why something is the
48
48
  - **macOS, Linux, or native Windows for the demo; WSL2 for a clearance.** `npx clearotron
49
49
  demo` runs anywhere Node does, native Windows included. A real clearance does not: the engine resolves
50
50
  the reasoning CLI the POSIX way, so a native-Windows clearance refuses at preflight even with the CLI
51
- on `PATH`. On Windows, `wsl --install -d Ubuntu`, then `wsl -d Ubuntu`, and work through this page
51
+ on `PATH`. Native Windows clearances are planned for a later release. Until then, on Windows,
52
+ `wsl --install -d Ubuntu`, then `wsl -d Ubuntu`, and work through this page
52
53
  from **inside** that distribution. Name it: plain `wsl` can open a minimal image with no apt, no
53
54
  curl and no bash, and everything below assumes Ubuntu. A fresh Ubuntu has no Node at all, and
54
55
  apt's package is below the floor above, so `npx` answers "not found" before anything of ours runs.
@@ -732,9 +733,10 @@ Use the demo to see what this system produces. Use `npx clearotron start` to run
732
733
  - Creates `~/trademark/` — `pool/`, `workspace/`, `queue/`, `outbox/`, `locks/`, an empty grants file,
733
734
  and a small git repository for saved searches. Same base directory `npx clearotron install` uses, so whichever
734
735
  of the two you ran first, the other finds the same install. Move it with `npx clearotron start --base <dir>`.
735
- - Mints your sign-in passphrase and **prints it once**. Write it down. It is stored as a scrypt digest,
736
- nothing can read it back, and no later start reprints it. To get a new one, delete
737
- `~/.cordillera/portal-local-credential.json` and start again.
736
+ - Mints your sign-in passphrase and **prints it once**. Write it down. It is stored as a scrypt digest in
737
+ `~/trademark/portal-local-credential.json`, nothing can read it back, and no later start reprints it. To
738
+ get a new one, run `clearotron passphrase --reset`. An install that has been signing in with
739
+ `~/.cordillera/portal-local-credential.json`, which earlier versions shared between installs, keeps it.
738
740
 
739
741
  ### Who you are
740
742
 
@@ -744,12 +746,12 @@ You sign in as `<your-username>@localhost` unless you say otherwise:
744
746
  npx clearotron start --user you@example.com
745
747
  ```
746
748
 
747
- The address is written to `.env`, so it is asked for once. It is also the first person on this
748
- install: the first start writes it into the grants file (`CLEAROTRON_ACCESS_FILE`, §8) with access to
749
- everything and both permissions, Run clearances and Manage. Setup asks for your organisation's name
750
- directly after the address, and the same start files it there as your first organisation. The address
751
- admits nobody else at its domain; enrolling anyone else is that same file, exactly as on a hosted
752
- instance.
749
+ Setup does not ask for the address: it writes the local-account form to `.env` and shows it once, in
750
+ its summary, as the address that signs in. An address already in `.env` is kept. It is also the first
751
+ person on this install: the first start writes it into the grants file (`CLEAROTRON_ACCESS_FILE`, §8)
752
+ with access to everything and both permissions, Run clearances and Manage. Setup asks for your
753
+ organisation's name, and the same start files it there as your first organisation. The address admits
754
+ nobody else at its domain; enrolling anyone else is that same file, exactly as on a hosted instance.
753
755
 
754
756
  **No authentication is switched off to make this work, and none can be.** Both doors prove who the
755
757
  caller is — the portal by passphrase and a signed session cookie, the engine door by a mandatory
package/README.md CHANGED
@@ -36,8 +36,9 @@ npm install -g clearotron
36
36
  ```
37
37
 
38
38
  Node 22.13 or newer, on macOS or Linux. That puts `clearotron` on your `PATH`; every command below
39
- works in that short form. **On Windows the demo above runs natively; a real clearance needs WSL2** —
40
- the engine resolves the reasoning CLI the POSIX way and refuses at preflight otherwise.
39
+ works in that short form. **On Windows the demo above runs natively; a real clearance needs WSL2.**
40
+ Native Windows clearances are planned for a later release. Until then the engine does not run on native
41
+ Windows: it resolves the reasoning CLI the POSIX way, and a clearance started there refuses at preflight.
41
42
 
42
43
  That command gives you the **stable** release — the one that has run a real clearance end to end before it
43
44
  was published. If you want the newest code instead, a beta is published whenever there is something worth
@@ -119,6 +119,16 @@ const USAGE = `
119
119
  --context a file whose contents become this owner's context pack
120
120
  --dry-run say exactly what would be written, and write nothing
121
121
 
122
+ clearotron brandowner framework <key> <path>
123
+
124
+ Point an existing company at a risk framework, as skills/prelim-search/<file>.md.
125
+ The deck is checked before anything is written: a path that does not resolve, or a
126
+ manifest that will not load, is refused and the company is left exactly as it was.
127
+
128
+ This is the route a company created in the browser gets a framework by. The browser
129
+ deliberately sets no framework — frameworkPath is code-owned and the profile screen
130
+ shows it read-only — so without this verb there was no supported route at all.
131
+
122
132
  Exit codes: 0 written and recorded · 1 refused, nothing written · 2 usage
123
133
  3 written but NOT recorded — the bundle is written and the store has no record of it
124
134
  `;
@@ -266,11 +276,94 @@ export async function add(argv, {
266
276
  return { written: true, store, profile, framework, commit, commitError };
267
277
  }
268
278
 
279
+ /**
280
+ * Point an existing company at a risk framework.
281
+ *
282
+ * THE LINT IS `resolveFramework`, NOT A SECOND OPINION. That function already refuses a path of the
283
+ * wrong shape, one that does not resolve on this install, and one whose manifest will not load — in the
284
+ * words the create path uses. A second check here would be a second set of messages to keep in step, and
285
+ * the one this file could write is the one that would go stale.
286
+ *
287
+ * IT REFUSES BEFORE IT WRITES. A framework somebody chose that does not resolve is a mistake, not an
288
+ * absence, and the company is left exactly as it was — which matters more here than on a create, because
289
+ * there is an existing company whose rating this would change.
290
+ *
291
+ * THE WHOLE PROFILE IS REWRITTEN, one field changed. `defaultWriteProfile` is the one writer, so this
292
+ * cannot invent a second shape of profile file; the read-modify-write is deliberate and the alternative
293
+ * — patching a key in place — is how two writers come to disagree about what a bundle contains.
294
+ */
295
+ export async function framework(argv, {
296
+ resolution = profileStoreResolution(),
297
+ out = console.log,
298
+ } = {}) {
299
+ const [key, path] = argv;
300
+ if (!key) throw new Refusal(`this command needs a company key.${USAGE}`);
301
+ if (!path) throw new Refusal(`this command needs a framework path, as skills/prelim-search/<file>.md.${USAGE}`);
302
+ try { assertProfileKey(key); }
303
+ catch (e) { throw new Refusal(e?.message ?? String(e)); }
304
+
305
+ const store = storeForAdd(resolution);
306
+ const file = join(store, `${key}.json`);
307
+ // AN ABSENT COMPANY IS NAMED, not created. `add` is the verb that creates; a typo here would
308
+ // otherwise write a bundle carrying nothing but a framework path.
309
+ if (!existsSync(file))
310
+ throw new Refusal(`no company "${key}" in ${store} — "brandowner add ${key} --name ..." creates one.`);
311
+
312
+ // Refuses here, before anything is read for writing.
313
+ const resolved = resolveFramework(path);
314
+
315
+ const profile = JSON.parse(readFileSync(file, "utf8"));
316
+ const was = profile.frameworkPath ?? null;
317
+ if (was === resolved.path) {
318
+ out(` ${key} already rates under ${resolved.path} — nothing to change.`);
319
+ return { written: false, store, framework: resolved };
320
+ }
321
+ // THE CONTEXT PACK IS READ AND HANDED BACK, because omitting it is not "leave it alone".
322
+ // `defaultWriteProfile` reads an absent pack as "this company has none" and REMOVES the sibling file.
323
+ // `add` never meets that branch: it always passes the pack it was given. This verb is the first caller
324
+ // that rewrites a company which already exists, so it is the first one that can reach it — and setting
325
+ // a framework would have deleted the company's context pack, and committed the deletion under a
326
+ // message about the framework. Found in review, driven before the fix: a store holding acme.json and
327
+ // acme.context.md kept only acme.json.
328
+ //
329
+ // Re-writing the pack is deliberate rather than clever. It lands in the commit's file list, git sees
330
+ // no change in a pack that was already stored the way this writer stores it, and the commit still
331
+ // carries only the profile. NOT byte-for-byte in every case: `defaultWriteProfile` trims and ends
332
+ // with one newline, so a pack hand-edited with blank lines around its text comes back without them.
333
+ // The words are untouched, which is what the arm below checks. A pack holding nothing but whitespace
334
+ // is still removed, which is what every other reader of this store already means by an empty pack.
335
+ const packPath = join(store, CONTEXT_PACK_FILE(key));
336
+ const contextPack = existsSync(packPath) ? readFileSync(packPath, "utf8") : "";
337
+ const { files } = defaultWriteProfile({
338
+ profileDir: store, key, profile: { ...profile, frameworkPath: resolved.path }, contextPack,
339
+ });
340
+
341
+ const repoRoot = resolveStoreRepoRoot({ names: ["CLEAROTRON_CUSTOMERS_DIR"], fallback: store }).root;
342
+ const audit = makeCommittableAudit({ auditPath: join(store, "audit.jsonl"), repoRoot });
343
+ const gitCommit = makeStoreCommit({ repoRoot, what: "customers" });
344
+ let by = "unknown";
345
+ try { by = userInfo().username || "unknown"; } catch { /* no passwd entry — the row still gets written */ }
346
+ const { commit, commitError } = commitWithAuditRow({
347
+ audit, gitCommit, files, by,
348
+ message: `company ${key} rates under ${resolved.path}${was ? ` (was ${was})` : ""}`,
349
+ });
350
+
351
+ out(` ${key} now rates under ${resolved.path}${was ? ` — was ${was}` : " — it had none of its own"}.`);
352
+ if (commit) out(` recorded ${commit}`);
353
+ if (commitError) out(` WROTE THE CHANGE BUT DID NOT RECORD IT: ${commitError} — the audit line is on disk; fix the store's git state`);
354
+ return { written: true, store, framework: resolved, was, commit, commitError };
355
+ }
356
+
269
357
  async function main() {
270
358
  const [sub, ...rest] = process.argv.slice(2);
271
359
  if (!sub || sub === "--help" || sub === "-h" || sub === "help") { console.log(USAGE); process.exit(sub ? 0 : 1); }
272
- if (sub !== "add") { console.error(`brandowner: no such action "${sub}". One of: add`); process.exit(2); }
360
+ if (sub !== "add" && sub !== "framework") { console.error(`brandowner: no such action "${sub}". One of: add, framework`); process.exit(2); }
273
361
  try {
362
+ if (sub === "framework") {
363
+ const r = await framework(rest);
364
+ if (r?.commitError) process.exit(3);
365
+ return;
366
+ }
274
367
  // WROTE-BUT-DID-NOT-RECORD IS NOT SUCCESS. The write stands — rolling a written bundle back
275
368
  // would lose the operator's work over a git fault they can fix — but a scripted onboarding
276
369
  // reading exit 0 concludes the store recorded it, and the store did not. Exit 3 says both
package/bin/onboard.mjs CHANGED
@@ -72,11 +72,11 @@ import { styleFor, banner } from "../shared/tty-style.mjs"; // — weight wher
72
72
  import { bracketAsciiCells, BRAND } from "../shared/brand.mjs"; // F18 — the mark, from the geometry the SVG already uses
73
73
  // THE REFUSALS ABOUT THE SIGN-IN ADDRESS ITSELF, shared with `bin/start.mjs`. Two copies would be a
74
74
  // wizard that accepts an address the launcher then sends back.
75
- import { addressRefusal } from "../shared/staff-domain.mjs";
76
75
  import { join, dirname, isAbsolute, resolve } from "node:path";
77
76
  import { fileURLToPath, pathToFileURL } from "node:url";
78
77
  import { delimiter } from "node:path";
79
78
  import { Writable } from "node:stream";
79
+ import { looksLikeAKey, yesNoEchoOf } from "../shared/yes-no-echo.mjs"; // a yes/no question echoes only a yes/no answer
80
80
 
81
81
  import { envLocalPath, activeEnvPath, loadEnvLocal, LEGACY_ENV_LOCAL_LOCATION } from "../shared/env-local.mjs";
82
82
  import { writeSecretFile } from "../shared/secret-file.mjs"; // one atomic write for every file holding credentials
@@ -97,6 +97,7 @@ import { processTable } from "../shared/process-table.mjs"; // — /proc is no
97
97
  import { programsFromAnotherCheckout } from "../shared/checkout-move.mjs";
98
98
  import { entrypointOf } from "../driver/systemd/install-census.mjs"; // one ExecStart parser
99
99
  import { overlayReport, renderOverlayReport } from "../shared/doctrine-overlay.mjs"; // — the doctor reports the overlay
100
+ import { whereSavesGo } from "../shared/store-in-repo.mjs"; // — doctor says where a portal save goes once it is committed
100
101
  import { engineInventory, engineMode, ENGINE_MODES } from "../driver/config-inventory.mjs"; //
101
102
  import { probeEngineTurn, probeFailureText, PROBE_MODEL, PROBE_TIMEOUT_SEC, engineEnvKeys } from "../driver/engine/probe.mjs";
102
103
  import { runRequiredNames, missingRequirements, REGISTER_ENV, ENGINE_ENV } from "../driver/run-requirements.mjs"; // the order-time gate's own question, asked here rather than restated
@@ -594,13 +595,17 @@ const present = (v) => typeof v === "string" && v.trim() !== "";
594
595
  /**
595
596
  * Ask who signs in, and what their organisation is called.
596
597
  *
597
- * ── WHY THE ADDRESS IS ASKED AT ALL ─────────────────────────────────────────────────────────────────
598
+ * ── THE ADDRESS IS NOT ASKED (ruling, 2026-09-10) ──────────────────────────────────────────────────
598
599
  *
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`. An outside install reached a
601
- * state nobody chose that way: an assistant filled a documentation address into the environment file on
602
- * the operator's behalf, because this wizard had no prompt for it. So the address is asked for here,
603
- * where a person is definitionally present.
600
+ * It was, for a while. `clearotron start` took an address from `--user`, from `PORTAL_LOCAL_USER`, or
601
+ * from the local account as `<account>@localhost`, and an outside install reached a state nobody chose
602
+ * that way: an assistant filled a documentation address into the environment file on the operator's
603
+ * behalf. On a local install the question buys nothing the local-account form does not give, so it is
604
+ * gone: the address is `<account>@localhost`, used without a question and shown once, in the summary's
605
+ * "signs in as" line. Anyone behind a login system changes it in the grants file.
606
+ *
607
+ * AN ADDRESS THE SETTINGS FILE ALREADY HOLDS IS KEPT. A re-run of setup must not sign out the person an
608
+ * earlier run enrolled: their passphrase and their grants entry are both keyed on that address.
604
609
  *
605
610
  * WHAT IT NO LONGER DECIDES. The part after its `@` used to decide who was an administrator: everyone at
606
611
  * that domain was staff. That rule is deleted, with the question about granting the domain and the
@@ -608,47 +613,28 @@ const present = (v) => typeof v === "string" && v.trim() !== "";
608
613
  * written as their own entry in the grants file by `clearotron start` (`installerGrants`), and it admits
609
614
  * nobody else whatever its domain.
610
615
  *
611
- * TWO REFUSALS ABOUT THE ADDRESS ITSELF STAY: not a single email address, and a public or reserved
612
- * domain (shared/staff-domain.mjs, shared with `clearotron start` so the two refuse the same addresses).
616
+ * THE TWO REFUSALS ABOUT THE ADDRESS ITSELF are `clearotron start`'s (shared/staff-domain.mjs): not a
617
+ * single email address, and a public or reserved domain. The wizard no longer takes an address to refuse.
613
618
  *
614
- * THE ORGANISATION'S NAME, directly after the address and required. It is the organisation the install
619
+ * THE ORGANISATION'S NAME, the one question this step asks, and required. It is the organisation the install
615
620
  * starts with and the label the portal shows for it; `clearotron start` files it in the grants file the
616
621
  * first time that file holds none, and it is renamed there. WRITTEN QUOTED, because the loader reads
617
622
  * `.env` with Node's `parseEnv`, which ends an unquoted value at `#` and trims it — measured: "Café #1
618
623
  * Sàrl" written bare comes back "Café". Quoted it survives whole, except a double quote, which ends the
619
624
  * value, so a name holding one is sent back.
620
625
  *
621
- * THE DEFAULT IS THE LOCAL ACCOUNT AND NOTHING ELSE — not the git author, not the hostname, not a shell
622
- * variable. The address that becomes the first person comes from the person, or from the one source that
623
- * cannot name a second person.
626
+ * THE ADDRESS IS THE LOCAL ACCOUNT AND NOTHING ELSE — not the git author, not the hostname, not a shell
627
+ * variable: the one source that cannot name a second person.
624
628
  *
625
629
  * `io` IS INJECTED for the reason `offerUsptoSync`'s is: the branches that matter are the answers that
626
630
  * must be sent back, and closed over a terminal they are asserted nowhere. Returns the `.env` keys to
627
631
  * write and nothing else.
628
632
  */
629
- export async function askSignIn(io, { localAccount = "user" } = {}) {
633
+ export async function askSignIn(io, { localAccount = "user", existing = null } = {}) {
630
634
  const { askValue, ok = () => {}, problem = () => {} } = io;
631
- const localDefault = `${localAccount}@localhost`;
632
- prose("The portal admits one address on this install, and that address is the first person on it:",
633
- "access to everything, with both permissions — Run clearances and Manage. Enter accepts the",
634
- "local-account form, which is this machine and nobody else.");
635
- let address;
636
- for (;;) {
637
- const typed = String(await askValue("Sign-in address:", { def: localDefault })).trim().toLowerCase();
638
- if (!typed.includes("@") || typed.indexOf("@") !== typed.lastIndexOf("@")) {
639
- problem(`"${typed}" is not a single email address. The portal refuses a multi-@ identity outright, `
640
- + "so this would sign in and then be denied at the door.");
641
- continue;
642
- }
643
- // The refusal's own sentence, never a second copy: `clearotron start` prints these same words when it
644
- // meets the same address, and two wordings of one refusal is how a reader comes to believe they have
645
- // met two different problems.
646
- const refusal = addressRefusal(typed);
647
- if (refusal) { problem(refusal); continue; }
648
- address = typed;
649
- break;
650
- }
651
- ok(`${address} — the first person on this install, with access to everything.`);
635
+ // Returned rather than left to the writer to carry, so the address in force is written whichever file
636
+ // it was read from.
637
+ const address = String(existing ?? "").trim() || `${localAccount}@localhost`;
652
638
  for (;;) {
653
639
  const name = String(await askValue("Your organisation's name:") ?? "").trim();
654
640
  if (!name) {
@@ -1770,6 +1756,47 @@ export async function runCheck() {
1770
1756
  } catch (e) {
1771
1757
  info(`could not resolve the profile store from here (${String(e?.message ?? e).slice(0, 80)}) — read a run's \`profile-store\` line instead`);
1772
1758
  }
1759
+
1760
+ // ── AND WHERE A SAVE GOES ONCE IT IS COMMITTED ─────────────────────────────────────────────────────
1761
+ //
1762
+ // A save through the portal commits into the store's repository and stops there. Whether it then
1763
+ // leaves this machine is a property of that repository, answered by `whereSavesGo` beside the
1764
+ // committer, which carries the measurement: a store inside a checkout that tracks a remote branch has
1765
+ // its saves published by the next person who syncs or pushes that checkout.
1766
+ //
1767
+ // `warn`, NEVER `problem`, and the exit contract near the top of this file is why. rc 1 means
1768
+ // something the operator set is not doing what they think it is. A store in a repository they push is
1769
+ // doing exactly what it was set up to do; the danger is somebody else's ordinary git work in the same
1770
+ // checkout, which this command cannot see. So the line says it, says when it is fine, and leaves the
1771
+ // exit status alone.
1772
+ //
1773
+ // THE SERVICES' STORE on a hosted box, for the reason the roster lines above give. When their
1774
+ // environment could not be read nothing is judged here, and the lines above already said so.
1775
+ {
1776
+ const storeSet = hosted ? (serviceKnown ? effectiveForService("CLEAROTRON_CUSTOMERS_DIR") : null) : effective("CLEAROTRON_CUSTOMERS_DIR");
1777
+ const storeDir = storeSet?.v || null;
1778
+ if (storeDir && existsSync(storeDir)) {
1779
+ const w = whereSavesGo(storeDir);
1780
+ if (w.state === "publishes") {
1781
+ const waiting = w.ahead > 0 ? `, and ${w.ahead} commit(s) made there have not been pushed yet` : "";
1782
+ const how = w.via && w.via !== "tracking"
1783
+ ? `whose branch ${w.branch} tracks no remote branch, but a bare \`git push\` there sends it to ${w.upstream} (${w.via})`
1784
+ : `whose branch ${w.branch} tracks ${w.upstream}`;
1785
+ warn(`saves to ${storeDir} are committed in ${w.root}, ${how} — `
1786
+ + `whoever next syncs or pushes that checkout publishes them${waiting}. Fine if you publish this store `
1787
+ + "on purpose; if other work happens in that checkout, give the store a repository of its own with no remote");
1788
+ } else if (w.state === "stays-here") {
1789
+ ok(`saves to ${storeDir} are committed in ${w.root}, `
1790
+ + (w.branch ? `whose branch ${w.branch} tracks no remote branch` : "which is not on a branch")
1791
+ + " — nothing carries them off this machine unless someone pushes them by name");
1792
+ } else if (w.state === "not-a-repository") {
1793
+ warn(`${storeDir} is not inside a git repository, so a save through the portal is refused rather than `
1794
+ + "recorded until it is");
1795
+ } else {
1796
+ info(`could not ask git where saves to ${storeDir} go (${w.why}) — a failure to look, not a finding`);
1797
+ }
1798
+ }
1799
+ }
1773
1800
  }
1774
1801
 
1775
1802
  // — WHICH DOCTRINE FILES THIS INSTALL OVERRIDES, AND WHETHER OURS HAVE MOVED UNDER THEM.
@@ -2259,12 +2286,34 @@ export async function runCheck() {
2259
2286
  // the provider's own capability rather than a number typed here.
2260
2287
  if (PROBE_PROVIDERS) {
2261
2288
  say("\n Register lane — proven, not inferred");
2289
+ // THE PROBE ASKS WITH WHAT THE SECTION ABOVE PRINTED. That section reads the environment first and
2290
+ // this install's env file behind it, and says which: `signa (.env)`. This process does not apply the
2291
+ // file — `doctor` is not a CLI entry in shared/env-local.mjs, because reading is not applying — so the
2292
+ // probe took its provider from a module constant fixed from the shell alone, and its key from the
2293
+ // shell at call time. A provider or key kept only in the file, which is where setup writes them,
2294
+ // came back "not set" or "absent" directly under the line that had just printed it. Measured
2295
+ // 2026-09-10 on a fresh install.
2296
+ //
2297
+ // So the provider is resolved from the same reading, and the provider's own variables, the ones the
2298
+ // section above lists, are placed in this process for the probe and put back after it: the adapter
2299
+ // reads them from the environment when it is called. Nothing else moves.
2300
+ const placed = [];
2262
2301
  try {
2263
2302
  const [{ activeProvider }, { makeLaneProbe, probeSpend, DEFAULT_CONTROLS, loadProviderCapabilities }] = await Promise.all([
2264
2303
  import(pathToFileURL(join(REPO, "driver", "driver.config.mjs")).href),
2265
2304
  import(pathToFileURL(join(REPO, "providers", "_shared", "lane-probe.mjs")).href),
2266
2305
  ]);
2267
- const adapter = activeProvider();
2306
+ const chosen = effective("CLEAROTRON_DATABASE");
2307
+ const adapter = activeProvider(chosen ? { CLEAROTRON_DATABASE: chosen.v } : null);
2308
+ const spec = PROVIDERS.find((p) => p.id === adapter?.id);
2309
+ const names = spec ? [...spec.credentials, ...(spec.optionalCredentials ?? [])]
2310
+ : [adapter?.credEnv, ...(adapter?.credEnvAlso ?? [])].filter(Boolean);
2311
+ for (const k of ["CLEAROTRON_DATABASE", ...names]) {
2312
+ const e = effective(k);
2313
+ if (e?.from !== ".env") continue; // unset, or the environment already carries it
2314
+ placed.push([k, Object.hasOwn(process.env, k), process.env[k]]);
2315
+ process.env[k] = e.v;
2316
+ }
2268
2317
  // NOT `adapter.capabilities` — that is null on every adapter, and reading it announced a LOCAL
2269
2318
  // index as a billable search. The declaration lives in the provider's own module. (issue 1871)
2270
2319
  const caps = await loadProviderCapabilities(REPO, adapter?.id);
@@ -2293,6 +2342,10 @@ export async function runCheck() {
2293
2342
  } catch (e) {
2294
2343
  // A probe that could not run says so. It is not evidence about the credential either way.
2295
2344
  info(`the register lane could not be probed: ${String(e?.message ?? e)}`);
2345
+ } finally {
2346
+ // EVERY ONE PUT BACK, including those that were absent: the sections below read this process's
2347
+ // environment, and must not report a value from the file as though the shell had set it.
2348
+ for (const [k, had, v] of placed) { if (had) process.env[k] = v; else delete process.env[k]; }
2296
2349
  }
2297
2350
 
2298
2351
  const pxKey = effective("PERPLEXITY_API_KEY");
@@ -2766,6 +2819,12 @@ export async function runCheck() {
2766
2819
  // ── CLI gate ─────────────────────────────────────────────────────────────────────────────────────────
2767
2820
  // Everything above is importable: the helpers are what the tests exercise directly, and importing this
2768
2821
  // file must not start a wizard. Same gate driver/dev-portal.mjs uses.
2822
+ // SETUP'S ONE READLINE KEEPS NO HISTORY. Readline remembers recent answers for the up-arrow, and a key
2823
+ // typed at a masked prompt, or pasted at a yes/no one, is an answer like any other: an up-arrow at the
2824
+ // next question brought it back in full, showed it and submitted it. Setup never needs an earlier answer
2825
+ // back. Exported so the test drives the options this file uses; above the call below, which reads it.
2826
+ export const SETUP_READLINE = Object.freeze({ terminal: true, historySize: 0 });
2827
+
2769
2828
  if (!isEntrypoint(import.meta.url)) {
2770
2829
  // imported, not run — do nothing else
2771
2830
  } else {
@@ -2795,26 +2854,50 @@ if (!input.isTTY) {
2795
2854
  // in whatever the reader pastes into a bug report. So the echo is muted while a secret is being typed:
2796
2855
  // the output stream readline writes through drops everything while `muted` is set.
2797
2856
  let muted = false;
2857
+ // AND WHILE A YES/NO QUESTION IS OPEN, only what can still become its answer is echoed
2858
+ // (shared/yes-no-echo.mjs), so a key pasted there is never shown. `rl` is read only while one is open.
2859
+ let yesNo = false;
2798
2860
  const maskedOutput = new Writable({
2799
- write(chunk, enc, cb) { if (!muted) output.write(chunk, enc); cb(); },
2861
+ write(chunk, enc, cb) {
2862
+ if (!muted) {
2863
+ const out = yesNo ? yesNoEchoOf(String(chunk), { line: rl.line, prompt: rl.getPrompt() }) : chunk;
2864
+ if (out.length) output.write(out, yesNo ? undefined : enc);
2865
+ }
2866
+ cb();
2867
+ },
2800
2868
  });
2801
- const rl = createInterface({ input, output: maskedOutput, terminal: true });
2869
+ const rl = createInterface({ input, output: maskedOutput, ...SETUP_READLINE });
2802
2870
  const askRaw = async (q) => (await rl.question(q)).trim();
2803
2871
  const askSecretRaw = async (q) => {
2804
2872
  output.write(q);
2805
2873
  muted = true;
2806
2874
  try { return (await rl.question("")).trim(); } finally { muted = false; output.write("\n"); }
2807
2875
  };
2808
- /** Yes/no with an explicit default. Enter takes the default; nothing else is guessed at. */
2809
- const confirm = async (q, def = true) => {
2876
+ // A YES/NO READ: the echo is filtered for exactly as long as the question is open (see maskedOutput).
2877
+ const askYesNo = async (q) => { yesNo = true; try { return (await rl.question(q)).trim(); } finally { yesNo = false; } };
2878
+ /**
2879
+ * Yes/no with an explicit default. Enter takes the default; nothing else is guessed at, except a key.
2880
+ *
2881
+ * A question that offers to take a key (`key: true`) accepts one pasted as the answer. The echo has
2882
+ * already kept it off the screen, and asking for it a second time would only invite a second paste.
2883
+ * Every other yes/no question still says "Please answer y or n." to anything that is not one.
2884
+ */
2885
+ const confirmOrKey = async (q, def = true, { key = true, what = "key" } = {}) => {
2810
2886
  for (;;) {
2811
- const a = (await askRaw(` ${q} ${def ? "[Y/n]" : "[y/N]"} `)).toLowerCase();
2812
- if (a === "") return def;
2813
- if (["y", "yes"].includes(a)) return true;
2814
- if (["n", "no"].includes(a)) return false;
2887
+ const raw = await askYesNo(` ${q} ${def ? "[Y/n]" : "[y/N]"} `);
2888
+ const a = raw.toLowerCase();
2889
+ if (a === "") return { yes: def, value: null };
2890
+ if (["y", "yes"].includes(a)) return { yes: true, value: null };
2891
+ if (["n", "no"].includes(a)) return { yes: false, value: null };
2892
+ if (key && looksLikeAKey(raw)) {
2893
+ info(`that looks like the ${what} itself, so it is taken as the answer. It was not shown.`);
2894
+ info(`received — ${raw.length} characters, ending …${raw.slice(-4)}`);
2895
+ return { yes: true, value: raw };
2896
+ }
2815
2897
  say(" Please answer y or n.");
2816
2898
  }
2817
2899
  };
2900
+ const confirm = async (q, def = true) => (await confirmOrKey(q, def, { key: false })).yes;
2818
2901
  /**
2819
2902
  * — `skippable` IS THE WHOLE ANSWER TO "WHICH PROMPTS MAY BE LEFT EMPTY", DECIDED ONCE.
2820
2903
  *
@@ -3197,8 +3280,11 @@ try {
3197
3280
  // named, and the re-probe is the proof either way.
3198
3281
  if (eng.headless) {
3199
3282
  info(`on a box with no browser: run \`${eng.headless.cmd}\`${eng.headless.tokenEnv ? " (from any machine you can sign in on)" : " here"}.`);
3200
- if (eng.headless.tokenEnv && await confirm(`Did that give you a token to paste? Capture it into ${eng.headless.tokenEnv} now`, false)) {
3201
- const tok = await askValue(`${eng.headless.tokenEnv}:`, { secret: true, skippable: true, skipped: "Nothing captured." });
3283
+ if (eng.headless.tokenEnv) {
3284
+ // A TOKEN PASTED AT THE YES/NO IS THE ANSWER (confirmOrKey): taken, never shown, not asked for twice.
3285
+ const answer = await confirmOrKey(`Did that give you a token to paste? Capture it into ${eng.headless.tokenEnv} now`, false, { what: "token" });
3286
+ const tok = answer.value ?? (answer.yes
3287
+ ? await askValue(`${eng.headless.tokenEnv}:`, { secret: true, skippable: true, skipped: "Nothing captured." }) : null);
3202
3288
  if (tok !== null) {
3203
3289
  candidate[eng.headless.tokenEnv] = tok;
3204
3290
  authEnv[eng.headless.tokenEnv] = tok; // the re-probe below must prove the lane WITH it
@@ -3292,7 +3378,10 @@ try {
3292
3378
  if (present(candidate[k])) { ok(`${k} already adopted from your environment`); continue; }
3293
3379
  info(`${k} is optional. Without it, the offices it serves are DISCLOSED as deferred coverage rather`);
3294
3380
  info(" than searched — the run still works and still tells the truth about what it did not reach.");
3295
- if (await confirm(`Set ${k} now?`, false)) candidate[k] = await askValue(`${k}:`, { secret: true });
3381
+ // A VALUE PASTED AT THE YES/NO IS THE ANSWER (confirmOrKey), as at every question that leads to a secret.
3382
+ const answer = await confirmOrKey(`Set ${k} now?`, false);
3383
+ if (answer.value) candidate[k] = answer.value;
3384
+ else if (answer.yes) candidate[k] = await askValue(`${k}:`, { secret: true });
3296
3385
  else info(`Skipped. Build the index later with: ${invocationPrefix()}clearotron sync`);
3297
3386
  }
3298
3387
  if (registerSelected) {
@@ -3363,7 +3452,10 @@ try {
3363
3452
  if (adapter.obtain) say(` Where to get one: ${adapter.obtain}.`);
3364
3453
  if (!present(candidate[name])) {
3365
3454
  const skippedLine = `Skipped. You can add ${name} later; until then: ${adapter.absentMeans ?? "the lane stays off"}.`;
3366
- if (await confirm(`Enter a ${adapter.label} API key now?`, true)) {
3455
+ // A KEY PASTED AT THE YES/NO IS THE ANSWER (confirmOrKey): taken, never shown, not asked for twice.
3456
+ const answer = await confirmOrKey(`Enter a ${adapter.label} API key now?`, true);
3457
+ if (answer.value) candidate[name] = answer.value;
3458
+ else if (answer.yes) {
3367
3459
  const v = await askValue(`${adapter.label} key:`, { secret: true, skippable: true, skipped: skippedLine });
3368
3460
  if (v !== null) candidate[name] = v;
3369
3461
  } else info(skippedLine);
@@ -3586,18 +3678,20 @@ try {
3586
3678
  say(" fallback — falls through by name, so an empty store is a working install. Your own customers");
3587
3679
  say(" are added here by name; the bundled demo customers never show through into your roster.");
3588
3680
 
3589
- // 7c ── WHO SIGNS IN, AND WHAT THEIR ORGANISATION IS CALLED
3681
+ // 7c ── YOUR ORGANISATION, AND WHO SIGNS IN
3590
3682
  //
3591
- // The address is the first person on the install and the name is its first organisation. The whole
3683
+ // The name is the install's first organisation. The address is not asked: it is the local account, or
3684
+ // the one the settings file in force already holds, and the summary below shows it once. The whole
3592
3685
  // reasoning, including the domain rule this step no longer writes, is on `askSignIn` above.
3593
- section("Who signs in");
3686
+ section("Your organisation");
3594
3687
  const localAccount = (() => {
3595
3688
  try { return userInfo().username || "user"; } catch { return "user"; }
3596
3689
  })();
3597
- // ONE CALL SITE. The loop itself lives in `askSignIn` so that the branches that matter — an address
3598
- // that must be sent back, a name that is missing are reachable without a terminal. The same seam
3599
- // and the same reason as `offerUsptoSync`.
3600
- Object.assign(candidate, await askSignIn({ askValue, ok, problem }, { localAccount }));
3690
+ const signInKept = readEnvFile(READ_ENV_PATH()).PORTAL_LOCAL_USER ?? null;
3691
+ // ONE CALL SITE. The loop itself lives in `askSignIn` so that the branches that matter a name that is
3692
+ // missing, or one the settings file cannot keep — are reachable without a terminal. The same seam and
3693
+ // the same reason as `offerUsptoSync`.
3694
+ Object.assign(candidate, await askSignIn({ askValue, ok, problem }, { localAccount, existing: signInKept }));
3601
3695
 
3602
3696
  // 8 ── the engine's own preflight over the whole candidate
3603
3697
  //
@@ -3653,6 +3747,11 @@ try {
3653
3747
  // rather than fall back, so a permissions fault can never silently swap a customer's framework for the
3654
3748
  // Generic default). Nothing is written INTO them — see the note at step 7b.
3655
3749
  for (const k of ["CLEAROTRON_CUSTOMERS_DIR"]) mkdirSync(candidate[k], { recursive: true });
3750
+ // AND THE SKILLS FOLDER THE NOTE AT 7b TELLS THE READER TO USE, created and NOT configured.
3751
+ // CLEAROTRON_INSTRUCTIONS_DIR stays unset, so the engine uses the product's own instructions, and the
3752
+ // portal reads an empty folder the same way: it overrides nothing. It is here so that the place an
3753
+ // override goes exists, and so the line below is true of both folders it describes.
3754
+ mkdirSync(join(cfg, "skills"), { recursive: true });
3656
3755
  ok(`configuration directories created under ${cfg} (empty — the bundled instructions and the generic fallback show through; customers are yours to add)`);
3657
3756
 
3658
3757
  // 9a ── PUT THE VERB ON THIS OPERATOR'S PATH
@@ -3723,6 +3822,8 @@ try {
3723
3822
  say(`\n ${style.bold("Setup finished.")} Nothing here needs running again.\n`);
3724
3823
  say(" What this box has now:");
3725
3824
  for (const line of [
3825
+ // THE ONE PLACE THE ADDRESS IS SHOWN (ruling, 2026-09-10): setup no longer asks for it.
3826
+ stateLine("signs in as", candidate.PORTAL_LOCAL_USER, "the local account"),
3726
3827
  stateLine("register", candidate.CLEAROTRON_DATABASE, "no register, so every search refuses until one is chosen"),
3727
3828
  stateLine("web research", present(candidate.PERPLEXITY_API_KEY) ? "a key" : "", "a clearance stops before it starts, and names the missing key; a knockout search runs and discloses the half it skipped"),
3728
3829
  stateLine("data directory", candidate.CLEAROTRON_REPORTS_DIR, "unset"),