clearotron 0.3.0-beta.1 → 0.3.0-beta.10

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 (107) hide show
  1. package/.env.example +26 -5
  2. package/CONTRIBUTING.md +1 -1
  3. package/INSTALL.md +122 -16
  4. package/README.md +31 -20
  5. package/bin/brandowner.mjs +94 -1
  6. package/bin/connect.mjs +71 -26
  7. package/bin/disconnect.mjs +16 -11
  8. package/bin/example.mjs +14 -2
  9. package/bin/key.mjs +23 -5
  10. package/bin/onboard.mjs +346 -98
  11. package/bin/passphrase.mjs +23 -4
  12. package/bin/start.mjs +363 -60
  13. package/bin/status.mjs +23 -3
  14. package/bin/stop.mjs +23 -8
  15. package/bin/update.mjs +38 -6
  16. package/build-info.json +2 -2
  17. package/demo/full-country-search/run/status.json +1 -1
  18. package/demo/global-preliminary-search/run/status.json +1 -1
  19. package/demo/knockout-search/run/email-body.md +1 -1
  20. package/demo/knockout-search/run/status.json +2 -2
  21. package/demo/multi-country-focus-search/run/status.json +1 -1
  22. package/docs/architecture/04-configuration-reference.md +6 -2
  23. package/docs/architecture/05-config-governance.md +3 -2
  24. package/driver/CHANGELOG.md +199 -5
  25. package/driver/compose-read.mjs +21 -2
  26. package/driver/contract-e3-backlog.mjs +1 -1
  27. package/driver/demo-container.mjs +65 -2
  28. package/driver/demo-posture.mjs +59 -8
  29. package/driver/driver.config.mjs +41 -7
  30. package/driver/engine/anthropic-agent.mjs +34 -17
  31. package/driver/engine/child-record.mjs +93 -0
  32. package/driver/engine/jx-turn.mjs +4 -1
  33. package/driver/engine/openai-agent.mjs +60 -2
  34. package/driver/enqueue-schema.mjs +3 -1
  35. package/driver/findings-model.mjs +6 -1
  36. package/driver/gateway.mjs +88 -15
  37. package/driver/knockout-assess-record.mjs +6 -3
  38. package/driver/package.json +1 -1
  39. package/driver/portal-local-auth.mjs +132 -5
  40. package/driver/portal-service.mjs +156 -51
  41. package/driver/portal-upstream.mjs +20 -1
  42. package/driver/profile-service.mjs +31 -3
  43. package/driver/publish/index.mjs +12 -9
  44. package/driver/publish/knockout.mjs +14 -1
  45. package/driver/publish/office-record-links.mjs +111 -25
  46. package/driver/publish/render-knockout.mjs +31 -11
  47. package/driver/publish/xlsx.mjs +33 -1
  48. package/driver/recipe-service.mjs +14 -5
  49. package/driver/record-origins.mjs +14 -0
  50. package/driver/register-records.mjs +6 -0
  51. package/driver/run-requirements.mjs +24 -1
  52. package/driver/runner.mjs +10 -5
  53. package/driver/search-policy.mjs +7 -2
  54. package/driver/skills/knockout-assess/SKILL.md +1 -1
  55. package/driver/suite-census.json +326 -74
  56. package/driver/systemd/render-units.mjs +7 -13
  57. package/driver/unit-inventory.mjs +109 -5
  58. package/driver/updater-identity.mjs +178 -0
  59. package/driver/usage-ledger.mjs +5 -5
  60. package/mcp-server/CHANGELOG.md +42 -2
  61. package/mcp-server/CONNECT.md +3 -2
  62. package/mcp-server/http-server.mjs +16 -13
  63. package/mcp-server/lib/driver.mjs +9 -0
  64. package/mcp-server/lib/knockout.mjs +14 -2
  65. package/mcp-server/lib/ops.mjs +38 -16
  66. package/mcp-server/lib/options.mjs +20 -5
  67. package/mcp-server/package.json +2 -2
  68. package/mcp-server/server.mjs +1 -1
  69. package/package.json +1 -1
  70. package/portal-ui/dist/assets/{index-KpytsmNH.css → index-Cv-E_agg.css} +206 -88
  71. package/portal-ui/dist/assets/{index-CWTHP0sH.js → index-CwPAS0we.js} +556 -399
  72. package/portal-ui/dist/index.html +2 -2
  73. package/portal-ui/package.json +3 -3
  74. package/providers/clarivate/src/core.js +5 -0
  75. package/providers/oauth-mcp-bridge/CHANGELOG.md +36 -0
  76. package/providers/oauth-mcp-bridge/package.json +2 -2
  77. package/scripts/changelog-plain-language.mjs +7 -30
  78. package/scripts/e2e-unread-terminals.mjs +1 -1
  79. package/scripts/e2e.mjs +210 -22
  80. package/scripts/engine-probe.mjs +2 -2
  81. package/scripts/env-audit.mjs +10 -3
  82. package/scripts/env-classify.mjs +208 -12
  83. package/scripts/live-surface-check.mjs +74 -2
  84. package/scripts/plain-language-rules.mjs +103 -0
  85. package/scripts/release-note-required.mjs +118 -24
  86. package/scripts/release-notes-lint.mjs +22 -43
  87. package/scripts/release-version.mjs +59 -0
  88. package/scripts/revisit-render-check.mjs +28 -6
  89. package/scripts/text-difference.mjs +22 -0
  90. package/scripts/travelling-predicates.mjs +1 -1
  91. package/shared/access-audience.mjs +7 -6
  92. package/shared/brand.mjs +7 -3
  93. package/shared/client-door.mjs +39 -0
  94. package/shared/connect-clients.mjs +290 -321
  95. package/shared/env-file-merge.mjs +24 -0
  96. package/shared/env-local.mjs +25 -2
  97. package/shared/invocation.mjs +49 -2
  98. package/shared/names-in-force.mjs +3 -0
  99. package/shared/permanent-install.mjs +237 -0
  100. package/shared/reap-on-exit.mjs +27 -14
  101. package/shared/running-start.mjs +73 -0
  102. package/shared/stdio-connect.mjs +95 -14
  103. package/shared/store-in-repo.mjs +197 -2
  104. package/shared/verb-shim.mjs +10 -1
  105. package/shared/withheld-paths-access.mjs +6 -6
  106. package/shared/wsl.mjs +23 -0
  107. package/shared/yes-no-echo.mjs +35 -0
