clearotron 0.3.0-beta.8 → 0.3.0

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.
package/INSTALL.md CHANGED
@@ -295,6 +295,71 @@ keeps working throughout.
295
295
  provider, so it is safe to run on any machine that has the repository. "Safe anywhere" is about spend,
296
296
  not about where it will run — on a packaged tree it refuses, as above.
297
297
 
298
+ ## 2a. Removing it
299
+
300
+ An install is not one directory, so removing it is not one command. Everything it writes is listed here.
301
+ Read the list before you delete anything: one of these directories holds your reports.
302
+
303
+ **Stop it first.** If the product is running in a terminal, Ctrl-C in that terminal. If you installed the
304
+ background services, `clearotron stop` stops them and removes their unit files. `clearotron status` says
305
+ which of the two you have.
306
+
307
+ **The program.** Installed globally, `npm rm -g clearotron` removes it. Installed by `clearotron install`
308
+ (which is also what a demo run from `npx` leaves behind), it lives in two places under `~/.local`:
309
+
310
+ ```
311
+ ~/.local/lib/node_modules/clearotron
312
+ ~/.local/bin/clearotron
313
+ ```
314
+
315
+ **The settings, and the keys.** `~/.config/clearotron/.env` holds this install's signing secrets, so
316
+ deleting it invalidates every key it ever issued. Beside it: `~/.config/clearotron/token-denylist`, the
317
+ list of keys you revoked, and `~/.config/clearotron/running`, which records a product running in a
318
+ terminal and is rewritten on every start.
319
+
320
+ **Your work.** The install keeps it all under one directory, `~/trademark` by default — wherever
321
+ `CLEAROTRON_REPORTS_DIR`'s parent points if you moved it:
322
+
323
+ ```
324
+ ~/trademark/pool THE REPORTS. Every clearance this install delivered.
325
+ ~/trademark/workspace the working files of each run, including unfinished ones
326
+ ~/trademark/queue work ordered and not yet run
327
+ ~/trademark/outbox what the product prepared to send
328
+ ~/trademark/locks which runs are in flight
329
+ ~/trademark/config your companies, their projects and your saved searches
330
+ ~/trademark/config/recipes the saved searches themselves
331
+ ~/trademark/config/profiles the company profiles
332
+ ~/trademark/grants.json who may use this install
333
+ ~/trademark/portal-audit.log what the portal was asked to do
334
+ ~/trademark/portal-local-credential.json the sign-in this install minted for you
335
+ ```
336
+
337
+ **Keep the pool if you want the reports.** They are finished documents and nothing else reads them: move
338
+ `~/trademark/pool` somewhere of your own and delete the rest. Deleting the pool deletes the clearances.
339
+
340
+ **The demo, if you ran one.** `~/trademark-demo` holds the demo's own copy of everything above, including
341
+ its own copy of the program under `~/trademark-demo/program`. It is one directory to remove and it shares
342
+ nothing with an install.
343
+
344
+ **The background services, if you installed them.** `clearotron stop` removes the four unit files; if you
345
+ would rather see them, they are in `~/.config/systemd/user`:
346
+
347
+ ```
348
+ clearotron-portal.service
349
+ clearotron-mcp-face.service
350
+ clearotron-worker.service
351
+ clearotron-client-mcp.service
352
+ ```
353
+
354
+ **And `~/.env`, which only a background install writes.** A service inherits nothing from the terminal
355
+ that installed it, so `clearotron start --background` writes everything those services need into `~/.env`,
356
+ mode 600 — your register credential, your research key and the engine's settings among it. It is not the
357
+ same file as `~/.config/clearotron/.env`, which configures the product when you run it yourself. Delete
358
+ both, or you leave a file of credentials in your home for services that no longer exist.
359
+
360
+ Nothing else on the machine is ours: no system-wide files, no services outside your own user, and nothing
361
+ in `/etc`, `/usr` or `/var`.
362
+
298
363
  ## 3. Configuration (environment)
299
364
 
300
365
  Runtime configuration is by environment variable, and there are two ways to supply them.
package/README.md CHANGED
@@ -59,6 +59,9 @@ npx clearotron@beta install
59
59
  What each channel promises, and when a stable is cut: [docs/RELEASES.md](docs/RELEASES.md). If you are not
