clearotron 0.2.4 → 0.3.0-beta.1

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 (235) hide show
  1. package/.env.example +13 -2
  2. package/CONTRIBUTING.md +1 -1
  3. package/INSTALL.md +62 -38
  4. package/bin/brandowner.mjs +18 -169
  5. package/bin/clearotron.mjs +3 -1
  6. package/bin/connect.mjs +28 -19
  7. package/bin/disconnect.mjs +3 -3
  8. package/bin/example.mjs +7 -7
  9. package/bin/framework-preflight.mjs +49 -0
  10. package/bin/grant.mjs +151 -93
  11. package/bin/onboard.mjs +220 -138
  12. package/bin/start.mjs +146 -137
  13. package/bin/stop.mjs +2 -2
  14. package/bin/update.mjs +1 -1
  15. package/build-info.json +2 -2
  16. package/docs/CLIENT-MCP.md +2 -2
  17. package/docs/E2E.md +12 -2
  18. package/docs/ONBOARDING.md +1 -1
  19. package/docs/PORTAL.md +14 -13
  20. package/docs/SECURITY.md +23 -24
  21. package/docs/architecture/04-configuration-reference.md +12 -5
  22. package/docs/architecture/05-config-governance.md +7 -7
  23. package/docs/architecture/07-quality-and-audit.md +1 -1
  24. package/docs/architecture/08-development-guide.md +5 -0
  25. package/docs/configuration.md +118 -0
  26. package/docs/decisions/0004-documentation-structure.md +2 -2
  27. package/docs/decisions/0006-what-the-public-repository-carries.md +2 -2
  28. package/driver/CHANGELOG.md +70 -0
  29. package/driver/ask-ledger.mjs +2 -2
  30. package/driver/cancel.mjs +27 -0
  31. package/driver/case-law-sources.mjs +3 -3
  32. package/driver/company-bundle.mjs +261 -0
  33. package/driver/compare.mjs +1 -1
  34. package/driver/compose-read.mjs +2 -2
  35. package/driver/config-inventory.mjs +2 -2
  36. package/driver/contract-audit.mjs +1 -1
  37. package/driver/contract-e3-backlog.mjs +1 -1
  38. package/driver/contract-vocabulary.mjs +1 -1
  39. package/driver/declination-call.mjs +1 -1
  40. package/driver/deliver-trigger.sh +3 -3
  41. package/driver/dev-portal.mjs +3 -1
  42. package/driver/digest-queue.mjs +1 -1
  43. package/driver/disposition-tool.mjs +1 -1
  44. package/driver/doc-constants.mjs +1 -1
  45. package/driver/drain-posture.mjs +2 -2
  46. package/driver/drainer-identity.mjs +1 -1
  47. package/driver/driver.config.mjs +44 -9
  48. package/driver/effective-scope.mjs +30 -1
  49. package/driver/effort-model.mjs +6 -6
  50. package/driver/engine/CONTRACT.md +2 -2
  51. package/driver/engine/anthropic-agent.mjs +11 -11
  52. package/driver/engine/jx-turn.mjs +1 -1
  53. package/driver/engine/mcp/gather-config.mjs +29 -4
  54. package/driver/engine/mcp/recording-server.mjs +73 -1
  55. package/driver/engine/openai-agent.mjs +1 -1
  56. package/driver/engine/probe.mjs +28 -4
  57. package/driver/enqueue-schema.mjs +23 -3
  58. package/driver/findings-model.mjs +2 -2
  59. package/driver/flag-snapshot.mjs +2 -2
  60. package/driver/floor-duty.mjs +2 -2
  61. package/driver/frame-diff-model.mjs +1 -1
  62. package/driver/framework-preflight.mjs +143 -0
  63. package/driver/gateway.mjs +9 -1
  64. package/driver/hit-list.mjs +1 -1
  65. package/driver/jx-lanes.mjs +1 -1
  66. package/driver/jx.mjs +1 -1
  67. package/driver/knockout-assess-record.mjs +1 -1
  68. package/driver/knockout-review-record.mjs +435 -0
  69. package/driver/order-probe.mjs +1 -1
  70. package/driver/outbox-backoff.mjs +2 -2
  71. package/driver/owner-use-check.mjs +2 -2
  72. package/driver/package.json +1 -1
  73. package/driver/pipeline-knockout.mjs +105 -8
  74. package/driver/pipeline.mjs +81 -30
  75. package/driver/plain-register.mjs +77 -3
  76. package/driver/portal-access.mjs +141 -74
  77. package/driver/portal-config-view.mjs +59 -70
  78. package/driver/portal-report.mjs +4 -4
  79. package/driver/portal-service.mjs +348 -141
  80. package/driver/portal-upstream.mjs +105 -17
  81. package/driver/predelivery-lint.mjs +43 -18
  82. package/driver/product-rows.mjs +1 -1
  83. package/driver/products.mjs +1 -1
  84. package/driver/profile-page.html +30 -5
  85. package/driver/profile-service.mjs +197 -26
  86. package/driver/profiles.mjs +48 -1
  87. package/driver/publish/index.mjs +31 -13
  88. package/driver/publish/knockout.mjs +9 -5
  89. package/driver/publish/office-record-links.mjs +189 -0
  90. package/driver/publish/parse.mjs +3 -3
  91. package/driver/publish/publish-inputs.mjs +26 -0
  92. package/driver/publish/render-knockout.mjs +42 -42
  93. package/driver/publish/render.mjs +29 -4
  94. package/driver/publish/report-data.mjs +2 -2
  95. package/driver/publish/seed-pool.mjs +1 -1
  96. package/driver/publish/templates/report.css +8 -8
  97. package/driver/publish/xlsx.mjs +49 -7
  98. package/driver/queue-watch-verdict.mjs +2 -2
  99. package/driver/recipe-service.mjs +1 -1
  100. package/driver/record-carry.mjs +1 -1
  101. package/driver/reference-score.mjs +1 -1
  102. package/driver/reference-strip-signatures.mjs +1 -1
  103. package/driver/register-availability.mjs +4 -3
  104. package/driver/register-count.mjs +3 -3
  105. package/driver/register-records.mjs +1 -1
  106. package/driver/repair-composers.mjs +1 -1
  107. package/driver/repairs.mjs +3 -3
  108. package/driver/replay-archive.mjs +1 -1
  109. package/driver/report-card-record.mjs +1 -1
  110. package/driver/result-noun-fields.mjs +5 -0
  111. package/driver/roster-verdict.mjs +48 -5
  112. package/driver/run-activity.mjs +1 -1
  113. package/driver/run-requirements.mjs +18 -5
  114. package/driver/runner.mjs +24 -15
  115. package/driver/search-policy.mjs +8 -8
  116. package/driver/senior-rights.mjs +1 -1
  117. package/driver/skills/prelim-search/delivery-contract.md +1 -1
  118. package/driver/skills/prelim-search/risk-framework-triage.md +10 -7
  119. package/driver/stages-knockout.mjs +72 -6
  120. package/driver/stages.mjs +10 -10
  121. package/driver/suite-census.json +238 -70
  122. package/driver/synthesis-record.mjs +2 -2
  123. package/driver/systemd/clearotron-client-mcp.service +3 -3
  124. package/driver/systemd/clearotron-deploy.service +2 -2
  125. package/driver/systemd/clearotron-mcp-face.service +1 -1
  126. package/driver/systemd/clearotron-portal.service +3 -3
  127. package/driver/systemd/clearotron-worker.service +5 -5
  128. package/driver/systemd/install-census.mjs +1 -1
  129. package/driver/systemd/render-units.mjs +9 -9
  130. package/driver/terminal-clamp.mjs +1 -1
  131. package/driver/trigger-cap.mjs +18 -2
  132. package/driver/unit-inventory.mjs +8 -8
  133. package/driver/usage-ledger.mjs +5 -3
  134. package/driver/verify-knockout.mjs +7 -7
  135. package/driver/verify.mjs +5 -5
  136. package/driver/whatif-memo-run.mjs +1 -1
  137. package/driver/whatif-queue.mjs +3 -3
  138. package/driver/whatif-worker.mjs +2 -2
  139. package/examples/README.md +1 -1
  140. package/examples/grants.example.json +25 -24
  141. package/mcp-server/CHANGELOG.md +10 -0
  142. package/mcp-server/http-server.mjs +3 -3
  143. package/mcp-server/key-socket.mjs +1 -1
  144. package/mcp-server/lib/audit-view.mjs +3 -3
  145. package/mcp-server/lib/brief.mjs +3 -3
  146. package/mcp-server/lib/driver.mjs +1 -1
  147. package/mcp-server/lib/events.mjs +1 -1
  148. package/mcp-server/lib/http-handler.mjs +2 -2
  149. package/mcp-server/lib/instructions.mjs +2 -2
  150. package/mcp-server/lib/knockout.mjs +1 -1
  151. package/mcp-server/lib/ops.mjs +6 -3
  152. package/mcp-server/lib/options.mjs +15 -4
  153. package/mcp-server/lib/plan.mjs +7 -6
  154. package/mcp-server/lib/runs.mjs +10 -0
  155. package/mcp-server/lib/whatif.mjs +5 -5
  156. package/mcp-server/package.json +1 -1
  157. package/mcp-server/packs/README.md +1 -1
  158. package/mcp-server/remote/client-mcp-apikey.service +1 -1
  159. package/mcp-server/remote/client-mcp.service +2 -2
  160. package/mcp-server/remote/trademark-artifacts-http.service +1 -1
  161. package/mcp-server/server.mjs +38 -24
  162. package/package.json +2 -2
  163. package/portal-ui/dist/assets/{index-KFAHMgdT.js → index-CWTHP0sH.js} +3471 -1901
  164. package/portal-ui/dist/assets/{index-1ziUJX1E.css → index-KpytsmNH.css} +79 -26
  165. package/portal-ui/dist/index.html +2 -2
  166. package/portal-ui/package.json +1 -1
  167. package/providers/_shared/lane-probe.mjs +9 -3
  168. package/providers/jx-subclass/lookup.mjs +1 -1
  169. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  170. package/providers/oauth-mcp-bridge/package.json +1 -1
  171. package/providers/oauth-mcp-bridge/systemd/courtlistener-mcp.service +1 -1
  172. package/scripts/citation-drift-report.mjs +1 -1
  173. package/scripts/citation-line-check.mjs +2 -2
  174. package/scripts/drive-env-check.mjs +1 -1
  175. package/scripts/e2e.mjs +5 -5
  176. package/scripts/env-audit.mjs +13 -1
  177. package/scripts/headless-page.mjs +5 -5
  178. package/scripts/live-surface-check.mjs +26 -2
  179. package/scripts/mint-names-in-force.mjs +19 -5
  180. package/scripts/mint-reference-strip-backlog.mjs +1 -1
  181. package/scripts/mint-suite-census.mjs +37 -10
  182. package/scripts/pack-publishable.mjs +1 -1
  183. package/scripts/preinstall-node-check.mjs +1 -1
  184. package/scripts/release-await-cut.mjs +3 -3
  185. package/scripts/release-cut-decision.mjs +1 -1
  186. package/scripts/release-dist-tag.mjs +1 -1
  187. package/scripts/release-install-check.mjs +1 -1
  188. package/scripts/release-notes-lint.mjs +1 -1
  189. package/scripts/release-publish-guard.mjs +1 -1
  190. package/scripts/release-version-pr-checks.mjs +2 -2
  191. package/scripts/release-version.mjs +61 -5
  192. package/scripts/render-brand-banner.mjs +1 -1
  193. package/scripts/render-check.mjs +2 -2
  194. package/scripts/repo-writes.mjs +1 -1
  195. package/scripts/report-frame-check.mjs +1 -1
  196. package/scripts/report-screenshot.mjs +2 -2
  197. package/scripts/retire-bare-refs.mjs +1 -1
  198. package/scripts/revisit-render-check.mjs +1 -1
  199. package/scripts/score.mjs +1 -1
  200. package/scripts/strip-titles-and-attributions.mjs +389 -0
  201. package/scripts/strip-tracker-citations.mjs +122 -5
  202. package/scripts/test-run.mjs +4 -4
  203. package/scripts/third-party-notices.mjs +1 -1
  204. package/scripts/verify-publishable.mjs +1 -1
  205. package/shared/access-audience.mjs +2 -2
  206. package/shared/anon-overlay.mjs +1 -1
  207. package/shared/brand.mjs +15 -1
  208. package/shared/bundle-freshness.mjs +1 -1
  209. package/shared/bundle-rebuild.mjs +1 -1
  210. package/shared/checkout-move.mjs +2 -2
  211. package/shared/client-door.mjs +8 -8
  212. package/shared/connect-clients.mjs +7 -7
  213. package/shared/connector-signin-probe.mjs +1 -1
  214. package/shared/env-aliases.mjs +1 -1
  215. package/shared/env-local.mjs +5 -5
  216. package/shared/grants-edit.mjs +76 -0
  217. package/shared/install-auth.mjs +1 -1
  218. package/shared/listen.mjs +3 -3
  219. package/shared/mcp-challenge.mjs +1 -1
  220. package/shared/names-in-force.mjs +2 -1
  221. package/shared/onboarding-store.mjs +19 -2
  222. package/shared/reference-guard-classes.mjs +44 -2
  223. package/shared/register-selection.mjs +1 -1
  224. package/shared/scope.mjs +223 -56
  225. package/shared/secret-file.mjs +1 -1
  226. package/shared/server-units.mjs +1 -1
  227. package/shared/staff-domain.mjs +45 -78
  228. package/shared/summary-blocks.mjs +2 -2
  229. package/shared/systemd-failure.mjs +3 -3
  230. package/shared/tracked-files.mjs +1 -1
  231. package/shared/trigger-lane.mjs +1 -1
  232. package/shared/tty-style.mjs +1 -1
  233. package/shared/usage-block.mjs +1 -1
  234. package/shared/vacuous-pass.mjs +1 -1
  235. package/shared/verb-shim.mjs +1 -1