package/.env.example CHANGED
@@ -388,6 +388,26 @@ 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
+
402
+ # Marks an install that has already moved itself out of npm's npx cache. `npx clearotron install` first
403
+ # installs the same version under ~/.local and then runs that copy's install, with this set to 1, so the
404
+ # copy does not try to move itself a second time. Unset, an install running from npx's cache moves.
405
+ # Set by bin/onboard.mjs on the one process it starts, never by an operator, so the row is left
406
+ # commented. It is listed for the reason CLEAROTRON_DEMO is: the catalogue is about what an operator can
407
+ # FIND. Read by bin/onboard.mjs.
408
+ # effect: deployment
409
+ # CLEAROTRON_RELOCATED=
410
+
391
411
  # How many client what-ifs the runner drains at once (default 1). A what-if re-runs ONE stage of a run
392
412
  # that already exists, in a sandbox, at a client's request — the owner opened it to clients on
393
413
  # 2026-08-27 and ruled SPEND controls out with it ("ignore the call spend"). This is not one.
@@ -538,11 +558,12 @@ CLEAROTRON_DEMO_PROFILES=
538
558
 
539
559
  # ── The organisation setup names ──────────────────────────────────────────────────────────────────
540
560
  #
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
561
+ # Your organisation's name. `npm run setup` asks for it (and, on a local install, not for a sign-in
562
+ # address) and writes it here, quoted, so a name holding a `#` survives. The first `clearotron start` files it as the first
543
563
  # organisation in the grants file (CLEAROTRON_ACCESS_FILE) when that file holds none; from then on the
