clearotron 0.3.0-beta.10 → 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 (75) hide show
  1. package/.env.example +3 -13
  2. package/INSTALL.md +6 -110
  3. package/README.md +21 -31
  4. package/bin/connect.mjs +26 -71
  5. package/bin/disconnect.mjs +11 -16
  6. package/bin/example.mjs +2 -14
  7. package/bin/key.mjs +5 -23
  8. package/bin/onboard.mjs +44 -191
  9. package/bin/start.mjs +33 -197
  10. package/bin/status.mjs +3 -23
  11. package/bin/stop.mjs +8 -23
  12. package/bin/update.mjs +6 -38
  13. package/build-info.json +2 -2
  14. package/demo/full-country-search/run/status.json +1 -1
  15. package/demo/global-preliminary-search/run/status.json +1 -1
  16. package/demo/knockout-search/run/email-body.md +1 -1
  17. package/demo/knockout-search/run/status.json +2 -2
  18. package/demo/multi-country-focus-search/run/status.json +1 -1
  19. package/docs/architecture/04-configuration-reference.md +1 -2
  20. package/driver/CHANGELOG.md +8 -107
  21. package/driver/compose-read.mjs +2 -21
  22. package/driver/contract-e3-backlog.mjs +1 -1
  23. package/driver/demo-container.mjs +2 -65
  24. package/driver/driver.config.mjs +0 -5
  25. package/driver/engine/anthropic-agent.mjs +17 -34
  26. package/driver/engine/jx-turn.mjs +1 -4
  27. package/driver/engine/openai-agent.mjs +2 -60
  28. package/driver/enqueue-schema.mjs +1 -3
  29. package/driver/gateway.mjs +15 -88
  30. package/driver/package.json +1 -1
  31. package/driver/portal-local-auth.mjs +3 -35
  32. package/driver/portal-service.mjs +22 -91
  33. package/driver/portal-upstream.mjs +1 -20
  34. package/driver/profile-service.mjs +3 -31
  35. package/driver/publish/index.mjs +9 -12
  36. package/driver/publish/knockout.mjs +2 -4
  37. package/driver/publish/office-record-links.mjs +21 -56
  38. package/driver/recipe-service.mjs +5 -14
  39. package/driver/record-origins.mjs +0 -14
  40. package/driver/search-policy.mjs +2 -7
  41. package/driver/suite-census.json +46 -154
  42. package/driver/systemd/render-units.mjs +13 -7
  43. package/mcp-server/CHANGELOG.md +2 -36
  44. package/mcp-server/CONNECT.md +2 -3
  45. package/mcp-server/lib/driver.mjs +0 -2
  46. package/mcp-server/lib/knockout.mjs +2 -2
  47. package/mcp-server/lib/options.mjs +5 -20
  48. package/mcp-server/package.json +1 -1
  49. package/package.json +1 -1
  50. package/portal-ui/dist/assets/{index-CwPAS0we.js → index-CcFjgM78.js} +383 -525
  51. package/portal-ui/dist/assets/{index-Cv-E_agg.css → index-CsCuPshD.css} +86 -199
  52. package/portal-ui/dist/index.html +2 -2
  53. package/portal-ui/package.json +1 -1
  54. package/providers/clarivate/src/core.js +0 -5
  55. package/providers/oauth-mcp-bridge/CHANGELOG.md +0 -32
  56. package/providers/oauth-mcp-bridge/package.json +1 -1
  57. package/scripts/e2e-unread-terminals.mjs +1 -1
  58. package/scripts/e2e.mjs +20 -197
  59. package/scripts/engine-probe.mjs +2 -2
  60. package/scripts/env-classify.mjs +0 -3
  61. package/scripts/revisit-render-check.mjs +4 -22
  62. package/scripts/travelling-predicates.mjs +1 -1
  63. package/shared/access-audience.mjs +6 -7
  64. package/shared/brand.mjs +3 -7
  65. package/shared/client-door.mjs +0 -39
  66. package/shared/connect-clients.mjs +321 -290
  67. package/shared/env-file-merge.mjs +0 -24
  68. package/shared/invocation.mjs +2 -49
  69. package/shared/names-in-force.mjs +0 -2
  70. package/shared/stdio-connect.mjs +14 -95
  71. package/shared/store-in-repo.mjs +2 -50
  72. package/shared/verb-shim.mjs +1 -10
  73. package/shared/permanent-install.mjs +0 -237
  74. package/shared/running-start.mjs +0 -73
  75. package/shared/wsl.mjs +0 -23
package/bin/start.mjs CHANGED
@@ -109,25 +109,23 @@ async function runTables() {
109
109
  return { registers: PROVIDERS, engines: ENGINE_BINARIES, defaultEngine: RUN_DEFAULT_ENGINE };
110
110
  }
111
111
  import { spawn, spawnSync, execFileSync } from "node:child_process";
112
- import { storeInRepo, storeOutsideRepoMessage, storeCommitRefusal } from "../shared/store-in-repo.mjs"; //
112
+ import { storeInRepo, storeOutsideRepoMessage } from "../shared/store-in-repo.mjs"; //
113
113
  import { stdioConnectOffer } from "../shared/stdio-connect.mjs";
114
- import { ensureDemoProgram } from "../shared/permanent-install.mjs"; // — a demo from npx keeps its own copy
115
114
  import { mergeEnvFile } from "../shared/env-file-merge.mjs";
116
115
  import { mcpOriginFor } from "../shared/lane-address.mjs"; // — one author for the origin
117
116
  import { SERVER_INSTALL_SET, unitsToRestartOnRefresh, unitHealthVerdict } from "../shared/server-units.mjs"; // — one authority, two callers
118
117
  // — the door --background now INSTALLS, and the one authority for the settings
119
118
  // it refuses to start without. (Until 2026-09-03 this import read "the one unit --background may
120
119
  // tolerate and never manage"; settled point 2 superseded that.)