package/shared/brand.mjs CHANGED
@@ -78,6 +78,20 @@ export const BRAND = {
78
78
  product: process.env.CLEAROTRON_BRAND_PRODUCT || "Trademark clearance",
79
79
  };
80
80
 
81
+ /**
82
+ * The organisation running this installation, or NULL when nobody has named one.
83
+ *
84
+ * NOT `BRAND.name`, and the difference is the whole point. That value falls back to the product's own
85
+ * name so a sentence the product speaks always has something to call itself — "ask Clearotron to enrol
86
+ * it" reads correctly on an installation nobody has branded. This is the other question: WHO runs this
87
+ * installation, which on a fresh install has no answer, and answering it with the product's name puts
88
+ * "Organisation: Clearotron" on the top bar of every install that never set it.
89
+ *
90
+ * Null rather than an empty string, so a caller cannot render it by accident. Nothing sets the variable
91
+ * today; setup gains the question that will.
92
+ */
93
+ export const ORGANISATION_NAME = process.env.CLEAROTRON_BRAND_NAME?.trim() || null;
94
+
81
95
  /**
82
96
  * THE CONFIDENTIALITY POSTURE ON A DELIVERED DOCUMENT — one rule, both report templates.
83
97
  *
@@ -614,7 +628,7 @@ export const logoLockup = ({ mark = 30, tag = BRAND.tagline, cls = "" } = {}) =>
614
628
  // inherit the SAME staff-auto / client-explicit gating as every other token. `isolation:isolate` + z-index:-1
615
629
  // keeps the wash/mark behind content without having to raise every child.
616
630
  //
617
- // THE WORDMARK RENDERS LOWERCASE, AND THE NAME IN PROSE DOES NOT. Owner ruling 2026-08-21, and it is
631
+ // THE WORDMARK RENDERS LOWERCASE, AND THE NAME IN PROSE DOES NOT. Ruling 2026-08-21, and it is
618
632
  // the THIRD casing ruling on this name — read the distinction before changing either half:
619
633
  //
620
634
  // `.lk-word` (here) the BRAND MARK. Lowercase — "in the UI, clearotron is lowercase as a brand".
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // bundle-freshness.mjs — is the portal bundle the one its sources would build?
5
5
  //
6
- // MOVED HERE FROM `bin/onboard.mjs` BECAUSE A SECOND READER NEEDS IT (tracker issue 160). `doctor` asked
6
+ // MOVED HERE FROM `bin/onboard.mjs` BECAUSE A SECOND READER NEEDS IT. `doctor` asked
7
7
  // this question and answered it well; `/portal/health` asked a narrower one — present or absent — and
8
8
  // answered `ui: "built", ok: true` over a bundle `doctor` had just called stale. Two surfaces, two
9
9
  // answers, one of them wrong, and the operator has no reason to prefer either.
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // ── WHY THE PRODUCT DOES THIS RATHER THAN TELLING THE READER TO ─────────────────────────────────────
6
6
  //
7
- // Owner ruling, 2026-09-05: "this is not a question a user should ever face." A packaged install ships
7
+ // Ruling, 2026-09-05: "this is not a question a user should ever face." A packaged install ships
8
8
  // the built UI, so an npm install or upgrade replaces the bundle and the sources together and none of
9
9
  // this can arise. Only a SOURCE checkout updated by a plain `git pull` can be stale — `portal-ui/dist`
10
10
  // is untracked on the public tree, so a pull can never update it — and that reader ran the documented
@@ -2,7 +2,7 @@
2
2
  // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
3
  // checkout-move.mjs — is this command about to repoint the whole deployment at a different tree?
4
4
  //
5
- // ── why this exists (tracker issue 193) ─────────────────────────────────────────────────────────────
5
+ // ── why this exists ─────────────────────────────────────────────────────────────────────────────────
6
6
  //
7
7
  // `clearotron connect` writes `CLEAROTRON_CHECKOUT_DIR` into the install's env file, set to whatever
8
8
  // checkout it happened to be run from. Every shipped unit's `ExecStart` is `${CLEAROTRON_CHECKOUT_DIR}/…`,
@@ -152,7 +152,7 @@ export function describeConflict(posture, move) {
152
152
  }
153
153
 
154
154
  /**
155
- * Programs executing this product from a tree OTHER than the one the install names (tracker issue 193).
155
+ * Programs executing this product from a tree OTHER than the one the install names.
156
156
  *
157
157
  * `doctor` already reports a running program older than the checkout. This is the same question with
158
158
  * the more dangerous answer: a process on a DIFFERENT tree keeps working until it restarts, and then
@@ -2,7 +2,7 @@
2
2
  // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
3
  // client-door.mjs — the client connector's settings, its key, and the revocation of one person's key.
4
4
  //
5
- // ── THE DOOR NOW COMES UP WITH THE PRODUCT (owner ruling 2026-09-03,) ─────────
5
+ // ── THE DOOR NOW COMES UP WITH THE PRODUCT (ruling 2026-09-03,) ─────────
6
6
  //
7
7
  // There are TWO MCP doors. The ENGINE door (`mcp-server/http-server.mjs`) is what `clearotron start`
8
8
  // runs and what the portal's Start button calls; it refuses an account-scoped key outright. The CLIENT
@@ -354,7 +354,7 @@ export function describeDoorState(door, {
354
354
  * differ in ONE value — which variable carries that public address — so the composition is one function
355
355
  * with that name as a parameter rather than two functions that agree until they do not.
356
356
  *
357
- * Written down after tracker issue 192, where the census found the asymmetry: the client door's
357
+ * Written down after the census found the asymmetry: the client door's
358
358
  * allow-list is composed by the installer and the engine door's was composed by nothing at all, so a
359
359
  * hosted operator was asked for a value sitting next to an identical one the product works out. Two
360
360
  * authors composing `host:port` in two places is what let them diverge in the first place.
@@ -386,7 +386,7 @@ export function allowedHosts(port, env = {}, { urlName = CLIENT_DOOR_URL_ENV } =
386
386
  }
387
387
 
388
388
  /**
389
- * The allow-list an existing one should become, once the door's port has moved (tracker issue 197).
389
+ * The allow-list an existing one should become, once the door's port has moved.
390
390
  *
391
391
  * The loopback entries are THIS INSTALLER'S and are re-derived from the port; every other host in the
392
392
  * list belongs to the operator and is kept. That split is the whole point: a repair about a port must
@@ -522,7 +522,7 @@ export function enablePlan({ env = {}, address, identity, accessFile = null, por
522
522
  // identity. Its own preconditions are exactly what this plan has already established — the signing
523
523
  // secret, account access on, a loopback host, and an allow-list.
524
524
  const settings = {
525
- // Written, not required — and this REPLACES whatever was there (tracker issue 193).
525
+ // Written, not required — and this REPLACES whatever was there.
526
526
  //
527
527
  // The comment here used to say the opposite: "an installer that already set it keeps its value,
528
528
  // because setEnvValue replaces only what this plan names". That reads as a preservation guarantee
@@ -586,7 +586,7 @@ export function enablePlan({ env = {}, address, identity, accessFile = null, por
586
586
  /**
587
587
  * The change in words, in the tense of the moment the caller is in. ONE author for both.
588
588
  *
589
- * There is no consent prompt — owner ruling 2026-08-31, *"One press does all of it, invisibly… No
589
+ * There is no consent prompt — ruling 2026-08-31, *"One press does all of it, invisibly… No
590
590
  * second step"* — so these sentences are reported AFTER the door is open, or ahead of it under
591
591
  * `--dry-run`. What they must never be is two separately written sets that disagree about what was
592
592
  * turned on; a stale future-tense sentence printed after the fact is a product describing a change it
@@ -611,7 +611,7 @@ export function describeChange(plan, { applied = false, publicAddress = null, re
611
611
  // does not go on to think about who else can reach it, so the claim is made only when it is true, and
612
612
  // when it cannot be established it is not made at all. Silence is the safe failure here; a reassuring
613
613
  // sentence is not.
614
- // ── A PUBLISHED ADDRESS IS NOT AUTOMATICALLY A PUBLIC ONE (tracker issue 130) ────────────────────
614
+ // ── A PUBLISHED ADDRESS IS NOT AUTOMATICALLY A PUBLIC ONE ────────────────────────────────────────
615
615
  //
616
616
  // `publicAddress` is whatever the operator put in `CLEAROTRON_CLIENT_MCP_URL`, and a loopback value
617
617
  // is a thing an operator does set — it is the address that works for them at the keyboard. The
@@ -739,7 +739,7 @@ export function applyEnablePlan(plan, io) {
739
739
 
740
740
  // ══ THE LEDGER: what connect issued, as IDs and never as secrets ═════════════
741
741
  //
742
- // Owner ruling, 2026-08-31, verbatim shape: "Say yes. Recording key IDs, never secrets. … Store the
742
+ // Ruling, 2026-08-31, verbatim shape: "Say yes. Recording key IDs, never secrets. … Store the
743
743
  // jti beside it and disconnect is: remove the row, add the id to the denylist. No new bookkeeping."
744
744
  //
745
745
  // The record rides IN THE GRANTS FILE, beside the rows that give the key its reach — one file to read
@@ -799,7 +799,7 @@ export function connectKeyReport(grants, { now = Date.now(), revoked = () => fal
799
799
  return { rows, valid: rows.filter((r) => r.state === "valid").length };
800
800
  }
801
801
 
802
- // ══ REVOCATION: disconnect is a PERSON, not a service (owner ruling 2026-09-03, Q3) ═══════════════
802
+ // ══ REVOCATION: disconnect is a PERSON, not a service (ruling 2026-09-03, Q3) ═══════════════
803
803
  //
804
804
  // SUPERSEDED, AND THE OLD SHAPE IS WORTH KNOWING BECAUSE IT WAS COHERENT. Under the 2026-08-31 ruling
805
805
  // the door existed only because a reader had asked for it, so its mirror was a teardown: revoke the
@@ -50,8 +50,8 @@
50
50
  // Two tables partitioning the same clients on different axes do not merely risk drifting; they had
51
51
  // already drifted before either was finished. The page said Codex needs a key address. This table says
52
52
  // Codex needs no key at all. On a local install the page's answer resolved to `null`, so the page named
53
- // a one-line command in its own instructions and then rendered no command — which is tracker issue
54
- // 1976's defect, sitting inside the page written to answer it.
53
+ // a one-line command in its own instructions and then rendered no command — which is that very
54
+ // defect, sitting inside the page written to answer it.
55
55
  //
56
56
  // So the browser no longer derives any of this. It is handed resolved rows and renders them. That is not
57
57
  // a preference for server-side logic: the install's own filesystem path is not a browser fact, and any
@@ -123,7 +123,7 @@ export const CONNECT_CLIENTS = Object.freeze([
123
123
  ],
124
124
  },
125
125
  {
126
- // NOT A SEPARATE PRODUCT (tracker issue 147; owner: "there is no such thing as desktop"). This is
126
+ // NOT A SEPARATE PRODUCT (decided: there is no such thing as desktop). This is
127
127
  // Claude reached the way that runs on the reader's own machine, so it carries Claude's name and says
128
128
  // which way it is in the sub-label. The `desktop-json` stdio shape is unchanged — what moved is what
129
129
  // a reader is told this is, not how it connects.
@@ -136,7 +136,7 @@ export const CONNECT_CLIENTS = Object.freeze([
136
136
 
137
137
  // ── Speaks HTTP. Connects from the vendor's own servers. ────────────────────────────────────────
138
138
  //
139
- // ONE ROW, BECAUSE IT IS ONE APP (tracker issue 147, owner ruling in session: "you know its just ONE
139
+ // ONE ROW, BECAUSE IT IS ONE APP (ruling in session: "you know its just ONE
140
140
  // APP on a laptop which has cowork and code in it and claude is what its called"). `cowork` was a
141
141
  // separate row here and is merged in; the sub-label carries where it is met, which is a fact about the
142
142
  // reader's screen rather than about our software.
@@ -177,7 +177,7 @@ export const CONNECT_CLIENTS = Object.freeze([
177
177
  ],
178
178
  },
179
179
  {
180
- // UNDRIVEN, AND WORDED LIKE IT (tracker issue 148; the owner drives this vendor himself this
180
+ // UNDRIVEN, AND WORDED LIKE IT (the owner drives this vendor himself this
181
181
  // week and the dated stamp appears then). The old second step named "API Key" as the control to
182
182
  // choose — the same assertion-from-no-observation that made the cowork row send clients hunting
183
183
  // for a box that is not the way in. Two lines and a place to put each is what we actually know.
@@ -201,7 +201,7 @@ export const CONNECT_CLIENTS = Object.freeze([
201
201
  // being read by somebody who is not us. Found by driving the four decks; neither instrument could
202
202
  // see it, because both ask whether the right row rendered and neither asks whether the sentence reads.
203
203
  //
204
- // Owner's ruling 2026-09-06, tracker issue 147, option B: this row gets its own line and the approved
204
+ // Owner's ruling 2026-09-06, option B: this row gets its own line and the approved
205
205
  // sentence is left untouched for the three named ones. Option A — renaming the row to "your
206
206
  // assistant" — was rejected because it edits a line he approved to repair a line he did not.
207
207
  //
@@ -230,7 +230,7 @@ export const CONNECT_CLIENTS = Object.freeze([
230
230
  * A stub that restates a wire is a second author for one shape. This is the shape; both callers ask.
231
231
  */