544
- # grants file holds the name, renaming is an edit there, and this is not read. Unset or empty, no
545
- # organisation is invented. `clearotron start --organisation <name>` supplies it for one start, and a
546
- # demo never reads it. Read by bin/start.mjs.
564
+ # grants file holds the name and renaming is an edit there. The portal also reads it, to name the
565
+ # organisation on its top bar and on its sign-in refusal page. Unset or empty, no organisation is
566
+ # invented. `clearotron start --organisation <name>` supplies it for one start, and a demo never reads
567
+ # it. Read by bin/start.mjs and shared/brand.mjs.
547
568
  # effect: deployment
548
569
  CLEAROTRON_ORGANISATION_NAME=
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.
@@ -63,6 +64,15 @@ run is [mcp-server/CONNECT.md](mcp-server/CONNECT.md), and why something is the
63
64
  npx clearotron install
64
65
  ```
65
66
 
67
+ Run from `npx`, the install first installs Clearotron under `~/.local`, as `npm install -g --prefix
68
+ ~/.local` would, and finishes from there. That way the `clearotron` command and your assistant's connection
69
+ do not point into npm's temporary cache, which npm replaces on an update and deletes when it cleans up.
70
+ Later, the `update` command updates that copy in place.
71
+
72
+ On WSL, a program on the Windows side can hold a port that WSL reports as free, and the browser reaches
73
+ it first. VS Code's Remote-SSH port forwarding is the common case. If the page that opens is not this
74
+ install's sign-in, run the same command again with `--port 28802`, or any free number.
75
+
66
76
  A *hosted* deployment needs Linux for one further thing, the systemd outbox trigger —
67
77
  [driver/systemd/README.md](driver/systemd/README.md).
68
78
  - **A reasoning CLI on your `PATH`, signed in.** This is the prerequisite people miss. Every stage runs
@@ -285,6 +295,71 @@ keeps working throughout.
285
295
  provider, so it is safe to run on any machine that has the repository. "Safe anywhere" is about spend,
286
296
  not about where it will run — on a packaged tree it refuses, as above.
287
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
+
288
363
  ## 3. Configuration (environment)
289
364
 
290
365
  Runtime configuration is by environment variable, and there are two ways to supply them.
@@ -490,6 +565,25 @@ matches, the neutral Generic default applies.
490
565
  - **Your real customers live outside the repo.** Point `CLEAROTRON_CUSTOMERS_DIR` at your own private
491
566
  config store and the engine loads *those* accounts instead. **Same engine, different config path** —
492
567
  the code carries no customer identities.
568
+
569
+ Two things go with it, and both are refusals rather than preferences:
570
+
571
+ - **`PROFILE_REPO_ROOT` moves too.** The customer directory has to sit inside the repository that
572
+ variable names, because editing a profile is a commit. Point one somewhere new and leave the other
573
+ behind and the portal and the profile service both refuse to start, naming both variables.
574
+ - **That repository needs a `user.name` and a `user.email` of its own.** Saves are committed under
575
+ the name of whoever asked for them, but git also records who *made* the commit, and it will not
576
+ commit at all without one. A service account usually has no global git identity, so a store created
577
+ by hand needs its own:
578
+
579
+ ```bash
580
+ git init -b main /srv/clearotron-store
581
+ git -C /srv/clearotron-store config user.name "clearotron local install"
582
+ git -C /srv/clearotron-store config user.email "clearotron@example.com"
583
+ ```
584
+
585
+ `clearotron start` does this for the store it creates. A store you make yourself does not get it,
586
+ and the symptom is the first save failing at a commit rather than anything about profiles.
493
587
  - **Run data is external too.** Published reports, audits, and per-run state go to the archive pool at
494
588
  `CLEAROTRON_REPORTS_DIR`. Nothing customer-specific is committed to the repository.
495
589
 
@@ -729,12 +823,17 @@ Use the demo to see what this system produces. Use `npx clearotron start` to run
729
823
 
730
824
  - Generates `PORTAL_SECRET` and `TRADEMARK_MCP_TOKEN_SECRET` and **appends** them to `~/.config/clearotron/.env` at
731
825
  mode 600. Append, never rewrite: that file also holds the credentials `npx clearotron install` collected.
826
+ It also records where saved searches are kept, `CLEAROTRON_RECIPES_DIR` and `RECIPE_REPO_ROOT`, so
827
+ `clearotron doctor` and a connected assistant read the same saved searches as the portal.
732
828
  - Creates `~/trademark/` — `pool/`, `workspace/`, `queue/`, `outbox/`, `locks/`, an empty grants file,
733
829
  and a small git repository for saved searches. Same base directory `npx clearotron install` uses, so whichever
734
830
  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.
831
+ That does not move anything the env file already names: the saved-search lines above, and the data
832
+ directories `npx clearotron install` wrote, keep pointing at the old place until you edit them.
833
+ - Mints your sign-in passphrase and **prints it once**. Write it down. It is stored as a scrypt digest in
834
+ `~/trademark/portal-local-credential.json`, nothing can read it back, and no later start reprints it. To
835
+ get a new one, run `clearotron passphrase --reset`. An install that has been signing in with
836
+ `~/.cordillera/portal-local-credential.json`, which earlier versions shared between installs, keeps it.
738
837
 
739
838
  ### Who you are
740
839
 
@@ -744,12 +843,12 @@ You sign in as `<your-username>@localhost` unless you say otherwise:
744
843
  npx clearotron start --user you@example.com
745
844
  ```