121
- import { defaultDenylistPath, denylistPathFor, denylistFor, ensureDenylistFile, CLIENT_DOOR_UNIT, enablePlan, clientDoorPort, demoTokenSecret, demoTokenSecretPath, keyIssueCommand } from "../shared/client-door.mjs"; // — one owner for the revocation list's path
120
+ import { defaultDenylistPath, denylistPathFor, denylistFor, ensureDenylistFile, CLIENT_DOOR_UNIT, enablePlan, clientDoorPort } from "../shared/client-door.mjs"; // — one owner for the revocation list's path
122
121
  import { createServer } from "node:net";
123
122
  import { listenErrorMessage, nextFreePort } from "../shared/listen.mjs";
124
123
  import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
125
124
  import { randomBytes } from "node:crypto";
126
- import { invocationPrefix, invoke, reachableCommand } from "../shared/invocation.mjs"; // — the banner names the verb
127
- import { unitEnvPath, activeEnvPath } from "../shared/env-local.mjs"; // — the file the units read, named once
128
- import { parseEnvFile } from "../shared/env-file-merge.mjs"; // ONE KEY=value reader, in a leaf: render-units is a COMMAND, and importing it from here closed a cycle
125
+ import { invocationPrefix, invoke } from "../shared/invocation.mjs"; // — the banner names the verb
126
+ import { unitEnvPath } from "../shared/env-local.mjs"; // — the file the units read, named once
129
127
  import { homedir, userInfo } from "node:os";
130
- import { dirname, join, resolve, sep } from "node:path";
128
+ import { dirname, join } from "node:path";
131
129
  import { fileURLToPath } from "node:url";
132
130
  import { usageBlock } from "../shared/usage-block.mjs";
133
131
  import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-point test, all spellings
@@ -143,7 +141,6 @@ import { addressRefusal } from "../shared/staff-domain.mjs";
143
141
  import { withPerson, withOrganisation, withCompany } from "../shared/grants-edit.mjs";
144
142
  import { assertGrantsShape, resolvePerson } from "../shared/scope.mjs";
145
143
  import { backgroundManager } from "../shared/os-advice.mjs";
146
- import { recordRunning } from "../shared/running-start.mjs";
147
144
  import { frontingVariablesSet } from "../shared/install-auth.mjs"; // — one owner for what counts as a proxy in front of a door
148
145
 
149
146
  const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
@@ -303,22 +300,6 @@ export function resolvePorts(env = {}) {
303
300
  client: one("CLIENT_MCP_HTTP_PORT", clientDoorPort({})) };
304
301
  }
305
302
 
306
- /**
307
- * WHAT TO DO WHEN THE PAGE THAT OPENS IS NOT OURS. Printed under every "Open" line.
308
- *
309
- * A port can be free where this runs and taken where the browser runs: on WSL, a Windows-side listener
310
- * (VS Code's Remote-SSH forwarding is the one measured, 2026-09-11) answers 127.0.0.1 before WSL does. The
311
- * doors bind cleanly, the in-use detection has nothing to see, and the browser shows somebody else's page
312
- * with nothing on this screen saying so. `--port` already moves all three doors; the reader has to be told
313
- * about it at the moment the address is handed over, which is here.
314
- */
315
- export function foreignPageHint(verb) {
316
- return [
317
- "If the page that opens is not this install's sign-in, another program on this machine holds that",
318
- `port from outside this environment. Run \`${invoke(verb)} --port 28802\` (or any free number) instead.`,
319
- ];
320
- }
321
-
322
303
  /**
323
304
  * Apply `--port <n>` to the three doors.
324
305
  *
@@ -374,50 +355,6 @@ export function defaultGrantsPath({ env = process.env, demo = false } = {}) {
374
355
  return installPaths(join(homedir(), demo ? "trademark-demo" : "trademark")).grants;
375
356
  }
376
357
 
377
- /**
378
- * The paths `clearotron start` hands its services: the install's layout under `base`, with whatever the
379
- * environment already names winning over the layout's default, as `start` has always applied it.
380
- *
381
- * EXPORTED FOR THE ONE OTHER READER THAT MUST AGREE WITH IT. On an install with no background units,
382
- * `clearotron doctor` has no unit file to read the services' environment from: the services are this
383
- * command's children and get the saved-search store from here. An install started before the store was
384
- * written to the env file has no line there to read, so doctor asks this function for it, and the
385
- * answer it prints and the one the services were given have one author.
386
- *
387
- * NOT IN A DEMO. Nothing the environment says about an install is the demo's: with the reader's settings
388
- * in force, 0.3.0-beta.1's demo seeded its example reports into their real archive.
389
- */
390
- export function startPaths({ env = process.env, base = join(homedir(), "trademark"), demo = false } = {}) {
391
- const paths = installPaths(base);
392
- if (demo) return paths;
393
- for (const [k, name] of [["pool", "CLEAROTRON_REPORTS_DIR"], ["workspace", "CLEAROTRON_WORK_DIR"], ["queue", "CLEAROTRON_QUEUE_DIR"],
394
- ["outbox", "CLEAROTRON_OUTBOX_DIR"], ["locks", "CLEAROTRON_RUN_LOCK_DIR"], ["grants", "CLEAROTRON_ACCESS_FILE"],
395
- ["recipes", "CLEAROTRON_RECIPES_DIR"]]) if (env[name]) paths[k] = env[name];
396
- if (env.RECIPE_REPO_ROOT) paths.configStore = env.RECIPE_REPO_ROOT;
397
- if (env.PORTAL_AUDIT) paths.audit = env.PORTAL_AUDIT;
398
- return paths;
399
- }
400
-
401
- /**
402
- * The saved-search store, as the env file must record it for every reader OUTSIDE this command's tree.
403
- *
404
- * The services are handed the store by `childEnv`. A connector an assistant launches, `clearotron doctor`
405
- * and every other hand-run command are not this command's children: they read the env file, and the
406
- * file never named the store. So on a local install the portal listed a company's saved searches, the
407
- * connected assistant was told there were none, and doctor said saved searches were off. Written
408
- * add-only beside the secrets, as `npx clearotron install` writes the reports and work directories: a
409
- * line an operator wrote wins.
410
- */
411
- export function storesForOtherReaders(paths) {
412
- return { CLEAROTRON_RECIPES_DIR: paths.recipes, RECIPE_REPO_ROOT: paths.configStore };
413
- }
414
-
415
- // Written above each line, so a reader who moves the install sees that these two move with it.
416
- const STORE_NOTES = {
417
- CLEAROTRON_RECIPES_DIR: "Where saved searches are kept. `clearotron start` created it; edit both lines if you move the install.",
418
- RECIPE_REPO_ROOT: "The git repository saved searches are committed in, which holds the directory above.",
419
- };
420
-
421
358
  /** Everything this install keeps on disk, under one base directory. */