232
232
  // `sub`, `verifiedOn` and `by` ride only where the ROW carries them, and absent means absent rather than
233
- // null (tracker issue 147). Two of the three are load-bearing on the page:
233
+ // null. Two of the three are load-bearing on the page:
234
234
  //
235
235
  // • `sub` is how one app can appear once per route without two rows claiming to be two products —
236
236
  // "Claude · app, web, and Cowork" and "Claude · app, on this computer" are one product met two ways.
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // ── why this exists ─────────────────────────────────────────────────────────────
6
6
  //
7
- // Tracker issue 149. Two settings at the identity provider decide whether a remote assistant can sign
7
+ // Two settings at the identity provider decide whether a remote assistant can sign
8
8
  // in at all, and this product could see neither. The first — the challenge form — is covered:
9
9
  // `shared/mcp-challenge.mjs` reads it out of an unauthenticated response and `doctor` reports it.
10
10
  //
@@ -16,7 +16,7 @@
16
16
  // install, the install writes the names in force, and the two boxes that predate the rename are
17
17
  // REBUILT rather than deployed onto. There is no population left holding the old lines.
18
18
  //
19
- // THAT PREMISE STOPPED BEING TRUE WHEN THE PRODUCT MOVED TO THE REGISTRY (tracker issue 168). A
19
+ // THAT PREMISE STOPPED BEING TRUE WHEN THE PRODUCT MOVED TO THE REGISTRY. A
20
20
  // tarball is replaced whole; a published package is UPGRADED, and an upgrade leaves the operator's