746
845
 
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.
846
+ Setup does not ask for the address: it writes the local-account form to `.env` and shows it once, in
847
+ its summary, as the address that signs in. An address already in `.env` is kept. It is also the first
848
+ person on this install: the first start writes it into the grants file (`CLEAROTRON_ACCESS_FILE`, §8)
849
+ with access to everything and both permissions, Run clearances and Manage. Setup asks for your
850
+ organisation's name, and the same start files it there as your first organisation. The address admits
851
+ nobody else at its domain; enrolling anyone else is that same file, exactly as on a hosted instance.
753
852
 
754
853
  **No authentication is switched off to make this work, and none can be.** Both doors prove who the
755
854
  caller is — the portal by passphrase and a signed session cookie, the engine door by a mandatory
@@ -901,12 +1000,19 @@ their own integration work.
901
1000
  **Two shapes, and the line between them is what your assistant can do — not where it runs.** Ruling
902
1001
  2026-09-03, on the vendor's own documented behaviour:
903
1002
 
904
- 1. **An assistant that can launch a local process** — Claude Code, Claude Desktop's local config,
905
- Codex CLI, an agent that runs commands. It spawns the server over stdio. No address, no key, no
906
- network, no ingress. `npx clearotron start` prints the one line to paste, and `npx clearotron
907
- connect` hands it over per assistant.
908
- 2. **Everything else, wherever it appears to run** — Cowork, ChatGPT, claude.ai, Perplexity, the mobile
909
- apps. These need **a publicly reachable HTTPS address**, plus a key. Always.
1003
+ 1. **Clearotron is installed on the machine the assistant runs on** — Claude's desktop app, Claude Code,
1004
+ Codex, the ChatGPT desktop app, an agent that runs commands. The assistant spawns the server over
1005
+ stdio. No address, no key, no network, no ingress. `npx clearotron start` prints the one line to
1006
+ paste, and `npx clearotron connect --where here` hands it over per assistant.
1007
+ 2. **Clearotron is running somewhere else** — a server, a cloud machine, anywhere the assistant is
1008
+ not. These need **a publicly reachable HTTPS address**, plus a key made for the person connecting.
1009
+ Always. Claude (app, web, Cowork and mobile), ChatGPT on the web, Perplexity and other agents only
1010
+ ever connect this way; Claude Code and Codex can connect either way. `npx clearotron connect
1011
+ --where elsewhere` makes the key and prints the steps for the assistant you name.
1012
+
1013
+ Without `--where`, `connect` asks when both answers are possible, and `--client <name>` on its own keeps
1014
+ the answer that assistant had before: a key for Claude, the local line for Claude Code and Codex.
1015
+ `npx clearotron disconnect` takes the same `--where`.
910
1016
 