422
359
  export function installPaths(base) {
423
360
  return {
@@ -759,10 +696,6 @@ export function childEnv({ ports, paths, user, portalSecret, tokenSecret, opsTok
759
696
  // already wrong for a process that is not the portal.
760
697
  ...(demo ? {
761
698
  CLEAROTRON_DEMO: "1",
762
- // A DEMO NAMES NO ORGANISATION. The portal reads this to name the one running the install, on its
763
- // top bar and its sign-in refusal page, and an exported value is the reader's real organisation.
764
- // The portal's alone: no door names an organisation, and the doors stay as a live install's.
765
- CLEAROTRON_ORGANISATION_NAME: "",
766
699
  // AND THE SIGN-IN CREDENTIAL LIVES IN THE DEMO'S OWN BASE. Without this it defaults to
767
700
  // ~/.cordillera/portal-local-credential.json — shared with every install on the box — and the
768
701
  // demo then inherits a digest minted for somebody else's address: the portal prints "the
@@ -911,46 +844,21 @@ if (isMain) {
911
844
  // Decided before any path is, because in a demo every path below is the demo's own. The posture
912
845
  // itself is described at the DEMO block further down.
913
846
  const DEMO = argv.includes("--demo");
914
- // A DEMO HAS NO BACKGROUND FORM, and asking for one is refused before anything is written. The
915
- // background path installs units that run the reader's own install, so `--demo --background` would set
916
- // up an empty install in their home and call it the demo.
917
- if (DEMO && wantBackground)
918
- fatal("the demo has no background form — it runs as long as its terminal does. `--background` starts your own install as services, not the demo.");
919
847
  // The same base `npm run setup` writes under, so whichever of the two a reader ran first, the other
920
848
  // finds the same install rather than a second one beside it.
849
+ const paths = installPaths(flag("--base", join(homedir(), DEMO ? "trademark-demo" : "trademark")));
921
850
  // Whatever the environment already says wins over the base-derived default, for every path — a reader
922
- // who ran `npm run setup` has these in .env already and this must not move their data. Not in a demo,
923
- // which `startPaths` says why. One author, because `doctor` asks the same function what the services
924
- // were handed.
925
- const paths = startPaths({ env: process.env, base: flag("--base", join(homedir(), DEMO ? "trademark-demo" : "trademark")), demo: DEMO });
926
- // ── NOTHING OF THE DEMO LANDS IN AN INSTALL, AND THAT IS CHECKED BEFORE ANYTHING IS WRITTEN ───────
927
- //
928
- // The demo keeps its signing secret in its base and `key issue --base` reads it from there, so a demo
929
- // pointed at an install's directory would leave a secret where that install's own key command looks:
930
- // every key issued afterwards would be signed with the demo's while the install's door verified with
931
- // its own — a key that looks issued and is refused, with nothing said either way. The settings are
932
- // READ here and never applied; a demo still takes nothing from them.
933
- if (DEMO) {
934
- const base = resolve(paths.base);
935
- const inside = (p) => { const v = String(p ?? "").trim(); if (!v) return false; const r = resolve(v); return r === base || r.startsWith(base + sep); };
936
- let settings = {};
937
- try { settings = parseEnvFile(readFileSync(activeEnvPath(), "utf8")); } catch { /* no settings in force: nothing of an install to collide with */ }
938
- const found = [];
939
- if (inside(settings.CLEAROTRON_REPORTS_DIR)) found.push(`an install keeps its reports in ${settings.CLEAROTRON_REPORTS_DIR}`);
940
- if (inside(settings.RECIPE_REPO_ROOT)) found.push(`an install keeps its saved searches in ${settings.RECIPE_REPO_ROOT}`);
941
- if (inside(settings.CLEAROTRON_WORK_DIR)) found.push(`an install works in ${settings.CLEAROTRON_WORK_DIR}`);
942
- if (existsSync(join(paths.base, ".env"))) found.push(`it holds a settings file, ${join(paths.base, ".env")}`);
943
- if (!found.length && base === resolve(join(homedir(), "trademark"))) found.push("it is the directory an install is set up in by default");
944
- // AN INSTALL NOBODY CONFIGURED still answers. `start --base <dir>` writes a guest list on every start
945
- // and no settings at all, so an install somewhere of its own passes every check above. A guest list
946
- // with no demo secret beside it is somebody's install; a demo's own base has both.
947
- if (!found.length && existsSync(paths.grants) && !existsSync(demoTokenSecretPath(paths.base)))
948
- found.push(`it holds a guest list, ${paths.grants}, and no demo of its own`);
949
- if (found.length)
950
- fatal(`--demo cannot run in ${paths.base}: ${found.join("; ")}.\n`
951
- + " The demo keeps its own data, and its own signing secret, in its base. Leaving those in an\n"
952
- + " install's directory would make keys issued for that install refuse at its door.\n"
953
- + " Run the demo without --base, or give it a directory of its own.");
851
+ // who ran `npm run setup` has these in .env already and this must not move their data.
852
+ //
853
+ // NOT IN A DEMO. Nothing the environment says about an install is the demo's: with the reader's
854
+ // settings in force, 0.3.0-beta.1's demo seeded its example reports into their real archive. Not read,
855
+ // rather than deleted from the environment, so a real start cannot be reached by this branch at all.
856
+ if (!DEMO) {
857
+ for (const [k, name] of [["pool", "CLEAROTRON_REPORTS_DIR"], ["workspace", "CLEAROTRON_WORK_DIR"], ["queue", "CLEAROTRON_QUEUE_DIR"],
858
+ ["outbox", "CLEAROTRON_OUTBOX_DIR"], ["locks", "CLEAROTRON_RUN_LOCK_DIR"], ["grants", "CLEAROTRON_ACCESS_FILE"],
859
+ ["recipes", "CLEAROTRON_RECIPES_DIR"]]) if (process.env[name]) paths[k] = process.env[name];
860
+ if (process.env.RECIPE_REPO_ROOT) paths.configStore = process.env.RECIPE_REPO_ROOT;
861
+ if (process.env.PORTAL_AUDIT) paths.audit = process.env.PORTAL_AUDIT;
954
862
  }
955
863
  // ── THIS INSTALL'S FIRST START, read before this start writes either file that answers it ────────────
956
864
  //
@@ -1207,29 +1115,16 @@ if (isMain) {
1207
1115
  return v;
1208
1116
  };
1209
1117
  const portalSecret = secretFor("PORTAL_SECRET");
1210
- // A DEMO'S SIGNING SECRET IS KEPT IN ITS OWN BASE, so the key command it prints can sign for it; see
1211
- // `demoTokenSecret`. Every other secret a demo uses stays in memory.
1212
- const tokenSecret = DEMO
1213
- ? demoTokenSecret(paths.base, {
1214
- read: (f) => readFileSync(f, "utf8"),
1215
- write: (f, text) => { mkdirSync(dirname(f), { recursive: true }); writeSecretFile(f, text); },
1216
- mint: () => randomBytes(32).toString("base64url"),
1217
- })
1218
- : secretFor("TRADEMARK_MCP_TOKEN_SECRET");
1118
+ const tokenSecret = secretFor("TRADEMARK_MCP_TOKEN_SECRET");
1219
1119
  if (!process.env.PORTAL_LOCAL_USER) generated.PORTAL_LOCAL_USER = user;
1220
- const stores = DEMO ? {} : storesForOtherReaders(paths);
1221
1120
 
1222
1121
  // A DEMO WRITES NO SECRETS AND NO ADDRESS. They are generated per run and live in memory only, which
1223
1122
  // is the same posture the ops key already has here — and it is what makes "removing the demo is one
1224
1123
  // directory" true rather than nearly true.
1225
- //
1226
- // THE STORES ARE WRITTEN EVEN WHEN NO SECRET IS. An install whose secrets are already in the file is
1227
- // exactly the install whose connector and doctor could not see its saved searches, and a gate on the
1228
- // secrets alone would never reach it.
1229
- if ((Object.keys(generated).length || Object.keys(stores).length) && !DEMO) {
1124
+ if (Object.keys(generated).length && !DEMO) {
1230
1125
  let existing = "";
1231
1126
  try { existing = readFileSync(ENV_PATH, "utf8"); } catch (e) { if (e.code !== "ENOENT") fatal(`${ENV_PATH} exists but could not be read (${e.code}).`); }
1232
- const merged = mergeEnvFile(existing, { ...generated, ...stores }, { notes: STORE_NOTES });
1127
+ const merged = mergeEnvFile(existing, generated);
1233
1128
  if (merged.added.length) {
1234
1129
  // ONE WRITER FOR EVERY FILE THAT HOLDS CREDENTIALS, and it creates the directory. This site had its
1235
1130
  // own copy of the write and did not learn what the wizard's copy learned when `.env` moved under
@@ -1240,7 +1135,7 @@ if (isMain) {
1240
1135
  } catch (e) {
1241
1136
  fatal(`could not write ${ENV_PATH} (${String(e?.message ?? e)}).`);
1242
1137
  }
1243
- // NAMES only. Some of what this file holds is credentials.
1138
+ // NAMES only. This file is where the credentials are.
1244
1139
  say(` wrote ${merged.added.join(", ")} to ${ENV_PATH} (mode 600)`);
1245
1140
  }
1246
1141
  }
@@ -1297,7 +1192,7 @@ if (isMain) {
1297
1192
  say(` grants ${paths.grants} named nobody, so ${user} was added: Run, Manage, access to everything`);
1298
1193
  if (seeded.changed.includes("organisation")) say(` organisation "${organisation}", filed there — rename it there too`);
1299
1194
  else if (seeded.changed.includes("person") && !Object.keys(seeded.grants.tenants ?? {}).length)
1300
- say(` no organisation named yet — \`${reachableCommand('start --organisation "<name>"')}\` files the first one`);
1195
+ say(` no organisation named yet — \`${invocationPrefix()}clearotron start --organisation "<name>"\` files the first one`);
1301
1196
  // REPORTED, NOT REPAIRED. A file that names other people is somebody's decision, and adding this
1302
1197
  // address to it would give access to everything to an address nobody enrolled.
1303
1198
  if (seeded.unadmitted)
@@ -1357,12 +1252,6 @@ if (isMain) {
1357
1252
  // search is what will fail.
1358
1253
  err(` WARNING: could not initialise the saved-search store at ${paths.configStore} (${String(e?.message ?? e)}). Searches will list and run; SAVING one will fail until this is a git repository.`);
1359
1254
  }
1360
- } else {
1361
- // AN ADOPTED STORE IS ASKED WHETHER IT CAN RECORD A SAVE, HERE, not at the first save. A repository
1362
- // made by hand has no identity unless somebody gave it one, and on a machine with no global identity
1363
- // the first company created in the portal is then refused. The one created above sets its own.
1364
- const cannot = storeCommitRefusal(paths.configStore);
1365
- if (cannot) err(` WARNING: ${cannot.message}. Until then, creating a company or saving a search is refused.`);
1366
1255
  }
1367
1256
 
1368
1257
  // ── THE DEMO'S OWN STORE, WITH ITS COMPANY IN IT ────────────────────────────────────────────────
@@ -1435,13 +1324,8 @@ if (isMain) {
1435
1324
  // SEEDED FROM A COPY, for the reason the player publishes from one: republishing writes a receipt
1436
1325
  // into the run directory it reads, and `demo/` is tracked. This is the path a reader actually takes
1437
1326
  // — `clearotron demo` hands over to this — so fixing the player alone left the defect where it was.
1438
- const { publishSource, seedDemoRuns } = await import("../driver/demo-container.mjs");
1327
+ const { publishSource } = await import("../driver/demo-container.mjs");
1439
1328
  const seed = await seedPool({ pool: paths.pool, examplesDir: publishSource(join(REPO, "demo"), { repoRoot: REPO }), republish: republishRun });
1440
- // AND AS RUNS, so the assistant this demo's connect line wires has them to list, brief and open. Under
1441
- // the demo's own workspace only: nothing of it reaches an install started afterwards. Their report
1442
- // links are stamped with this portal's address, the one the Open line prints.
1443
- const runs = seedDemoRuns({ workspace: paths.workspace, examplesDir: join(REPO, "demo"), portalOrigin: `http://${HOST}:${ports.portal}` });
1444
- if (runs.seeded.length) say(` runs ${runs.seeded.length} sample run(s) your assistant can list, brief and open`);
1445
1329
  // WHAT WAS ALREADY THERE IS SAID TOO. This branch used to run only when the pool
1446
1330
  // was empty; it now tops a stale pool up to the package's set, so "seeded 1" on an upgrade is a fact
1447
1331
  // about what was MISSING and says nothing on its own about how many are now listed.
@@ -1501,17 +1385,6 @@ if (isMain) {
1501
1385
  // the file the verb resets is the file the portal reads. A demo keeps its own, by layout (childEnv).
1502
1386
  const { installCredential } = await import("../driver/portal-local-auth.mjs");
1503
1387
  const signIn = DEMO ? null : installCredential({ base: paths.base, env: process.env, firstStart: firstStartOfThisInstall });
1504
- // ── A DEMO RUN FROM NPX KEEPS ITS OWN COPY OF THE PROGRAM ───────────────────────────────────────────
1505
- //
1506
- // Its connect line launched the connector from npm's cache, which npm deletes when it cleans up, so an
1507
- // assistant registered with it lost the demo without a word (measured on a published beta, 2026-09-11).
1508
- // The same version goes into `<base>/program` (shared/permanent-install.mjs), BEFORE the services start
1509
- // so the portal's connect rows name it too, and only when it is not there already. It never stops the
1510
- // demo: if npm fails, the demo runs as before and says the line will not survive a cache clean.
1511
- // `clearotron demo` makes the copy before it starts this file, and starts this file FROM the copy, so
1512
- // there this finds nothing to do; it acts for a `start --demo` typed at npx directly.
1513
- const demoProgramRoot = DEMO ? ensureDemoProgram({ base: paths.base, say }) : null;
1514
-
1515
1388
  const envs = childEnv({ ports, paths, user, portalSecret, tokenSecret, opsToken,
1516
1389
  localWorker: wantWorker, demo: DEMO, clientFence: declaredFence || null,
1517
1390
  credential: signIn && signIn.source !== "shared" ? signIn.path : null });
@@ -1960,7 +1833,6 @@ if (isMain) {
1960
1833
  }
1961
1834
  say("");
1962
1835
  say(` Open: ${envs.url}`);
1963
- for (const line of foreignPageHint(DEMO ? "demo" : "start")) say(` ${line}`);
1964
1836
  say(" This SURVIVES the terminal — close the window, the product keeps running.");
1965
1837
  say(` Stop it: ${invoke("stop")} (stops and removes the units; issued connect keys survive — \`${invoke("disconnect")}\` revokes those)`);
1966
1838
  say(` Is it up? ${invoke("status")}`);
@@ -2194,7 +2066,6 @@ if (isMain) {
2194
2066
 
2195
2067
  say("");
2196
2068
  say(` Open ${envs.url}`);
2197
- for (const line of foreignPageHint(DEMO ? "demo" : "start")) say(` ${line}`);
2198
2069
  say("");
2199
2070
  // ── TWO DOORS, TWO AUDIENCES, BOTH NAMED ( — F26) ─────────────────────────
2200
2071
  //
@@ -2220,26 +2091,12 @@ if (isMain) {
2220
2091
  if (adoptedClientDoor)
2221
2092
  say(` Already running as ${CLIENT_DOOR_UNIT}; this start kept it, so existing keys still work.`);
2222
2093
  else
2223
- say(` It refuses every caller until a key is issued: ${keyIssueCommand({ prefix: invocationPrefix(), demo: DEMO, user, base: paths.base, defaultBase: join(homedir(), "trademark") })}`);
2094
+ say(` It refuses every caller until a key is issued: ${invocationPrefix()}clearotron key issue <email>`);
2224
2095
  } else {
2225
2096
  say(` Client door NOT RUNNING on ${HOST}:${ports.client} — its output above says why. The portal and`);
2226
2097
  say(" the engine door are unaffected; a client assistant cannot connect until it is up.");
2227
2098
  }
2228
2099
  say("");
2229
- // ── WHAT `status` AND `stop` READ ABOUT THIS START ───────────────────────────────────────────────
2230
- //
2231
- // Both verbs knew only about background units, so with this start serving, `status` described units
2232
- // nobody installed and `stop` said nothing was running (measured on a published beta, 2026-09-11). The
2233
- // record is the address this banner just printed. It goes on every exit — Ctrl-C, a fatal refusal, a
2234
- // crash — and a start killed outright leaves a record whose process is gone, which the readers ignore.
2235
- try {
2236
- const forget = recordRunning({ pid: process.pid, demo: DEMO, base: paths.base, url: envs.url, host: HOST,
2237
- ports: { portal: ports.portal, mcp: ports.mcp, client: doorRunning ? ports.client : null },
2238
- startedAt: new Date().toISOString() });
2239
- process.on("exit", forget);
2240
- } catch (e) {
2241
- say(` (\`status\` will not see this start: its record could not be written — ${String(e?.message ?? e)})`);
2242
- }
2243
2100
  // — THE BANNER TOLD THE SAME STORY ON EVERY START, and it was only true of the first.
2244
2101
  //
2245
2102
  // "printed once, above" describes what a FIRST start does. On every start after it the passphrase was
@@ -2306,10 +2163,7 @@ if (isMain) {
2306
2163
  //
2307
2164
  // The string comes from the ONE composer, not from a literal here: three surfaces state this route
2308
2165
  // and a line of instruction with more than one author drifts silently.
2309
- // THE WORKSPACE AND POOL THE SERVICES WERE HANDED, not this process's environment: a demo reads no env
2310
- // file, so its own line named no workspace and the connector fell back to the real install's.
2311
- // A demo run from npx names its own copy of the program, which a cache clean does not remove.
2312
- const connect = stdioConnectOffer({ workDir: paths.workspace, reportsDir: paths.pool, ...(demoProgramRoot ? { installRoot: demoProgramRoot } : {}) });
2166
+ const connect = stdioConnectOffer({ workDir: process.env.CLEAROTRON_WORK_DIR || null });
2313
2167
  say(" Connect your assistant to this install — one line, no address and no sign-in:");
2314
2168
  say("");
2315
2169
  say(` ${connect.command}`);
@@ -2331,35 +2185,17 @@ if (isMain) {
2331
2185
  // cannot succeed and given a service manager that is not on the machine and cannot be put there.
2332
2186
  // Reported from a real run. Same rule as the engine refusal above: do not name a route this platform
2333
2187
  // does not have.
2334
- for (const line of backgroundOfferLines({ demo: DEMO, manager: backgroundManager(), start: invoke("start") })) say(line);
2188
+ const manager = backgroundManager();
2189
+ if (manager) {
2190
+ say(` To get your prompt back instead, stop this and run ${invoke("start")} --background`);
2191
+ say(` — same product, managed by ${manager}, and it survives logout.`);
2192
+ } else {
2193
+ say(" There is no background form on this platform: the product runs as long as this window does.");
2194
+ say(" Leave it open and use a second terminal for the commands above.");
2195
+ }
2335
2196
  say("");
2336
2197
  }
2337
2198
 
2338
- /**
2339
- * The closing offer of a foreground start: how to get the prompt back, where there is a way.
2340
- *
2341
- * NOT IN A DEMO. The offer ran `start --background`, which set up a new, empty install in the reader's
2342
- * home — not the demo, its samples or its sign-in — and then failed where the user's systemd was not
2343
- * reachable (measured on a published beta, 2026-09-11). A demo has no background form, so it is not
2344
- * offered one. Where the offer stands, it names what it needs BEFORE the reader stops what is running.
2345
- * PURE.
2346
- */
2347
- export function backgroundOfferLines({ demo = false, manager = null, start = "clearotron start" } = {}) {
2348
- if (demo) return [
2349
- " The demo has no background form: it runs as long as this window does. Leave it open and use a",
2350
- " second terminal for the commands above.",
2351
- ];
2352
- if (!manager) return [
2353
- " There is no background form on this platform: the product runs as long as this window does.",
2354
- " Leave it open and use a second terminal for the commands above.",
2355
- ];
2356
- return [
2357
- ` To get your prompt back instead, stop this and run ${start} --background`,
2358
- ` — same product, managed by ${manager}, and it survives logout. It needs ${manager}'s user manager`,
2359
- " reachable from this session; where it is not, that command says so and changes nothing you use.",
2360
- ];
2361
- }
2362
-
2363
2199
  /**
2364
2200
  * What a reader is told when a child this install cannot run without exits.
2365
2201
  *
package/bin/status.mjs CHANGED
@@ -18,7 +18,6 @@ import { BACKGROUND_UNITS, resolvePorts } from "./start.mjs";
18
18
  import { parseEnvFile } from "../driver/systemd/render-units.mjs"; // ONE KEY=value reader — what systemd actually reads
19
19
  import { CLIENT_DOOR_UNIT, clientDoorPort } from "../shared/client-door.mjs";
20
20
  import { invoke } from "../shared/invocation.mjs";
21
- import { readRunning, probe } from "../shared/running-start.mjs";
22
21
  import { configStaleness, stalenessWarning, parseSystemdTimestamp } from "../driver/config-staleness.mjs"; // — F48
23
22
 
24
23
  const UNIT_DIR = join(homedir(), ".config", "systemd", "user");
@@ -58,34 +57,15 @@ const startedEpochMs = (u) => {
58
57
  return parseSystemdTimestamp(line?.slice("ActiveEnterTimestamp=".length));
59
58
  } catch { return null; }
60
59
  };say("");
61
- // ── A FOREGROUND START ANSWERS HERE TOO ─────────────────────────────────────────────────────────────
62
- //
63
- // The README starts the product in a terminal, and this verb answered only for background units: with
64
- // everything up it printed a sentence about units the reader never installed (measured on a published
65
- // beta, 2026-09-11). The record gives the address `start` printed; the portal itself says whether it is up.
66
- const running = readRunning();
67
- for (const r of running) {
68
- const what = r.demo ? "The demo" : "The product";
69
- // The portal's liveness route, which answers without a session: the address itself asks for a sign-in
70
- // and read as "not answering" over a portal that was up (driven 2026-09-11).
71
- if (await probe(new URL("/portal/health", r.url).href)) say(` ${what} is up, in the foreground — a terminal holds \`start\` (pid ${r.pid}); Ctrl-C there stops it.`);
72
- else say(` ${what} was started in the foreground (pid ${r.pid}), but its portal at ${r.url} is not answering.`);
73
- say(` Open ${r.url}`);
74
- say(` Engine door http://${r.host}:${r.ports.mcp}/mcp`);
75
- if (r.ports.client) say(` Client door http://${r.host}:${r.ports.client}/mcp`);
76
- say("");
77
- }
78
60
  let installed = 0;
79
61
  for (const u of BACKGROUND_UNITS) {
80
62
  if (!existsSync(join(UNIT_DIR, u))) continue;
81
63
  installed++;
82
64
  say(` ${u.padEnd(34)} ${state(u)}`);
83
65
  }
84
- if (!installed && running.length) {
85
- say(" No background units are installed.");
86
- } else if (!installed) {
87
- say(" The product is not running: no terminal holds `start`, and no background units are installed.");
88
- say(` \`${invoke("start")}\` runs it in a terminal; \`${invoke("start")} --background\` runs it as services that survive logout.`);
66
+ if (!installed) {
67
+ say(" No background units are installed — this box runs the product only while a terminal holds");
68
+ say(` \`clearotron start\`. \`clearotron start --background\` is the form that survives the terminal.`);
89
69
  } else {
90
70
  // The ports come from the same resolver start uses, RESOLVED OVER THE UNITS' OWN FILE: the running
91
71
  // services read %h/.env via systemd, and this CLI's shell env may know nothing of it — the first
package/bin/stop.mjs CHANGED
@@ -23,7 +23,6 @@ import { systemdSaid, looksLikeBusFailure, busRemedy, CAPTURE_STDERR } from "../
23
23
  import { BACKGROUND_UNITS } from "./start.mjs";
24
24
  import { CLIENT_DOOR_UNIT } from "../shared/client-door.mjs";
25
25
  import { invoke } from "../shared/invocation.mjs";
26
- import { readRunning } from "../shared/running-start.mjs";
27
26
 
28
27
  const UNIT_DIR = join(homedir(), ".config", "systemd", "user");
29
28
  const say = (s = "") => console.log(s);
@@ -92,31 +91,17 @@ for (const u of BACKGROUND_UNITS) {
92
91
  }
93
92
  // THE COMMENT HERE ALREADY NAMED THE CAUSE AND SHRUGGED AT IT. If there is no user bus, the disables
94
93
  // above did not happen either — so this is where that is said.
95
- // ONLY WHEN THERE WAS SOMETHING TO RELOAD. On a box with no unit installed the reload has nothing to do,
96
- // and asking anyway printed a bus error and `su` advice to a reader whose product was running in a
97
- // terminal — a WSL distribution without systemd meets exactly that (measured 2026-09-11).
98
- if (found) {
99
- try {
100
- execFileSync("systemctl", ["--user", "daemon-reload"], CAPTURE_STDERR);
101
- } catch (e) {
102
- const said = systemdSaid(e);
103
- err(` could not ask systemd to reload its units — ${said}`);
104
- if (looksLikeBusFailure(said)) err(`\n${busRemedy()}\n`);
105
- }
94
+ try {
95
+ execFileSync("systemctl", ["--user", "daemon-reload"], CAPTURE_STDERR);
96
+ } catch (e) {
97
+ const said = systemdSaid(e);
98
+ err(` could not ask systemd to reload its units — ${said}`);
99
+ if (looksLikeBusFailure(said)) err(`\n${busRemedy()}\n`);
106
100
  }
107
101
 
108
102
  if (!found) {
109
- // A FOREGROUND START IS NOT "NOTHING RUNNING". It is not this verb's to stop — its terminal holds it —
110
- // but saying nothing was running while it served is the answer that sent a reader looking elsewhere.
111
- const foreground = readRunning();
112
- if (foreground.length) {
113
- say(" No background product is installed, but the product is running in the foreground:");
114
- for (const r of foreground) say(` ${r.demo ? "the demo" : "the product"} at ${r.url} (pid ${r.pid})`);
115
- say(" Ctrl-C in the terminal that holds it stops it. This verb stops only what `start --background` installed.");
116
- } else {
117
- say(" Nothing was running in the background — no pinned unit is installed on this box.");
118
- say(" Nothing to do, and nothing was changed.");
119
- }
103
+ say(" Nothing was running in the background — no pinned unit is installed on this box.");
104
+ say(" Nothing to do, and nothing was changed.");
120
105
  } else if (failures.length) {
121
106
  // THE SENTENCE THAT WAS WRONG. "The background product is stopped and the box runs nothing again" was
122
107
  // printed unconditionally — including on the run where four services stayed up. A reader who is told
package/bin/update.mjs CHANGED
@@ -59,8 +59,6 @@ import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-p
59
59
  import { readEnvFile } from "./onboard.mjs";
60
60
  import { invoke, invocationPrefix } from "../shared/invocation.mjs"; // — name a command the reader can actually type
61
61
  import { rebuildIfStale } from "../shared/bundle-rebuild.mjs"; // a pull cannot update an untracked bundle
62
- import { packagedUpdate } from "../shared/permanent-install.mjs"; // — a packaged install updates at its own prefix
63
- import { installShim, inspectShim, shimPath } from "../shared/verb-shim.mjs"; // — npm's link replaces the launcher
64
62
 
65
63
  const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
66
64
  const ENV_PATH = envLocalPath({ repoRoot: REPO }); // resolved, never composed: one resolver, so moving this file later is one line
@@ -248,22 +246,13 @@ export async function update(argv = process.argv.slice(2)) {
248
246
  // not succeed` — true, useless, and pointing at the wrong thing entirely. `update` is the verb a
249
247
  // stranger reaches for, so the one install we expect most people to have must not be answered with
250
248
  // a git error about a directory that was never a repository.
251
- //
252
- // A PACKAGED INSTALL NOW UPDATES ITSELF, the way it was installed: npm, at the prefix it lives under, on
253
- // the channel it came from (shared/permanent-install.mjs). It runs AFTER the live-run refusal below,
254
- // because npm replaces the program's files as surely as `npm ci` does. Only a layout that cannot be
255
- // named is still refused.
256
- let packaged = null;
257
249
  if (!isGitCheckout()) {
258
- packaged = packagedUpdate();
259
- if (!packaged) {
260
- console.error("\n This install is not a git checkout, so there is nothing to pull.");
261
- console.error(" It was installed from a package rather than cloned, which is the ordinary way.");
262
- console.error("\n Update it the way it was installed:\n");
263
- console.error(" npm install -g clearotron@latest\n");
264
- console.error(" Nothing was touched.");
265
- return 4;
266
- }
250
+ console.error("\n This install is not a git checkout, so there is nothing to pull.");
251
+ console.error(" It was installed from a package rather than cloned, which is the ordinary way.");
252
+ console.error("\n Update it the way it was installed:\n");
253
+ console.error(" npm install -g clearotron@latest\n");
254
+ console.error(" Nothing was touched.");
255
+ return 4;
267
256
  }
268
257
 
269
258
  // ── AND THE SECOND REFUSAL: NOT OVER A LIVE RUN ──────────────────────────────────────────────
@@ -300,27 +289,6 @@ export async function update(argv = process.argv.slice(2)) {
300
289
  return 4;
301
290
  }
302
291
 
303
- if (packaged) {
304
- if (packaged.current) {
305
- say(`\n This install is ${packaged.installed}, and nothing newer is published (${packaged.tag}: ${packaged.version}). Nothing was touched.\n`);
306
- return 0;
307
- }
308
- if (packaged.unread) say(`\n npm did not say which versions are published, so this follows the ${packaged.tag} channel.`);
309
- say(`\n Updating this install at ${packaged.prefix} from ${packaged.installed ?? "an unreadable version"} to clearotron@${packaged.spec}.`);
310
- const rc = runInCheckout("npm", packaged.npmArgs);
311
- if (rc !== 0) return rc;
312
- // npm puts its own link back at `<prefix>/bin/clearotron` on every install, over the launcher the
313
- // install wrote, and that link runs whichever `node` is first on PATH. Put the launcher back, but only
314
- // over npm's link or our own: anything else there was not ours before this update either.
315
- const kind = inspectShim(shimPath()).kind;
316
- if (kind === "npm-link" || kind === "ours" || kind === "ours-other-install") {
317
- const shim = installShim();
318
- if (!shim.ok) console.error(`\n The update worked, but the launcher at ${shim.path ?? "~/.local/bin/clearotron"} could not be written back: ${shim.detail}.`);
319
- }
320
- say("\n Updated. An assistant starts the new version the next time it launches Clearotron; restart the services for the portal.\n");
321
- return 0;
322
- }
323
-
324
292
  say("\n Configuration store is outside the checkout. Updating the product.");
325
293
  for (const e of entries) say(` ${e.name}=${e.value} (${e.from})`);
326
294
  if (!entries.length) {
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "f0c89eaba3add8ab33c46e0434cb3333b876681f",
3
- "version": "0.3.0-beta.10"
2
+ "commit": "f24d87c471bd3073f3a56dbf0edff39d1a640175",
3
+ "version": "0.3.0-beta.2"
4
4
  }
@@ -27,7 +27,7 @@
27
27
  "stepTotal": 9,
28
28
  "lastStage": "publish",
29
29
  "verdict": "CONDITIONAL",
30
- "url": "/portal/report/tmpdemo2014fullcountrysearch-venqori-2026-09-03-sample-capture/",
30
+ "url": "https://test-trademark.cordillera.ch/tmpdemo2014fullcountrysearch-venqori-2026-09-03-sample-capture/report.html",
31
31
  "failedStage": null,
32
32
  "reason": null,
33
33
  "deliveredAt": "2026-09-03T18:06:17.281Z",
@@ -27,7 +27,7 @@
27
27
  "stepTotal": 9,
28
28
  "lastStage": "publish",
29
29
  "verdict": "CONDITIONAL",
30
- "url": "/portal/report/tmpdemo2014globalpreliminarysearch-venqori-2026-09-02-sample-capture/",
30
+ "url": "https://test-trademark.cordillera.ch/tmpdemo2014globalpreliminarysearch-venqori-2026-09-02-sample-capture/report.html",
31
31
  "failedStage": null,
32
32
  "reason": null,
33
33
  "deliveredAt": "2026-09-02T17:33:15.625Z",
@@ -1,3 +1,3 @@
1
- <p style="font-family:Calibri,sans-serif;font-size:11pt">Knockout trademark review — DEMO-2014-knockout-search: <b>1 mark</b>, worst band <b style="color:#2f55a4">Low</b>. <a href="/portal/report/tmpdemo2014knockoutsearch-venqori-2026-09-02-sample-capture/">Open the full report</a> · <a href="/portal/report/tmpdemo2014knockoutsearch-venqori-2026-09-02-sample-capture/audit.xlsx">audit workbook</a>.</p>
1
+ <p style="font-family:Calibri,sans-serif;font-size:11pt">Knockout trademark review — DEMO-2014-knockout-search: <b>1 mark</b>, worst band <b style="color:#2f55a4">Low</b>. <a href="https://test-trademark.cordillera.ch/tmpdemo2014knockoutsearch-venqori-2026-09-02-sample-capture/report.html">Open the full report</a> · <a href="https://test-trademark.cordillera.ch/portal/report/tmpdemo2014knockoutsearch-venqori-2026-09-02-sample-capture/audit.xlsx">audit workbook</a>.</p>
2
2
  <ul style="font-family:Calibri,sans-serif;font-size:11pt"><li><b>VENQORI</b>: <b style="color:#2f55a4">Low</b> — Classes 9, 42, 41 — Register filings (classes 9, 42, 41): 0 identical, 0 containing, 0 on close variations.</li></ul>
3
3
  <p style="font-family:Calibri,sans-serif;font-size:11pt"><i>Ratings reflect our common law assessment. Register analysis may adjust ratings in either direction.</i></p>
@@ -33,7 +33,7 @@
33
33
  "stepN": 6,
34
34
  "stepTotal": 6,
35
35
  "verdict": "Low",
36
- "url": "/portal/report/tmpdemo2014knockoutsearch-venqori-2026-09-02-sample-capture/",
36
+ "url": "https://test-trademark.cordillera.ch/tmpdemo2014knockoutsearch-venqori-2026-09-02-sample-capture/report.html",
37
37
  "failedStage": null,
38
38
  "reason": null,
39
39
  "deliveredAt": "2026-09-02T21:46:53.238Z",
@@ -254,7 +254,7 @@
254
254
  "reports": [
255
255
  {
256
256
  "mark": "VENQORI",
257
- "url": "/portal/report/tmpdemo2014knockoutsearch-venqori-2026-09-02-sample-capture/"
257
+ "url": "https://test-trademark.cordillera.ch/tmpdemo2014knockoutsearch-venqori-2026-09-02-sample-capture/report.html"
258
258
  }
259
259
  ],
260
260
  "sendPending": true,
@@ -27,7 +27,7 @@
27
27
  "stepTotal": 9,
28
28
  "lastStage": "publish",
29
29
  "verdict": "CONDITIONAL",
30
- "url": "/portal/report/tmpdemo2014multicountryfocussearch-venqori-2026-09-02-sample-capture/",
30
+ "url": "https://test-trademark.cordillera.ch/tmpdemo2014multicountryfocussearch-venqori-2026-09-02-sample-capture/report.html",
31
31
  "failedStage": null,
32
32
  "reason": null,
33
33
  "deliveredAt": "2026-09-02T19:51:48.801Z",