21
21
  // environment file exactly where it was — so the population the premise says cannot exist is now
22
22
  // created by `npm install`. Measured rather than predicted: upgrading the production install across
@@ -280,7 +280,7 @@ const SHELL_EXPANSION = /\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/;
280
280
  /**
281
281
  * ── WHERE THIS INSTALL'S `.env` LIVES — ruled, 2026-09-05 ────────────────────────────────────────────
282
282
  *
283
- * `~/.config/clearotron/.env`. The owner's decision, recorded on tracker issue 140, taken from the three
283
+ * `~/.config/clearotron/.env`. The owner's decision, taken from the three
284
284
  * candidates below.
285
285
  *
286
286
  * WHAT IT FIXES. On a packaged install the wizard wrote `.env` to the package root, which is
@@ -349,7 +349,7 @@ export const LEGACY_ENV_LOCAL_LOCATION = "package-root";
349
349
  * what a run can see, and it is the only honest address to give a reader whose units are missing a value.
350
350
  *
351
351
  * NAMED HERE BECAUSE TWO PLACES NEED IT AND ONE OF THEM IS NOT A CLI. `bin/start.mjs` composed this
352
- * literal inline while it was the only writer; tracker issue 216 moved the run-configuration refusal to
352
+ * literal inline while it was the only writer; a later change moved the run-configuration refusal to
353
353
  * `driver/runner.mjs`, which must name the same file in the same words. Two `join(homedir(), ".env")`
354
354
  * calls are two authorities for one path, and the way that breaks is quiet: a refusal that sends an
355
355
  * operator to edit a file the units do not read.
@@ -415,7 +415,7 @@ export function activeEnvPath({ repoRoot = REPO_ROOT, home = homedir(), location
415
415
  */