911
1017
  **A loopback address is never an answer for shape 2, and that is not about your network.** A remote MCP
912
1018
  connector is reached **from the vendor's cloud**, never from the reader's device. Anthropic's own help
package/README.md CHANGED
@@ -19,50 +19,62 @@ no gateway, no platform, and nothing about your matters reaches us.
19
19
 
20
20
  ## Install
21
21
 
22
- **See it work first, with nothing checked out.**
22
+ **See it work first, with nothing installed.**
23
23
 
24
24
  ```bash
25
25
  npx clearotron demo
26
26
  ```
27
27
 
28
- That fetches the published package — it will ask once before downloading — then replays a finished
29
- clearance into a local pool and opens the report in your browser. No account, no credentials, no network
30
- calls to us.
28
+ That fetches the published package — it will ask once before downloading — then replays finished
29
+ clearances into a local portal and prints the portal's address and the passphrase to sign in with. Open
30
+ the address in your browser. No account, no credentials, no network calls to us.
31
31
 
32
32
  **Then install it.**
33
33
 
34
34
  ```bash
35
- npm install -g clearotron
35
+ npx clearotron install
36
36
  ```
37
37
 
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.
38
+ Node 22.13 or newer, on macOS or Linux. It needs no root: it puts the program under `~/.local`, with the
39
+ `clearotron` command in `~/.local/bin`, then asks one question at a time and checks each credential
40
+ before it saves it. With `~/.local/bin` on your `PATH`, every command below works in the short form;
41
+ otherwise use the full path `install` prints at the end. **On Windows the demo above runs natively; a
42
+ real clearance needs WSL2.** Native Windows clearances are planned for a later release. Until then the
43
+ engine does not run on native Windows: it resolves the reasoning CLI the POSIX way, and a clearance
44
+ started there refuses at preflight.
45
+
46
+ `npm install -g clearotron` also works where npm's global directory is yours to write. On a Linux Node
47
+ from the distribution or NodeSource that directory is `/usr`, owned by root, and npm refuses with
48
+ `EACCES`. Do not answer that with `sudo`; install under your home instead:
49
+ `npm install -g clearotron --prefix ~/.local`.
41
50
 
42
- That command gives you the **stable** release — the one that has run a real clearance end to end before it
43
- was published. If you want the newest code instead, a beta is published whenever there is something worth
44
- testing — deliberately, days apart, not on every merge:
51
+ `npx clearotron install` gives you the **stable** release — the one that has run a real clearance end to
52
+ end before it was published. If you want the newest code instead, a beta is published whenever there is
53
+ something worth testing — deliberately, days apart, not on every merge:
45
54
 
46
55
  ```bash
47
- npm install -g clearotron@beta
56
+ npx clearotron@beta install
48
57
  ```
49
58
 
50
59
  What each channel promises, and when a stable is cut: [docs/RELEASES.md](docs/RELEASES.md). If you are not
51
60
  sure, the first command is the one you want.
52
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
+
53
65
  ## Quick start
54
66
 
55
- Check the install before it does anything. `doctor` only reads — it writes nothing, calls nobody, and
56
- names whatever is still missing:
67
+ With it installed, check what it found before it does anything. `doctor` only reads — it writes nothing,
68
+ calls nobody, and names whatever is still missing:
57
69
 