60
60
  sure, the first command is the one you want.
61
61
 
62
+ Removing it later: **[INSTALL.md §2a](INSTALL.md#2a-removing-it)** lists every path an install writes, and
63
+ says which one holds your reports so you can keep them deliberately.
64
+
62
65
  ## Quick start
63
66
 
64
67
  With it installed, check what it found before it does anything. `doctor` only reads — it writes nothing,
package/bin/key.mjs CHANGED
@@ -29,7 +29,9 @@
29
29
  import "../shared/env-local.mjs"; // side effect: apply the install's .env — FIRST, before anything reads process.env
30
30
  import { invocationPrefix } from "../shared/invocation.mjs";
31
31
  import { existsSync, readFileSync } from "node:fs";
32
- import { defaultGrantsPath } from "./start.mjs";
32
+ import { defaultGrantsPath, installPaths } from "./start.mjs";
33
+ import { demoTokenSecretPath } from "../shared/client-door.mjs";
34
+ import { resolvePerson } from "../shared/scope.mjs"; // the door's own reading of the guest list, so one answer serves both
33
35
  import { mintFromOptions } from "../mcp-server/mint-token.mjs";
34
36
 
35
37
  const argv = process.argv.slice(2);
@@ -44,6 +46,7 @@ const USAGE = `usage: ${p}clearotron key issue <email> [options]
44
46
 
45
47
  --accounts a,b cap the key to these account keys as well (omit = whatever their grant allows)
46
48
  --ttl-days <n> how long it is valid (default 90)
49
+ --base <dir> the install or demo to issue for, when it is not the one set up in ~/trademark
47
50
 
48
51
  The token is printed ONCE on stdout and stored nowhere — possession is the credential. Everything
49
52
  else goes to stderr, including the jti that revokes it.`;
@@ -65,6 +68,17 @@ const email = positional[0];
65
68
  if (!email) die(`who is the key for? Give the person's email.\n\n${USAGE}`);
66
69
  if (!email.includes("@")) die(`"${email}" is not an email address — the subject of an account key is the identity their assistant presents, and the grants file is keyed on it.`);
67
70
 
71
+ // `--base` NAMES THE INSTALL, as it does for `start` and `passphrase`: its guest list, and — for a demo,
72
+ // which keeps its signing secret in its own base rather than in any settings file — that secret. Without
73
+ // it the verb read the install's settings and refused to issue a key for a running demo.
74
+ const baseAt = rest.indexOf("--base");
75
+ const base = baseAt >= 0 ? rest[baseAt + 1] : null;
76
+ if (baseAt >= 0 && (!base || base.startsWith("--"))) die(`--base needs a directory.\n\n${USAGE}`);
77
+ if (base) {
78
+ const secretFile = demoTokenSecretPath(base);
79
+ if (existsSync(secretFile)) process.env.TRADEMARK_MCP_TOKEN_SECRET = readFileSync(secretFile, "utf8").trim();
80
+ }
81
+
68
82
  const ttlDays = Number(flag("--ttl-days") ?? 90);
69
83
  const accounts = flag("--accounts") ? flag("--accounts").split(",").map((s) => s.trim()).filter(Boolean) : null;
70
84
 
@@ -93,15 +107,19 @@ for (const line of minted.notes) console.error(line);
93
107
  // inert. STDERR, so the token stays alone on stdout and `key issue ... > token.txt` keeps working —
94
108
  // that split is deliberate and this must not undo it.
95
109
  try {
96
- const rosterPath = defaultGrantsPath();
110
+ const rosterPath = base ? installPaths(base).grants : defaultGrantsPath();
97
111
  if (!existsSync(rosterPath)) {
98
112
  console.error(`\nNOTE: no guest list at ${rosterPath} yet, so nothing grants ${email} anything and this key will be refused at the door. \`clearotron start\` writes the list; then: clearotron grant add ${email} --tenant <name> --accounts <brand-owner-key>`);
99
113
  } else {
114
+ // THE DOOR'S OWN RESOLVER DECIDES, not a second reading of the file. This walked `tenants[].users`
115
+ // alone, and the demo's guest list grants its account through the top-level `people` map — so the
116
+ // demo's own key came with a note saying the door would refuse it, over a key the door accepts
117
+ // (driven 2026-09-11). One reader for one question.
100
118
  const roster = JSON.parse(readFileSync(rosterPath, "utf8"));
101
- const tenants = roster?.tenants && typeof roster.tenants === "object" ? roster.tenants : {};
102
- const listed = Object.values(tenants).some((t) => t?.users && Object.prototype.hasOwnProperty.call(t.users, email));
119
+ const person = resolvePerson(email, roster);
120
+ const listed = Boolean(person && (person.everything || person.accounts?.length || person.organisations?.length));
103
121
  if (!listed)
104
- console.error(`\nNOTE: ${email} is on no tenant in ${rosterPath}, so this key resolves to no accounts and the door will refuse it. Grant them access with: clearotron grant add ${email} --tenant <name> --accounts <brand-owner-key>`);
122
+ console.error(`\nNOTE: ${email} is granted nothing in ${rosterPath}, so this key resolves to no accounts and the door will refuse it. Grant them access with: clearotron grant add ${email} --tenant <name> --accounts <brand-owner-key>`);
105
123
  }
106
124
  } catch (e) {
107
125
  // A ROSTER THIS COMMAND CANNOT READ IS NOT A ROSTER SAYING THE SUBJECT IS ABSENT. Said as what it is,
package/bin/onboard.mjs CHANGED
@@ -116,7 +116,7 @@ import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-p
116
116
  // one synopsis reader for every verb that prints one.
117
117
  import { usageBlock } from "../shared/usage-block.mjs";
118
118
  import { invoke } from "../shared/invocation.mjs"; // — name a command the reader can actually type
119
- import { parseEnvFile } from "../driver/systemd/render-units.mjs"; // — ONE KEY=value reader; a second copy would drift from what systemd actually reads
119
+ import { parseEnvFile } from "../shared/env-file-merge.mjs"; // — ONE KEY=value reader, taken from a leaf: the unit renderer re-exports this same function, and it is a COMMAND, so importing it from here would put a command in this binary's graph
120
120
  import { unitEnvironment, unitValue, couldNotDetermine } from "../driver/unit-environment.mjs"; // — F34: claim about the UNITS only from the units' own environment
121
121
 
122
122
  /**
@@ -1503,6 +1503,9 @@ export async function runCheck() {
1503
1503
  } else if (form.form === "shim-path") {
1504
1504
  warn(`${form.dir} is not on this shell's PATH, so the bare \`clearotron\` will not resolve here`);
1505
1505
  info(`add it with: export PATH="${form.dir}:$PATH" — or open a new login shell`);
1506
+ } else if (form.form === "npx-pinned") {
1507
+ info(`this is running from npm's npx cache, so the commands below name this version through npx (\`${form.prefix.trim()}\`), `
1508
+ + `which works from any directory and after npm cleans its cache; \`${invoke("install")}\` puts \`clearotron\` on your PATH`);
1506
1509
  } else if (form.shimKind === "ours-other-install") {
1507
1510
  warn(`${form.shim} is a shim for a DIFFERENT install (${form.otherInstall})`);
1508
1511
  info(`re-run \`${invoke("install")}\` to point the bare name at this one`);
package/bin/start.mjs CHANGED
@@ -118,15 +118,16 @@ import { SERVER_INSTALL_SET, unitsToRestartOnRefresh, unitHealthVerdict } from "
118
118
  // — the door --background now INSTALLS, and the one authority for the settings
119
119
  // it refuses to start without. (Until 2026-09-03 this import read "the one unit --background may
120
120
  // tolerate and never manage"; settled point 2 superseded that.)
121
- import { defaultDenylistPath, denylistPathFor, denylistFor, ensureDenylistFile, CLIENT_DOOR_UNIT, enablePlan, clientDoorPort } from "../shared/client-door.mjs"; // — one owner for the revocation list's path
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
122
122
  import { createServer } from "node:net";
123
123
  import { listenErrorMessage, nextFreePort } from "../shared/listen.mjs";
124
124
  import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
125
125
  import { randomBytes } from "node:crypto";
126
126
  import { invocationPrefix, invoke, reachableCommand } from "../shared/invocation.mjs"; // — the banner names the verb
127
- import { unitEnvPath } from "../shared/env-local.mjs"; // — the file the units read, named once
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
128
129
  import { homedir, userInfo } from "node:os";
129
- import { dirname, join } from "node:path";
130
+ import { dirname, join, resolve, sep } from "node:path";
130
131
  import { fileURLToPath } from "node:url";
131
132
  import { usageBlock } from "../shared/usage-block.mjs";
132
133
  import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-point test, all spellings
@@ -142,6 +143,7 @@ import { addressRefusal } from "../shared/staff-domain.mjs";
142
143
  import { withPerson, withOrganisation, withCompany } from "../shared/grants-edit.mjs";
143
144
  import { assertGrantsShape, resolvePerson } from "../shared/scope.mjs";
144
145
  import { backgroundManager } from "../shared/os-advice.mjs";
146
+ import { recordRunning } from "../shared/running-start.mjs";
145
147
  import { frontingVariablesSet } from "../shared/install-auth.mjs"; // — one owner for what counts as a proxy in front of a door
146
148
 
147
149
  const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
@@ -909,6 +911,11 @@ if (isMain) {
909
911
  // Decided before any path is, because in a demo every path below is the demo's own. The posture
910
912
  // itself is described at the DEMO block further down.
911
913
  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.");
912
919
  // The same base `npm run setup` writes under, so whichever of the two a reader ran first, the other
913
920
  // finds the same install rather than a second one beside it.
914
921
  // Whatever the environment already says wins over the base-derived default, for every path — a reader
@@ -916,6 +923,35 @@ if (isMain) {
916
923
  // which `startPaths` says why. One author, because `doctor` asks the same function what the services
917
924
  // were handed.
918
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.");
954
+ }
919
955
  // ── THIS INSTALL'S FIRST START, read before this start writes either file that answers it ────────────
920
956
  //
921
957
  // The grants file and the config store's repository are both written further down, on every start
@@ -1171,7 +1207,15 @@ if (isMain) {
1171
1207
  return v;
1172
1208
  };
1173
1209
  const portalSecret = secretFor("PORTAL_SECRET");
1174
- const tokenSecret = secretFor("TRADEMARK_MCP_TOKEN_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");
1175
1219
  if (!process.env.PORTAL_LOCAL_USER) generated.PORTAL_LOCAL_USER = user;
1176
1220
  const stores = DEMO ? {} : storesForOtherReaders(paths);
1177
1221
 
@@ -2176,12 +2220,26 @@ if (isMain) {
2176
2220
  if (adoptedClientDoor)
2177
2221
  say(` Already running as ${CLIENT_DOOR_UNIT}; this start kept it, so existing keys still work.`);
2178
2222
  else
2179
- say(` It refuses every caller until a key is issued: ${invocationPrefix()}clearotron key issue <email>`);
2223
+ say(` It refuses every caller until a key is issued: ${keyIssueCommand({ prefix: invocationPrefix(), demo: DEMO, user, base: paths.base, defaultBase: join(homedir(), "trademark") })}`);
2180
2224
  } else {
2181
2225
  say(` Client door NOT RUNNING on ${HOST}:${ports.client} — its output above says why. The portal and`);
2182
2226
  say(" the engine door are unaffected; a client assistant cannot connect until it is up.");
2183
2227
  }
2184
2228
  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
+ }
2185
2243
  // — THE BANNER TOLD THE SAME STORY ON EVERY START, and it was only true of the first.
2186
2244
  //
2187
2245
  // "printed once, above" describes what a FIRST start does. On every start after it the passphrase was
@@ -2273,17 +2331,35 @@ if (isMain) {
2273
2331
  // cannot succeed and given a service manager that is not on the machine and cannot be put there.
2274
2332
  // Reported from a real run. Same rule as the engine refusal above: do not name a route this platform
2275
2333
  // does not have.
2276
- const manager = backgroundManager();
2277
- if (manager) {
2278
- say(` To get your prompt back instead, stop this and run ${invoke("start")} --background`);
2279
- say(` — same product, managed by ${manager}, and it survives logout.`);
2280
- } else {
2281
- say(" There is no background form on this platform: the product runs as long as this window does.");
2282
- say(" Leave it open and use a second terminal for the commands above.");
2283
- }
2334
+ for (const line of backgroundOfferLines({ demo: DEMO, manager: backgroundManager(), start: invoke("start") })) say(line);
2284
2335
  say("");
2285
2336
  }
2286
2337
 
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
+
2287
2363
  /**
2288
2364
  * What a reader is told when a child this install cannot run without exits.
2289
2365
  *
package/bin/status.mjs CHANGED
@@ -15,9 +15,10 @@ import { join } from "node:path";
15
15
  import { homedir } from "node:os";
16
16
  import { execFileSync } from "node:child_process";
17
17
  import { BACKGROUND_UNITS, resolvePorts } from "./start.mjs";
18
- import { parseEnvFile } from "../driver/systemd/render-units.mjs"; // ONE KEY=value reader — what systemd actually reads
18
+ import { parseEnvFile } from "../shared/env-file-merge.mjs"; // ONE KEY=value reader — what systemd actually reads, taken from the leaf the unit renderer re-exports it from
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";
21
22
  import { configStaleness, stalenessWarning, parseSystemdTimestamp } from "../driver/config-staleness.mjs"; // — F48
22
23
 
23
24
  const UNIT_DIR = join(homedir(), ".config", "systemd", "user");
@@ -57,15 +58,34 @@ const startedEpochMs = (u) => {
57
58
  return parseSystemdTimestamp(line?.slice("ActiveEnterTimestamp=".length));
58
59
  } catch { return null; }
59
60
  };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
+ }
60
78
  let installed = 0;
61
79
  for (const u of BACKGROUND_UNITS) {
62
80
  if (!existsSync(join(UNIT_DIR, u))) continue;
63
81
  installed++;
64
82
  say(` ${u.padEnd(34)} ${state(u)}`);
65
83
  }
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.`);
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.`);
69
89
  } else {
70
90
  // The ports come from the same resolver start uses, RESOLVED OVER THE UNITS' OWN FILE: the running
71
91
  // 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,6 +23,7 @@ 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";
26
27
 
27
28
  const UNIT_DIR = join(homedir(), ".config", "systemd", "user");
28
29
  const say = (s = "") => console.log(s);
@@ -91,17 +92,31 @@ for (const u of BACKGROUND_UNITS) {
91
92
  }
92
93
  // THE COMMENT HERE ALREADY NAMED THE CAUSE AND SHRUGGED AT IT. If there is no user bus, the disables
93
94
  // above did not happen either — so this is where that is said.
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`);
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
+ }
100
106
  }
101
107
 
102
108
  if (!found) {
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.");
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
+ }
105
120
  } else if (failures.length) {
106
121
  // THE SENTENCE THAT WAS WRONG. "The background product is stopped and the box runs nothing again" was
107
122
  // printed unconditionally — including on the run where four services stayed up. A reader who is told
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "6c0b09af72bcc7b6e57f5aa268ffb29361d1eaab",
3
- "version": "0.3.0-beta.8"
2
+ "commit": "cd86797b655d82bc021e2ada72cbacdc261174dc",
3
+ "version": "0.3.0"
4
4
  }
package/docs/RELEASES.md CHANGED
@@ -54,5 +54,5 @@ Downgrading is an ordinary install of the older version. Your configuration and
54
54
  by either — they live outside the package — so moving between channels is not a migration.
55
55
 
56
56
  Every version's notes are on the [releases page](https://github.com/CordilleraSarl/clearotron/releases)
57
- and in `CHANGELOG.md`, grouped New / Fixed / For operators. A beta's notes are the same notes; the stable
57
+ and in `CHANGELOG.md`, grouped Before you upgrade / New / Fixed / For operators. A beta's notes are the same notes; the stable
58
58
  that follows aggregates all of them into one entry.
@@ -1,5 +1,49 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d2afa5e: Before you upgrade: Name every person who uses your installation in the file that lists who may sign in, or the portal will not start. That file is in your install folder.
8
+ - d2afa5e: New: Switch between companies from any screen, and set a new one up in the browser.
9
+ - d2afa5e: New: Add people from the portal, and give each the right to run clearances, to manage people and companies, or both.
10
+ - d2afa5e: New: Every register finding in a report links to the trade mark office's own page for that record.
11
+ - d2afa5e: New: A clearance records any territory it could not cover, so a partial search never reads as a complete one.
12
+ - d2afa5e: New: Connect Clearotron to your own AI assistant — Claude Code, Codex, or ChatGPT — on your computer or a shared server.
13
+ - d2afa5e: New: A saved search is available in every company, in the portal and in your AI assistant.
14
+ - d2afa5e: New: Check a risk framework for errors before it rates a clearance, and point a company at its own.
15
+ - d2afa5e: New: Group companies under an organisation, each with its own daily limit on clearances.
16
+ - d2afa5e: New: Clearotron installs to a fixed location, so the commands it prints and your AI assistant's connection keep working.
17
+ - d2afa5e: New: `clearotron update` moves an installation, beta included, to the current release.
18
+
19
+ ### Patch Changes
20
+
21
+ - d2afa5e: Fixed: You sign in, open your companies, and run your first clearance on a new installation.
22
+ - d2afa5e: Fixed: The demo reads and writes only its own companies, reports, and saved searches.
23
+ - d2afa5e: Fixed: `clearotron doctor` checks what a clearance needs, so an installation it clears can run one.
24
+ - d2afa5e: Fixed: A key you paste at a yes-or-no question stays off the screen and out of your command history.
25
+ - d2afa5e: Fixed: Clearotron stays signed in to a paid Codex plan when Codex renews the sign-in.
26
+ - d2afa5e: Fixed: The dashboard shows only the chosen company's clearances, and keeps the filters on screen.
27
+ - d2afa5e: Fixed: The Stop button ends a run before its report goes out, and its dialog says what stopping does.
28
+ - d2afa5e: Fixed: A knockout report names the filings behind each finding, and marks the reviewer's notes as reference.
29
+
30
+ ## 0.3.0-beta.10
31
+
32
+ ### Patch Changes
33
+
34
+ - fd3a4f7: Fixed: A portal address that does not exist now says so wherever it is. Addresses under the admin path used to show the Global config screen.
35
+ - fd3a4f7: New: The install guide now says how to remove Clearotron, naming every path it writes and which one holds your reports.
36
+
37
+ ## 0.3.0-beta.9
38
+
39
+ ### Patch Changes
40
+
41
+ - 86061f4: Fixed: Commands printed while running from npx name the published version, so they still work after npm cleans its cache.
42
+ - ab9dc2d: Fixed: When the engine cannot answer "Describe it", the page names the check that finds out why. It says "just now" only for a usage limit.
43
+ - ab9dc2d: Fixed: `clearotron status` says whether a product started in a terminal is up, and on which addresses. `clearotron stop` says how to stop it.
44
+ - ab9dc2d: Fixed: The demo no longer offers `start --background`, which set up an empty install in your home instead of running the demo.
45
+ - ab9dc2d: Fixed: The key command the demo prints issues a key the demo's client door accepts, run exactly as printed.
46
+
3
47
  ## 0.3.0-beta.8
4
48
 
5
49
  ### Patch Changes
@@ -325,7 +325,26 @@ export function jsonFromTurnText(text) {
325
325
  return null;
326
326
  }
327
327
 
328
- export function makeComposeReader({ turn, now = () => new Date() } = {}) {
328
+ /**
329
+ * What the page says when the engine did not answer a read.
330
+ *
331
+ * "JUST NOW" ONLY WHERE IT IS TRUE. Every failed turn said the reader "could not reach the engine just
332
+ * now", and on a fresh install whose engine was not signed in that sent a stranger to press the button
333
+ * again, forever: the failure was the sign-in, which no retry changes (measured on a published beta,
334
+ * 2026-09-11). A rate limit is the one failure known to pass, so it alone says so, with its reset time
335
+ * when the engine gave one. Everything else names the check that finds the cause — `checkCommand`, in the
336
+ * reader's own invocation and with no machine path, because the page is not a place to print one.
337
+ */
338
+ export function engineFailureMessage(t, { checkCommand = "clearotron doctor --probe-engine" } = {}) {
339
+ if (t?.rateLimited) {
340
+ const at = t.resetsAt ? new Date(t.resetsAt) : null;
341
+ const when = at && !Number.isNaN(at.getTime()) ? ` It resets at ${at.toISOString().slice(11, 16)} UTC.` : "";
342
+ return `The engine is at its usage limit just now.${when} Try again then, or set the search up below.`;
343
+ }
344
+ return `The request could not be read: the engine did not answer it. \`${checkCommand}\` finds out why — set the search up below meanwhile.`;
345
+ }
346
+
347
+ export function makeComposeReader({ turn, now = () => new Date(), checkCommand } = {}) {
329
348
  if (typeof turn !== "function") return null;
330
349
  return async function read(brief) {
331
350
  const text = String(brief ?? "").trim();
@@ -361,7 +380,7 @@ export function makeComposeReader({ turn, now = () => new Date() } = {}) {
361
380
  + `${JSON.stringify(READ_SCHEMA)}\n\nThe brief:\n${text}`,
362
381
  });
363
382
  if (!t?.ok) {
364
- return { ok: false, error: "engine", message: "The reader could not reach the engine just now — set the search up below.",
383
+ return { ok: false, error: "engine", message: engineFailureMessage(t, { ...(checkCommand ? { checkCommand } : {}) }),
365
384
  cause: t?.cause ?? "the engine turn did not complete", vendor: t?.vendor ?? null, authMode: t?.authMode ?? null,
366
385
  engine: t?.engine ?? null, model: t?.model ?? null };
367
386
  }
@@ -94,7 +94,10 @@ export function readJxTuple(tuple, { vendor, authMode, engine }) {
94
94
  if (tuple?.killed || tuple?.signals?.stalled)
95
95
  return { ok: false, cause: "the engine turn was killed before it answered (stall or wall)", truncationObservable: observable, ...base };
96
96
  if (tuple?.signals?.rateLimited)
97
- return { ok: false, cause: "the engine turn was rate-limited", truncationObservable: observable, ...base };
97
+ // FLAGGED, not only described: a rate limit is the one failure a caller may call passing, and it
98
+ // needs to know that without parsing this sentence. `resetsAt` is the adapter's, when it read one.
99
+ return { ok: false, cause: "the engine turn was rate-limited", rateLimited: true, resetsAt: tuple?.signals?.resetsAt ?? null,
100
+ truncationObservable: observable, ...base };
98
101
  if (tuple?.code !== 0 || tuple?.json?.status !== "ok")
99
102
  return { ok: false, cause: `the engine turn did not complete cleanly (code ${tuple?.code ?? "?"}, status ${tuple?.json?.status ?? "none"})`,
100
103
  truncationObservable: observable, ...base };
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.0-beta.8",
5
+ "version": "0.3.0",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {
@@ -1590,7 +1590,10 @@ export function makePortalService({
1590
1590
  // A provider outage is not a portal fault and must not read as one. The composer shows the
1591
1591
  // sentence and keeps every field the user already typed — the brief is still in the box.
1592
1592
  audit({ event: "compose-read", by: principal.email, ok: false, error: String(e?.message ?? e) });
1593
- return { status: 502, json: { code: "read_failed", error: "The reader is not answering just now set the search up below." } };
1593
+ // NOT "just now": a throw says nothing about whether it will pass, and that wording sent a reader
1594
+ // back to the button for a failure no retry fixes. It names the check instead.
1595
+ return { status: 502, json: { code: "read_failed",
1596
+ error: `The request could not be read: the reader failed. \`${browserCommand("doctor --probe-engine")}\` finds out why — set the search up below meanwhile.` } };
1594
1597
  }
1595
1598
  // Length, never content. A brief is client material — whose mark, for whose product, before
1596
1599
  // whose deadline — and an audit log is a different disclosure surface from a run store.
@@ -4711,7 +4714,8 @@ const PORT = PORT_CHOICE.port;
4711
4714
  return null;
4712
4715
  }
4713
4716
  log(`brief reading ON — engine=${runner.engine} billing=${runner.authMode} model=${model}`);
4714
- return makeComposeReader({ turn: runner.turn });
4717
+ // The check a failed read points at, as a reader types it, with no path of this machine in it.
4718
+ return makeComposeReader({ turn: runner.turn, checkCommand: browserCommand("doctor --probe-engine") });
4715
4719
  } catch (e) {
4716
4720
  // A missing dependency or a bad key shape must not stop the portal booting: everything else on
4717
4721
  // this service is the load-bearing product, and the composer degrades to exactly what it did