416
416
  export function loadEnvLocal({ env = process.env, repoRoot = REPO_ROOT, note = defaultNote,
417
417
  home = homedir(), location = ENV_LOCAL_LOCATION, file = null } = {}) {
418
- // ── `file` NAMES A FILE OUTRIGHT, AND EXISTS BECAUSE A CALLER COULD NOT (tracker issue 179) ────────
418
+ // ── `file` NAMES A FILE OUTRIGHT, AND EXISTS BECAUSE A CALLER COULD NOT ────────────────────────────
419
419
  //
420
420
  // `readEnvFile(path)` in the wizard means "tell me what THIS file holds". It could only ask by handing
421
421
  // over `repoRoot: dirname(path)` and hoping the resolution below landed there — and it did, by
@@ -514,7 +514,7 @@ export const loaded = isCliEntry(process.argv[1]) ? loadEnvLocal() : null;
514
514
  *
515
515
  * `read` and `absent` are both a yes — absent only means nobody has written it yet, and that IS the file
516
516
  * to write. `service-managed`, `opted-out` and `unreadable` are a no, and a no means say nothing rather
517
- * than guess (tracker issue 200).
517
+ * than guess.
518
518
  */
519
519
  export function envFileRead(l = loaded) {
520
520
  return l && (l.reason === "read" || l.reason === "absent") ? l.path : null;
@@ -534,6 +534,6 @@ export function envFileRead(l = loaded) {
534
534
  //
535
535
  // IT WARNS AND RETURNS. There is nothing here that can refuse: the only names it knows are settings
536
536
  // whose BEHAVIOUR was deleted, so there is no value to apply wrongly. The renamed install-surface names
537
- // are not checked for at all — owner ruling, 2026-08-26 — because a machine reaches this code through
537
+ // are not checked for at all — ruling, 2026-08-26 — because a machine reaches this code through
538
538
  // the install and the two boxes that predate the rename are rebuilt rather than deployed onto.
539
539
  export const aliased = warnRetiredEnv();
@@ -0,0 +1,76 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ // grants-edit.mjs — the changes a person makes to the grants file: give someone access, and file a new
4
+ // company under its organisation.
5
+ //
6
+ // PURE. Each function takes the parsed grants object and returns a new one; the caller reads the file,
7
+ // applies the change and writes it atomically. One function per change, shared by every writer — the
8
+ // portal's People page, its company creation and `clearotron grant` — so two writers cannot produce two
9
+ // shapes of the same fact. Every result is put through `assertGrantsShape` before it is handed back, so a
10
+ // change that would make the file one the product refuses to load is refused here, before the write.
11
+ import { assertGrantsShape } from "./scope.mjs";
12
+
13
+ const SWITCHES = ["run", "manage", "everything"];
14
+
15
+ /**
16
+ * Give a person access. `points` are `[{ tenant, account? }]`: a whole organisation, or one company that
17
+ * organisation holds. Points are ADDED; nothing the person already holds is removed, and a company point
18
+ * inside an organisation they already hold whole is already covered.
19
+ *
20
+ * `switches` (`{ run, manage, everything }`) replace the person's entry under `people` when
21
+ * `setSwitches` is true. A caller that may not change them — the person's access reaches beyond the
22
+ * caller's own — passes false, and the entry is left exactly as it was.
23
+ */
24
+ export function withPerson(grants, { email, points = [], switches = {}, setSwitches = true }) {
25
+ const e = String(email ?? "").trim().toLowerCase();
26
+ if (!e || e.indexOf("@") <= 0 || e.indexOf("@") !== e.lastIndexOf("@"))
27
+ throw new Error(`"${email}" is not one email address`);
28
+ const g = structuredClone(grants ?? { tenants: {} });
29
+ g.tenants ??= {};
30
+ for (const { tenant, account = null } of points) {
31
+ const t = g.tenants[tenant];
32
+ if (!t) throw new Error(`there is no organisation "${tenant}"`);
33
+ t.users = { ...(t.users ?? {}) };
34
+ if (account == null) { t.users[e] = "*"; continue; }
35
+ if (!(Array.isArray(t.accounts) ? t.accounts : []).includes(account))
36
+ throw new Error(`organisation "${tenant}" does not hold "${account}"`);
37
+ const held = t.users[e];
38
+ if (held === "*") continue;
39
+ t.users[e] = [...new Set([...(Array.isArray(held) ? held : []), account])];
40
+ }
41
+ if (setSwitches) {
42
+ const entry = { run: switches.run === true, manage: switches.manage === true };
43
+ if (switches.everything === true) entry.everything = true;
44
+ g.people = { ...(g.people ?? {}), [e]: entry };
45
+ }
46
+ assertGrantsShape(g, "the grants file after this change");
47
+ return g;
48
+ }
49
+
50
+ /** File a company under the organisation that holds it. A company belongs to exactly one organisation. */
51
+ export function withCompany(grants, { tenant, account }) {
52
+ const g = structuredClone(grants ?? { tenants: {} });
53
+ const t = g.tenants?.[tenant];
54
+ if (!t) throw new Error(`there is no organisation "${tenant}"`);
55
+ t.accounts = [...new Set([...(Array.isArray(t.accounts) ? t.accounts : []), account])];
56
+ assertGrantsShape(g, "the grants file after this change");
57
+ return g;
58
+ }
59
+
60
+ /**
61
+ * Add an organisation. Its key is derived from the name — lowercase, hyphenated — and must not collide
62
+ * with one that exists. Returns `{ grants, key }`.
63
+ */
64
+ export function withOrganisation(grants, { name }) {
65
+ const n = String(name ?? "").trim();
66
+ if (!n) throw new Error("an organisation needs a name");
67
+ const key = n.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "organisation";
68
+ const g = structuredClone(grants ?? { tenants: {} });
69
+ g.tenants ??= {};
70
+ if (g.tenants[key]) throw new Error(`there is already an organisation "${key}"`);
71
+ g.tenants[key] = { name: n, accounts: [], users: {} };
72
+ assertGrantsShape(g, "the grants file after this change");
73
+ return { grants: g, key };
74
+ }
75
+
76
+ export { SWITCHES };
@@ -2,7 +2,7 @@
2
2
  // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
3
  // install-auth.mjs — which auth values the units will refuse to start without, on THIS install.
4
4
  //
5
- // ── why this exists (tracker issue 133) ─────────────────────────────────────────────────────────────
5
+ // ── why this exists ─────────────────────────────────────────────────────────────────────────────────
6
6
  //
7
7
  // From the fresh-user documented-install walk: a reader who does everything the document asks gets two
8
8
  // of four units dead. With auth enabled the portal refuses without `CLEAROTRON_OIDC_AUDIENCE` plus
package/shared/listen.mjs CHANGED
@@ -127,7 +127,7 @@ export function explicitPortRequiredMessage({ what, port, portVar }) {
127
127
  * `portFlag` an optional CLI equivalent, for the entry points that take one.
128
128
  */
129
129
  /**
130
- * The first free port at or after `from`, or `null` when nothing in range is free — owner ruling,
130
+ * The first free port at or after `from`, or `null` when nothing in range is free — ruling,
131
131
  * 2026-09-09.
132
132
  *
133
133
  * WHAT THIS IS FOR AND WHAT IT IS NOT. A collision on a DEFAULT port is this process discovering it
@@ -172,7 +172,7 @@ export function listenErrorMessage(err, { what, host, port, portVar, portFlag =
172
172
  // for the units' file, and `clearotron start` — the command that installs those units, so the command
173
173
  // that is resolving the ports they will be born with — does not read it. Measured as a stranger on
174
174
  // published 0.1.4: the three ports sat in the units' file, the refusal fired, and the only way to see
175
- // why was to compare two lists of variable names in a log line (tracker issue 200).
175
+ // why was to compare two lists of variable names in a log line.
176
176
  //
177
177
  // The CALLER passes the file, and passes it only when its own loader actually read one. This module
178
178
  // cannot know: the same function serves four services booted by units — where naming the CLI's file
@@ -249,7 +249,7 @@ export function listenOrDie(server, {
249
249
  // — the env file this process actually read, from `envFileRead()`. Same contract as
250
250
  // `portSource` above: null is a caller that has not been taught the question, and its sentence is
251
251
  // exactly what it was. Never composed here; a service booted by systemd read no file and must name
252
- // none (tracker issue 200).
252
+ // none.
253
253
  portFile = null,
254
254
  // — "env" | "default" | null. Null is a caller that has not been taught the question yet and
255
255
  // behaves exactly as before; every service in this repo passes it.
@@ -77,7 +77,7 @@ export function blockedByAccessChallenge(where, status) {
77
77
  + "changed at your identity provider, not here, and NOT repaired by recreating the application "
78
78
  + "(recreating it loses this setting and changes the audience, which is two symptoms from one "
79
79
  + "cause). "
80
- // ── NAME THE SWITCH (tracker issue 149) ──────────────────────────────────────────────────────
80
+ // ── NAME THE SWITCH ──────────────────────────────────────────────────────────────────────────
81
81
  // The rule first and the vendor second, because the identity interface is configuration on
82
82
  // purpose: issuer, audience, claim and header are all settings, and a Cloudflare-only remedy
83
83
  // re-narrows an interface that was widened deliberately. But a rule with no switch behind it is
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // Every `CLEAROTRON_*` name this build reads. It exists so the retired-spelling check can name the
6
6
  // replacement for an old `PRELIM_*` line rather than only saying the old one is dead — see
7
- // shared/env-aliases.mjs and tracker issue 168. Re-mint with:
7
+ // shared/env-aliases.mjs. Re-mint with:
8
8
  //
9
9
  // node scripts/mint-names-in-force.mjs
10
10
  //
@@ -114,6 +114,7 @@ export const NAMES_IN_FORCE = Object.freeze([
114
114
  "CLEAROTRON_OPENAI_MODEL_JUDGMENT",
115
115
  "CLEAROTRON_OPENAI_MODEL_SWEEP",
116
116
  "CLEAROTRON_ORDER_PROBE_SEED",
117
+ "CLEAROTRON_ORGANISATION_NAME",
117
118
  "CLEAROTRON_OUTBOX_BACKOFF_BASE_SEC",
118
119
  "CLEAROTRON_OUTBOX_BACKOFF_CAP_SEC",
119
120
  "CLEAROTRON_OUTBOX_BACKOFF_MAX_RETRIES",
@@ -14,9 +14,26 @@
14
14
 
15
15
  import { existsSync } from "node:fs";
16
16
 
17
- /** A refusal is a sentence for the operator, not a stack trace. Every one names what was wrong. */
17
+ /**
18
+ * A refusal is a sentence for the operator, not a stack trace. Every one names what was wrong.
19
+ *
20
+ * IT ALSO CARRIES A CODE AND THE FACTS BEHIND IT, because the same refusal now reaches two very
21
+ * different readers. The command line's reader typed a flag and is standing in a terminal, so a sentence
22
+ * naming that flag and the directory it wrote to is exactly right. The browser's reader is a lawyer who
23
+ * has never seen either, and a sentence mentioning `--platforms` and a filesystem path tells them
24
+ * nothing they can act on — it tells them they are in the wrong product.
25
+ *
26
+ * So the message stays the command line's, and `code` plus `detail` let the other door write its own
27
+ * sentence from the same facts. A door with no wording for a code falls back to the message, which is
28
+ * wrong for a browser but never blank — an unworded refusal must not become a silent one.
29
+ */
18
30
  export class Refusal extends Error {
19
- constructor(message) { super(message); this.name = "Refusal"; }
31
+ constructor(message, { code = null, ...detail } = {}) {
32
+ super(message);
33
+ this.name = "Refusal";
34
+ this.code = code;
35
+ this.detail = detail;
36
+ }
20
37
  }
21
38
 
22
39
  /**
@@ -249,6 +249,44 @@ export const CLASSES = [
249
249
  // a floored line quietly gain a second one. The permissive half of a gate is the dangerous half.
250
250
  const EVERY_MATCH = new Map(CLASSES.filter((c) => c.pattern).map((c) => [c.id, new RegExp(c.pattern.source, c.pattern.flags + "g")]));
251
251
 
252
+ /**
253
+ * ── A CITATION THAT WRAPPED IS IN NEITHER LINE ─────────────────────────────────────────────────────
254
+ *
255
+ * Every class above reads ONE line, and prose in this tree wraps at a fixed width, so a citation whose
256
+ * words end one line and whose number begins the next matches nothing. Measured on a real branch: four
257
+ * citations went in, the census counted four and refused; three came out and the count returned to its
258
+ * floor with the fourth still in the tree, because that one had wrapped.
259
+ *
260
+ * WORSE THAN A MISCOUNT, BECAUSE THE CENSUS IS A RATCHET. The floor only falls, and the check passing is
261
+ * the statement that the tree grew nothing new — so a wrapped citation is not merely uncounted, it joins
262
+ * the floor's silence, and nobody looks again because the number did not move.
263
+ *
264
+ * A WRAP IS A WRAP ONLY WHEN NEITHER LINE CARRIES ONE ALONE. Joining line N to N+1 also matches when the
265
+ * whole citation sits on N+1, which would report every ordinary hit a second time as a wrap on the line
266
+ * above it. `WRAPPED_HEAD` requires line N to END mid-citation, and that is what makes the pair disjoint
267
+ * from the per-line count rather than overlapping it.
268
+ *
269
+ * THE TWO HEAD FORMS ARE COMPLETED BY DIFFERENT THINGS, and collapsing them is the false positive that
270
+ * matters: a line ending in the bare word `tracker` needs the word `issue` on the next one, while a line
271
+ * ending in `tracker issue` needs only a number — and without the split, any line ending in `tracker`
272
+ * followed by a numbered list item reads as a citation.
273
+ *
274
+ * ONE DEFINITION. The sweep that removes these reads these same exports; a second detector agreeing today
275
+ * is two detectors disagreeing later, and while the sweep could see this class and the census could not,
276
+ * neither could report the disagreement.
277
+ */
278
+ export const WRAPPED_HEAD = /\btracker\s*$|\btracker issues?\s*$/i;
279
+ export const WRAPPED_TAIL = /^\s*(?:\/\/|#|\*|--)?\s*(?:issues?\s+)?\d+/i;
280
+
281
+ /** True when `a` ends a citation that `b` completes. PURE. */
282
+ export const wrapsInto = (a, b) => {
283
+ if (!WRAPPED_HEAD.test(a) || b === undefined) return false;
284
+ return /\btracker\s*$/i.test(a) ? /^\s*(?:\/\/|#|\*|--)?\s*issues?\s+\d+/i.test(b) : /^\s*(?:\/\/|#|\*|--)?\s*\d+/.test(b);
285
+ };
286
+
287
+ /** The class a wrapped citation belongs to — the spelled one, because that is what it spells. */
288
+ export const WRAPPED_CLASS = "spelled-citation";
289
+
252
290
  export function offendingClasses(path, line) {
253
291
  if (!isScannable(path) || !isProse(path, line)) return [];
254
292
  const text = withoutColourValues(withoutLinkTargets(line));
@@ -311,8 +349,12 @@ export function censusOf(files, read) {
311
349
  try { text = read(path); } catch { continue; }
312
350
  if (text.includes("\0")) continue; // a binary blob is not prose
313
351
  const counts = CLASSES.map(() => 0);
314
- for (const line of text.split("\n")) {
315
- for (const { id } of offendingClasses(path, line)) counts[COLUMN.get(id)]++;
352
+ const lines = text.split("\n");
353
+ for (let i = 0; i < lines.length; i++) {
354
+ for (const { id } of offendingClasses(path, lines[i])) counts[COLUMN.get(id)]++;
355
+ // AND THE PAIR, which no per-line rule can see. Counted on the HEAD line and once: the head ends
356
+ // mid-citation, so this can never be the same hit the loop above just counted.
357
+ if (isProse(path, lines[i]) && wrapsInto(lines[i], lines[i + 1])) counts[COLUMN.get(WRAPPED_CLASS)]++;
316
358
  }
317
359
  const sum = counts.reduce((a, b) => a + b, 0);
318
360
  if (sum) { out.files[path] = counts; out.total += sum; }
@@ -9,7 +9,7 @@
9
9
  // handed in as a PARAMETER by every caller. That was right while the only callers were the wizard and
10
10
  // `bin/start.mjs`, which both hold it legitimately.
11
11
  //
12
- // tracker issue 216 added a third and a fourth — the runner's intake wall and the intake doors — and
12
+ // A later change added a third and a fourth — the runner's intake wall and the intake doors — and
13
13
  // neither is a CLI. Both would have had to dynamic-import a CLI entry point at call time to read a
14
14
  // 44-line data table, and the cycle that trick avoids is not theoretical: a static import in that
15
15
  // direction makes `clearotron doctor` exit 13 after printing most of a report, because onboard's