58
70
  ```bash
59
- npx clearotron doctor
71
+ clearotron doctor
60
72
  ```
61
73
 
62
- Then start the product and open the portal it prints:
74
+ Then start the product and open the portal address it prints:
63
75
 
64
76
  ```bash
65
- npx clearotron start
77
+ clearotron start
66
78
  ```
67
79
 
68
80
  That is the portal a brand owner uses. Ordering a clearance is the same screen — describe it in a
@@ -81,11 +93,10 @@ rights-holders behind them by jurisdiction:
81
93
 
82
94
  ![The conflict landscape, with rights-holders grouped by jurisdiction](docs/assets/portal-conflict-landscape.jpg)
83
95
 
84
- Then run your own. `install` asks one question at a time and checks each credential before it saves it:
96
+ Then run your own: order it in the portal, or hand the engine a job file:
85
97
 
86
98
  ```bash
87
- npx clearotron install
88
- npx clearotron run --job my-job.json
99
+ clearotron run --job my-job.json
89
100
  ```
90
101
 
91
102
  ## How it fits together
@@ -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/connect.mjs CHANGED
@@ -45,8 +45,9 @@ import { homedir, userInfo } from "node:os";
45
45
  import { fileURLToPath } from "node:url";
46
46
  import { execFileSync } from "node:child_process";
47
47
  import { createServer } from "node:net";
48
- import { CONNECT_CLIENTS, clientById, whatItNeeds } from "../shared/connect-clients.mjs";
48
+ import { CONNECT_CLIENTS, WHERE_FLAG, clientById, leadRouteFor, plainStep, whatItNeeds } from "../shared/connect-clients.mjs";
49
49
  import { stdioConnectFor, STDIO_SHAPES } from "../shared/stdio-connect.mjs";
50
+ import { isWsl } from "../shared/wsl.mjs";
50
51
  import { defaultDenylistPath, clientDoorAddress, clientDoorPort, clientDoorState, enablePlan, applyEnablePlan, describeChange, recordConnectKey, CLIENT_DOOR_UNIT } from "../shared/client-door.mjs";
51
52
  import { mintToken, tokenId, resolvePerson, loadGrants } from "../shared/scope.mjs";
52
53
  import { envFrom } from "../shared/env-aliases.mjs";
@@ -205,7 +206,8 @@ function deploymentHas(env = process.env) {
205
206
  return {
206
207
  // EVERY SHAPE, RESOLVED ONCE. A row picks its own; nothing here knows a client's name.
207
208
  stdioRoutes: Object.fromEntries(Object.keys(STDIO_SHAPES).map((shape) =>
208
- [shape, stdioConnectFor(shape, { workDir: env.CLEAROTRON_WORK_DIR || null })])),
209
+ [shape, stdioConnectFor(shape, { workDir: env.CLEAROTRON_WORK_DIR || null, reportsDir: env.CLEAROTRON_REPORTS_DIR || null })])),
210
+ wsl: isWsl({ env }),
209
211
  // WHERE THE DOOR BINDS — not an address handed to any assistant. It is the loopback address the
210
212
  // unit listens on, and `enablePlan` needs it to write the unit. It used to be passed to the
211
213
  // resolver as `localAddress` and served to Cowork as somewhere to connect, which is the false
@@ -555,17 +557,12 @@ async function render(offer, have, { dryRun, running, allowMove = false }) {
555
557
  return 1;
556
558
  }
557
559
 
558
- if (offer.route === "disk" || (offer.route === "either" && offer.command)) {
559
- // A COMMAND AND A CONFIG BLOCK ARE NOT THE SAME INSTRUCTION, and saying "run this" over a TOML
560
- // block is how a reader pastes four lines into a shell. The shape says which it is.
561
- const s = offer.stdio;
562
- say(s?.kind === "config"
563
- ? ` Add this to ${s.where}:`
564
- : " Run this once, on this machine:");
560
+ if (offer.route === "disk") {
561
+ // THE ROW'S OWN STEPS, and nothing written here. A command and a settings block are not the same
562
+ // instruction — saying "run this" over a TOML block is how a reader pastes four lines into a shell
563
+ // and the steps already say which each one is and where it goes.
564
+ printSteps(offer, null);
565
565
  say("");
566
- for (const line of String(offer.command).split("\n")) say(` ${line}`);
567
- say("");
568
- if (s?.after) { say(` ${s.after}`); say(""); }
569
566
  say(` ${offer.note}`);
570
567
  return 0;
571
568
  }
@@ -621,22 +618,40 @@ async function render(offer, have, { dryRun, running, allowMove = false }) {
621
618
  }
622
619
 
623
620
  say(` Address: ${offer.address}`);
624
- if (key) say(` Key: ${key}`); // printed once, stored nowhere
625
- // ── AND WHERE TO PUT THEM ( F35) ───────────────────────────────────────
626
- //
627
- // The owner was left with two strings and no destination: *"I don't know how to connect it in Claude
628
- // Cowork with those details."* The steps were DEFINED IN THE PRODUCT the whole time — `withSteps`
629
- // computes them for every offer, interpolating this install's own address and operator — and this
630
- // verb simply never printed them. Nothing new is authored here; a second set of instructions written
631
- // at the CLI would drift from the page's, which is the defect connect-clients-are-data exists against.
621
+ // Printed once, stored nowhere — and only here when no step hands it over. A step whose copy carries
622
+ // the key prints it inside the line the reader pastes, which is the one place it is needed.
623
+ const keyInSteps = (offer.steps ?? []).some((s) => s.copy?.kind === "secret");
624
+ if (key && !keyInSteps) say(` Key: ${key}`);
625
+ printSteps(offer, key);
626
+ say("");
627
+ say(` ${offer.note}`);
628
+ return 0;
629
+ }
630
+
631
+ /**
632
+ * The offer's own steps, numbered, with each copy printed under the step that hands it over.
633
+ *
634
+ * ── AND WHERE TO PUT THEM ( — F35) ───────────────────────────────────────
635
+ *
636
+ * The owner was left with two strings and no destination: *"I don't know how to connect it in Claude
637
+ * Cowork with those details."* The steps were DEFINED IN THE PRODUCT the whole time and this verb simply
638
+ * never printed them. Nothing is authored here; a second set of instructions written at the CLI would
639
+ * drift from the page's, which is the defect connect-clients-are-data exists against. A secret copy is
640
+ * printed with the key this press minted put in its slot — the page does the same substitution into the
641
+ * clipboard — and with no key (a dry run) the step prints and its copy does not.
642
+ */
643
+ function printSteps(offer, key) {
632
644
  if (offer.steps?.length) {
633
645
  say("");
634
646
  say(` In ${offer.client?.name ?? "your assistant"}:`);
635
- offer.steps.forEach((step, n) => say(` ${n + 1}. ${step}`));
647
+ offer.steps.forEach((step, n) => {
648
+ say(` ${n + 1}. ${plainStep(step.text)}`);
649
+ const c = step.copy;
650
+ const text = !c ? null : c.kind === "secret" ? (key ? c.template.split(c.slot).join(key) : null) : c.text;
651
+ if (text) { say(""); for (const line of text.split("\n")) say(` ${line}`); say(""); }
652
+ if (step.hint) say(` ${plainStep(step.hint)}`);
653
+ });
636
654
  }
637
- say("");
638
- say(` ${offer.note}`);
639
- return 0;
640
655
  }
641
656
 
642
657
  async function main() {
@@ -654,6 +669,9 @@ async function main() {
654
669
  say(" not about you.");
655
670
  say("");
656
671
  say(" --client <name> skip the question (see --list for the names)");
672
+ say(" --where here your assistant runs on this machine: it starts the software itself");
673
+ say(" --where elsewhere your assistant runs somewhere else and reaches this install over the");
674
+ say(" internet, with a key made for you now");
657
675
  say(" --list the assistants this build knows");
658
676
  say(" --dry-run say what would change, change nothing");
659
677
  say(" --allow-checkout-move");
@@ -663,7 +681,7 @@ async function main() {
663
681
  say("");
664
682
  return 0;
665
683
  }
666
- const known = new Set(["--client", "--list", "--dry-run", "--allow-checkout-move", "--help", "-h"]);
684
+ const known = new Set(["--client", "--where", "--list", "--dry-run", "--allow-checkout-move", "--help", "-h"]);
667
685
  const unknown = argv.filter((a) => a.startsWith("--") && !known.has(a));
668
686
  if (unknown.length) {
669
687
  console.error(`connect: unrecognised flag(s): ${unknown.join(", ")}`);
@@ -671,6 +689,12 @@ async function main() {
671
689
  process.exit(2);
672
690
  }
673
691
  const dryRun = argv.includes("--dry-run");
692
+ const w = argv.indexOf("--where");
693
+ if (w >= 0 && !Object.hasOwn(WHERE_FLAG, argv[w + 1] ?? "")) {
694
+ console.error(`connect: --where takes one of: ${Object.keys(WHERE_FLAG).join(", ")}`);
695
+ process.exit(2);
696
+ }
697
+ let route = w >= 0 ? WHERE_FLAG[argv[w + 1]] : null;
674
698
  // F40 — reason about the RUNNING product from the units' own environment, not this CLI's env file.
675
699
  const running = runningEnv();
676
700
  const have = deploymentHas(running.env);
@@ -712,9 +736,30 @@ async function main() {
712
736
  chosen = CONNECT_CLIENTS[Number(answer) - 1] ?? clientById(answer);
713
737
  } finally { rl.close(); }
714
738
  if (!chosen) { console.error("connect: not one of the listed assistants."); process.exit(2); }
739
+ // THE SECOND QUESTION, ASKED ONLY WHEN IT HAS TWO ANSWERS — the page's own, in the terminal's words.
740
+ // Where only one route is served here, asking would offer a choice whose other half cannot work.
741
+ const both = ["disk", "public-http"].filter((r) => whatItNeeds(chosen, have, r)?.served);
742
+ if (!route && both.length > 1) {
743
+ say("");
744
+ say(" Where does it run?");
745
+ say("");
746
+ say(" 1) On this machine — it starts the software itself, nothing to open up");
747
+ say(" 2) Somewhere else — it reaches this install over the internet, with a key made for you now");
748
+ say("");
749
+ const rl2 = createInterface({ input: stdin, output: stdout });
750
+ try {
751
+ const answer = (await rl2.question(" 1-2: ")).trim();
752
+ route = answer === "1" ? "disk" : answer === "2" ? "public-http" : null;
753
+ } finally { rl2.close(); }
754
+ if (!route) { console.error("connect: answer 1 or 2."); process.exit(2); }
755
+ }
756
+ route ??= both[0] ?? chosen.lead;
715
757
  }
716
758
 
717
- return await render(whatItNeeds(chosen, have), have, { dryRun, running, allowMove: argv.includes("--allow-checkout-move") });
759
+ // NAMED WITHOUT --where: the route this id had before every row took both, so a scripted
760
+ // `--client cowork` still mints a key and `--client codex` still prints a settings block.
761
+ route ??= leadRouteFor(argv[i + 1]);
762
+ return await render(whatItNeeds(chosen, have, route), have, { dryRun, running, allowMove: argv.includes("--allow-checkout-move") });
718
763
  }
719
764
 
720
765
  // THE DISPATCH RUNS ONLY WHEN THIS FILE IS THE COMMAND. Without the guard, importing