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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.env.example +13 -2
  2. package/CONTRIBUTING.md +1 -1
  3. package/INSTALL.md +12 -10
  4. package/README.md +3 -2
  5. package/bin/brandowner.mjs +94 -1
  6. package/bin/onboard.mjs +156 -55
  7. package/bin/passphrase.mjs +23 -4
  8. package/bin/start.mjs +177 -38
  9. package/build-info.json +2 -2
  10. package/docs/architecture/04-configuration-reference.md +5 -2
  11. package/docs/architecture/05-config-governance.md +3 -2
  12. package/driver/CHANGELOG.md +95 -0
  13. package/driver/demo-posture.mjs +59 -8
  14. package/driver/driver.config.mjs +36 -7
  15. package/driver/engine/child-record.mjs +93 -0
  16. package/driver/findings-model.mjs +6 -1
  17. package/driver/knockout-assess-record.mjs +6 -3
  18. package/driver/package.json +1 -1
  19. package/driver/portal-local-auth.mjs +98 -3
  20. package/driver/portal-service.mjs +65 -29
  21. package/driver/publish/knockout.mjs +12 -1
  22. package/driver/publish/office-record-links.mjs +61 -10
  23. package/driver/publish/render-knockout.mjs +31 -11
  24. package/driver/publish/xlsx.mjs +33 -1
  25. package/driver/register-records.mjs +6 -0
  26. package/driver/run-requirements.mjs +24 -1
  27. package/driver/runner.mjs +10 -5
  28. package/driver/skills/knockout-assess/SKILL.md +1 -1
  29. package/driver/suite-census.json +174 -30
  30. package/driver/unit-inventory.mjs +109 -5
  31. package/driver/updater-identity.mjs +178 -0
  32. package/driver/usage-ledger.mjs +5 -5
  33. package/mcp-server/CHANGELOG.md +6 -0
  34. package/mcp-server/http-server.mjs +16 -13
  35. package/mcp-server/lib/driver.mjs +7 -0
  36. package/mcp-server/lib/knockout.mjs +14 -2
  37. package/mcp-server/lib/ops.mjs +38 -16
  38. package/mcp-server/package.json +2 -2
  39. package/mcp-server/server.mjs +1 -1
  40. package/package.json +1 -1
  41. package/portal-ui/dist/assets/{index-CWTHP0sH.js → index-CcFjgM78.js} +32 -17
  42. package/portal-ui/dist/assets/{index-KpytsmNH.css → index-CsCuPshD.css} +7 -2
  43. package/portal-ui/dist/index.html +2 -2
  44. package/portal-ui/package.json +3 -3
  45. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  46. package/providers/oauth-mcp-bridge/package.json +2 -2
  47. package/scripts/changelog-plain-language.mjs +7 -30
  48. package/scripts/e2e.mjs +13 -2
  49. package/scripts/env-audit.mjs +10 -3
  50. package/scripts/env-classify.mjs +205 -12
  51. package/scripts/live-surface-check.mjs +74 -2
  52. package/scripts/plain-language-rules.mjs +103 -0
  53. package/scripts/release-note-required.mjs +118 -24
  54. package/scripts/release-notes-lint.mjs +22 -43
  55. package/scripts/release-version.mjs +59 -0
  56. package/scripts/revisit-render-check.mjs +6 -2
  57. package/scripts/text-difference.mjs +22 -0
  58. package/shared/env-local.mjs +25 -2
  59. package/shared/names-in-force.mjs +1 -0
  60. package/shared/reap-on-exit.mjs +27 -14
  61. package/shared/store-in-repo.mjs +147 -0
  62. package/shared/withheld-paths-access.mjs +6 -6
  63. package/shared/yes-no-echo.mjs +35 -0
@@ -2049,9 +2049,14 @@ input.filter {
2049
2049
  cursor: not-allowed;
2050
2050
  }
2051
2051
 
2052
- /* the sentence that replaces the Stop button while a stop is in flight. */
2053
- .home2-stopping {
2052
+ /* The sentence that replaces the Stop button while a stop is in flight.
2053
+ ITS OWN ROW, BELOW THE FOOT, not a flex item inside it. `home2-card-foot` is a flex row of short
2054
+ things — the elapsed line and one button — and this is a paragraph. As a member of that row it
2055
+ squeezed "1 min so far" into a two-character column and the card read as garbled on a real run. */
2056
+ .home2-stop-note {
2057
+ margin-top: 10px;
2054
2058
  font-size: 12px;
2059
+ line-height: 1.5;
2055
2060
  color: var(--text-muted);
2056
2061
  font-style: italic;
2057
2062
  }
@@ -49,8 +49,8 @@
49
49
  -->
50
50
  <link rel="preconnect" href="https://api.fontshare.com" crossorigin />
51
51
  <link href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700,900&display=swap" rel="stylesheet" />
52
- <script type="module" crossorigin src="/portal/assets/index-CWTHP0sH.js"></script>
53
- <link rel="stylesheet" crossorigin href="/portal/assets/index-KpytsmNH.css">
52
+ <script type="module" crossorigin src="/portal/assets/index-CcFjgM78.js"></script>
53
+ <link rel="stylesheet" crossorigin href="/portal/assets/index-CsCuPshD.css">
54
54
  </head>
55
55
  <body>
56
56
  <div id="root"></div>
@@ -2,17 +2,17 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.0-beta.1",
5
+ "version": "0.3.0-beta.2",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
8
8
  "engines": {
9
- "node": ">=22"
9
+ "node": ">=22.13.0"
10
10
  },
11
11
  "scripts": {
12
12
  "build": "vite build",
13
13
  "dev": "vite",
14
14
  "typecheck": "tsc --noEmit",
15
- "test": "npm run typecheck && node ../scripts/test-run.mjs node --test test/*.test.ts",
15
+ "test": "npm run typecheck && node ../scripts/test-run.mjs node --experimental-strip-types --test test/*.test.ts",
16
16
  "test:full": "npm run test"
17
17
  },
18
18
  "dependencies": {
@@ -1,5 +1,9 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.3.0-beta.2
4
+
5
+ No changes in this release.
6
+
3
7
  ## 0.3.0-beta.1
4
8
 
5
9
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.3.0-beta.1",
3
+ "version": "0.3.0-beta.2",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "OAuth 2.1 MCP stdio bridge used by the engine's case-law gather stage (courtlistener / legaldatahunter).",
@@ -9,5 +9,5 @@
9
9
  "dependencies": {
10
10
  "@modelcontextprotocol/sdk": "^1.29.0"
11
11
  },
12
- "engines": { "node": ">=22" }
12
+ "engines": { "node": ">=22.13.0" }
13
13
  }
@@ -17,48 +17,25 @@
17
17
  // to who wrote the line.
18
18
  import { readFileSync } from "node:fs";
19
19
  import { isEntrypoint } from "../shared/is-entrypoint.mjs";
20
+ import { lineFindings, sourceDirectories, userDocs } from "./plain-language-rules.mjs";
20
21
 
21
- // The four the issue names, plus the two spellings each of the -ise/-ize pair it names once. A word
22
- // list is checked whole: "implementation" is jargon, "implement" inside "implemented" is the same word,
23
- // but "complement" is not, which is why this is a boundary match and not a substring one.
24
- export const BANNED_WORDS = [
25
- "refactor", "refactors", "refactored", "refactoring",
26
- "implement", "implements", "implemented", "implementing", "implementation", "implementations",
27
- "leverage", "leverages", "leveraged", "leveraging",
28
- "optimise", "optimises", "optimised", "optimising", "optimisation",
29
- "optimize", "optimizes", "optimized", "optimizing", "optimization",
30
- "utilise", "utilises", "utilised", "utilising", "utilisation",
31
- "utilize", "utilizes", "utilized", "utilizing", "utilization",
32
- ];
22
+ // THE RULES live in plain-language-rules.mjs, shared with the note lint, so a note CI passes cannot turn
23
+ // this gate red after the merge. BANNED_WORDS is re-exported for the callers that read it from here.
24
+ export { BANNED_WORDS } from "./plain-language-rules.mjs";
33
25
 
34
- // A FILE NAME is the thing a reader cannot act on: they do not have the tree open. Matched by
35
- // extension, because a bare word with a dot in it is how every file name in this repository reads.
36
- const FILE_NAME_RE = /\b[\w.-]+\.(mjs|js|cjs|ts|tsx|jsx|json|yml|yaml|md|sh|txt)\b/g;
37
- // A PATH, with or without an extension: two segments joined by a slash.
38
- const PATH_RE = /\b[\w.-]+\/[\w./-]+/g;
39
- // A FUNCTION NAME, in the two shapes this tree writes them: a call, and a bare camelCase identifier.
40
- const FUNCTION_RE = /\b[a-z][A-Za-z0-9_]*\(\)|\b[a-z]+[A-Z][A-Za-z0-9]*\b/g;
41
-
42
- const WORD_RE = new RegExp(`\\b(${BANNED_WORDS.join("|")})\\b`, "gi");
43
26
 
44
27
  /**
45
28
  * Every reason this text may not be shown to a reader. Empty means it may.
46
29
  *
47
- * Code spans are exempt: a line that says `` `clearotron --version` `` is telling a reader what to
48
- * type, which is the opposite of jargon. Everything outside the backticks is still checked.
30
+ * The line rules are plain-language-rules.mjs's, the same ones the note lint applies on the pull request.
49
31
  */
50
- export function findings(text) {
32
+ export function findings(text, { sourceDirs = sourceDirectories(), docs = userDocs() } = {}) {
51
33
  const out = [];
52
34
  const lines = text.split("\n");
53
35
  for (const [i, raw] of lines.entries()) {
54
36
  // A heading is changesets' own furniture ("## 0.2.0", "### Patch Changes"), not authored prose.
55
37
  if (/^\s*#{1,6}\s/.test(raw)) continue;
56
- const line = raw.replace(/`[^`]*`/g, (m) => " ".repeat(m.length));
57
- const at = (what, m) => out.push({ line: i + 1, kind: what, match: m, text: raw.trim() });
58
- for (const m of line.matchAll(WORD_RE)) at("jargon word", m[0]);
59
- for (const m of line.matchAll(FILE_NAME_RE)) at("file name", m[0]);
60
- for (const m of line.matchAll(PATH_RE)) at("path", m[0]);
61
- for (const m of line.matchAll(FUNCTION_RE)) at("function name", m[0]);
38
+ for (const f of lineFindings(raw, { sourceDirs, docs })) out.push({ line: i + 1, kind: f.kind, match: f.match, text: raw.trim() });
62
39
  }
63
40
  return out;
64
41
  }
package/scripts/e2e.mjs CHANGED
@@ -2393,6 +2393,8 @@ export function doorRefusal(receipt, ref, expect = null) {
2393
2393
  const secs = (n) => (n == null ? "?" : n >= 3600 ? `${Math.floor(n / 3600)}h${String(Math.floor((n % 3600) / 60)).padStart(2, "0")}m`
2394
2394
  : n >= 60 ? `${Math.floor(n / 60)}m${String(Math.round(n % 60)).padStart(2, "0")}s` : `${Math.round(n)}s`);
2395
2395
 
2396
+ const DEPTH_UNREAD = "narrative-write-ups:could-not-read";
2397
+
2396
2398
  function runLedger(runDir) {
2397
2399
  const st = readJson(join(runDir, "status.json")) ?? {};
2398
2400
  const dd = driverDir(runDir);
@@ -2437,7 +2439,15 @@ function runLedger(runDir) {
2437
2439
  if (row?.degraded) degraded.push(`unit ${key}: ${row.degradedCause ?? "cause not recorded"} (attempt ${row.attempts ?? "not recorded"})`);
2438
2440
  }
2439
2441
 
2440
- return { st, attempts, wall, degraded, stageLogs: files.length };
2442
+ // A GRADED RUN WHOSE NARRATIVE THE DEPTH CHECK COULD NOT READ delivered with the depth rules applied to
2443
+ // nothing. The receipt says so in a check of its own, and nothing a test lane reads carried it: the
2444
+ // client's mail holds no machine-check lines, and the workbook's Machine Checks sheet is for the reviewing
2445
+ // lawyer. Read from either shape a receipt carries, the check object or the stored list of failing ids.
2446
+ const lint = readJson(join(dd, "predelivery-lint.json"));
2447
+ const depthCheck = (Array.isArray(lint?.checks) ? lint.checks : []).find((c) => c?.id === DEPTH_UNREAD) ?? null;
2448
+ const depthUnread = depthCheck ? depthCheck.pass === false : (Array.isArray(lint?.failures) && lint.failures.includes(DEPTH_UNREAD));
2449
+
2450
+ return { st, attempts, wall, degraded, depthUnread, stageLogs: files.length };
2441
2451
  }
2442
2452
 
2443
2453
  // Everything worth a human's attention, in one list. Each entry is a thing to INVESTIGATE — never a
@@ -2453,7 +2463,7 @@ function brief(s, max = 150) {
2453
2463
  return `${t.slice(0, head)} … ${t.slice(-(max - head - 3))}`;
2454
2464
  }
2455
2465
 
2456
- function investigate({ st, attempts, degraded }) {
2466
+ function investigate({ st, attempts, degraded, depthUnread = false }) {
2457
2467
  const out = [];
2458
2468
  if (!attempts.length) out.push(`no model-attempt records found — the ledger cannot describe this run; do not read that as clean`);
2459
2469
 
@@ -2489,6 +2499,7 @@ function investigate({ st, attempts, degraded }) {
2489
2499
  // the row names its own kind ("lane zh: …" / "unit serp-grid:zh: …") — units degrade for different
2490
2500
  // reasons than lanes do, and a hardcoded "lane" here would mislabel every one of them
2491
2501
  for (const d of degraded) out.push(`degraded — ${d}`);
2502
+ if (depthUnread) out.push(`depth rules applied to nothing — ${DEPTH_UNREAD}: the narrative carries no write-up block this check can read, so neither the band-rank cut nor the word cap was verified on this run`);
2492
2503
  if (st.state && st.state !== "delivered") out.push(`terminal state is "${st.state}"${st.failedStage ? ` at ${st.failedStage}` : ""}${st.reason ? ` — ${brief(st.reason, 120)}` : ""}`);
2493
2504
  return out;
2494
2505
  }
@@ -79,7 +79,13 @@ const asList = process.argv.includes("--list");
79
79
  // the catalogue ratchet then demands an operator row for a value the driver overwrites — a row
80
80
  // teaching the operator to configure something they must not touch. `==`/`===` are excluded from
81
81
  // the exclusion, because a comparison is a read.
82
- const READ_RE = /(?<![.\w$])(?:process\.)?env\.([A-Z][A-Z0-9_]*)(?![A-Z0-9_])(?!\s*=[^=])|(?<![.\w$])(?:process\.)?env\[\s*["']([A-Z][A-Z0-9_]*)["']\s*\](?!\s*=[^=])/g;
82
+ //
83
+ // AND `?.` IS THE SAME READ. `env?.X`, `process.env?.X` and `env?.["X"]` read exactly what `env.X`
84
+ // reads, and the pattern used to stop at the `?`: a name read only that way needed no governance row,
85
+ // no catalogue row and no effect declaration, and every catalogue check stayed green about it. Measured
86
+ // 2026-09-10, three product names were read only through `?.`. An optional chain cannot be assigned
87
+ // through, so the assignment guard has nothing new to exclude.
88
+ const READ_RE = /(?<![.\w$])(?:process\??\.)?env\??\.([A-Z][A-Z0-9_]*)(?![A-Z0-9_])(?!\s*=[^=])|(?<![.\w$])(?:process\??\.)?env(?:\?\.)?\[\s*["']([A-Z][A-Z0-9_]*)["']\s*\](?!\s*=[^=])/g;
83
89
  const CODE_RE = /\.(mjs|js|cjs|ts)$/;
84
90
 
85
91
  // ── AN ACCESSOR CALL IS A READ, AND STEP 4 MADE IT THE COMMON ONE ────────────────────────────────
@@ -193,8 +199,9 @@ export function mergeEnvNameBindings(perFile) {
193
199
  }
194
200
 
195
201
  // The computed read whose subscript is a BARE IDENTIFIER. `env["X"]` is READ_RE's; this is the one that
196
- // needs the map. Same assignment guard as its siblings — `env[X] = v` is a write.
197
- const CONST_READ_RE = /(?<![.\w$])(?:process\.)?env\[\s*([A-Z][A-Z0-9_]*)\s*\](?!\s*=[^=])/g;
202
+ // needs the map. Same assignment guard as its siblings — `env[X] = v` is a write. `env?.[X]` is the same
203
+ // read as `env[X]`, for the reason READ_RE gives.
204
+ const CONST_READ_RE = /(?<![.\w$])(?:process\??\.)?env(?:\?\.)?\[\s*([A-Z][A-Z0-9_]*)\s*\](?!\s*=[^=])/g;
198
205
 
199
206
  export function namesRead(text, bindings = null) {
200
207
  const found = new Set();
@@ -10,6 +10,7 @@
10
10
  // node scripts/env-classify.mjs print the classification
11
11
  // node scripts/env-classify.mjs --apply write docs/architecture/env-classification.json
12
12
  // node scripts/env-classify.mjs --check rebuild and diff against the committed artifact
13
+ // node scripts/env-classify.mjs --check --artifact <path> …against another copy, for a driven check
13
14
  // node scripts/env-classify.mjs --gather-prod --unit-dir <d> --env-file <f> the production name list
14
15
  //
15
16
  // ── WHAT IS READ, AND WHAT IS NEVER READ ─────────────────────────────────────────────────────────
@@ -47,6 +48,7 @@ import { execFileSync } from "node:child_process";
47
48
  import { join, dirname } from "node:path";
48
49
  import { fileURLToPath } from "node:url";
49
50
  import { isEntrypoint } from "../shared/is-entrypoint.mjs";
51
+ import { catalogueRows } from "./env-audit.mjs";
50
52
 
51
53
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
52
54
  const ART = join(ROOT, "docs/architecture/env-classification.json");
@@ -54,6 +56,33 @@ const PROD = join(ROOT, "docs/architecture/env-set-in-production.txt");
54
56
  const read = (p) => { try { return readFileSync(p, "utf8"); } catch { return ""; } };
55
57
  const git = (...a) => { try { return execFileSync("git", ["-C", ROOT, ...a], { encoding: "utf8", maxBuffer: 1e8 }); } catch { return ""; } };
56
58
 
59
+ // THE ENV EXAMPLE FILES, NAMED ONCE AND READ ONE WAY. This module had two lists of them, and the one that
60
+ // decides `documented` left out `.env.deployment.example`, the largest, while the other read all four.
61
+ // Every name documented only there was recorded as undocumented: 159 in the committed classification
62
+ // when that was measured, 2026-09-10. A row is read by the catalogue's own `catalogueRows`, so "has a
63
+ // row" means here what it means in the audit.
64
+ export const ENV_EXAMPLE_FILES = [".env.example", ".env.deployment.example", ".env.dev.example", ".env.prod.example"];
65
+
66
+ /**
67
+ * Every name the example files carry a row for, commented or not.
68
+ *
69
+ * AN ABSENT FILE ADDS NOTHING. Three of the four are withheld from the public tree, and a public checkout
70
+ * must still gather; the artifact is minted only where they are laid, because it refuses without the
71
+ * production list that is withheld with them. A file that EXISTS and cannot be read refuses instead, for
72
+ * the reason `mustRead` gives: read as empty, every name it documents would record as undocumented.
73
+ */
74
+ export function exampleNames(root = ROOT) {
75
+ return ENV_EXAMPLE_FILES.flatMap((f) => {
76
+ const p = join(root, f);
77
+ let text = "";
78
+ try { text = readFileSync(p, "utf8"); } catch (e) {
79
+ if (e.code !== "ENOENT")
80
+ throw new Error(`env-classify: ${p} exists and could not be read (${e.code}). Read as empty, every name it documents would record as undocumented.`);
81
+ }
82
+ return catalogueRows(text).map((r) => r.name);
83
+ });
84
+ }
85
+
57
86
  /** Assignment NAMES from a shell-shaped env file. Values are matched and discarded, never returned. */
58
87
  export function namesInEnvFile(text) {
59
88
  return [...text.matchAll(/^[ \t]*(?:export[ \t]+)?([A-Z][A-Z0-9_]*)=/gm)].map((m) => m[1]);
@@ -169,8 +198,7 @@ export function gather({ root = ROOT, prodList = null } = {}) {
169
198
  e2e: set(["scripts/e2e.mjs", "scripts/test-run.mjs"]
170
199
  .flatMap((f) => namesMentioned(mustRead(root, f, "It is the end-to-end surface, and the names it sets are set nowhere else.")))),
171
200
  // A DESCRIPTION, never a setting. Kept apart so `everSet` cannot be satisfied by documentation.
172
- docs: set([".env.example", ".env.dev.example", ".env.prod.example"]
173
- .flatMap((f) => [...read(join(root, f)).matchAll(/^#?\s*([A-Z][A-Z0-9_]*)=/gm)].map((m) => m[1]))),
201
+ docs: set(exampleNames(root)),
174
202
  _tracked: tracked,
175
203
  };
176
204
  }
@@ -194,11 +222,101 @@ export function setupNames(root = ROOT) {
194
222
  // the wizard writes it through a COMPUTED key, `candidate[eng.headless.tokenEnv]`, so the literal
195
223
  // candidate-write matchers above cannot see it; the table field is the one derivable spelling).
196
224
  for (const m of cfg.matchAll(/(?:env|authEnv|apiKeyEnv|credEnv|tokenEnv):\s*"([A-Z][A-Z0-9_]*)"/g)) seed.add(m[1]);
225
+ // THE NAMES A HELPER RETURNS AND THE WIZARD MERGES IN. The sign-in step hands back the `.env` keys it
226
+ // wants written, and the wizard writes them with `Object.assign(candidate, await askSignIn(…))`. None of
227
+ // the matchers above sees a key inside a returned object, so both names that step writes — the address
228
+ // that signs in and the organisation's name — fell through every shape, the organisation's name to
229
+ // `tuning`: the residual bucket, and the one a cleanup deletes from. Read from the MERGE, not from the
230
+ // object's shape: a rule taking every object literal with upper-case keys would take in every constant
231
+ // table in the wizard, and grow this population by names nothing writes.
232
+ for (const n of namesMergedIntoCandidate(onboard)) seed.add(n);
197
233
  const out = new Set();
198
234
  for (const n of seed) for (const sp of [n]) out.add(sp);
199
235
  return out;
200
236
  }
201
237
 
238
+ /**
239
+ * Every name the wizard writes through an object a helper RETURNS: each `Object.assign(candidate, NAME(…))`
240
+ * names a helper, and the top-level keys of the object literals it returns are the names it writes.
241
+ *
242
+ * A helper named at a merge and not defined in the wizard REFUSES, and so does one whose returns name no
243
+ * key. Either way its names are written at install and cannot be read here, and reading them as none puts
244
+ * them in `tuning` with nothing saying the look failed — the silence `mustRead` exists for.
245
+ */
246
+ function namesMergedIntoCandidate(src) {
247
+ const names = new Set();
248
+ const helpers = new Set([...src.matchAll(/Object\.assign\(\s*candidate\s*,\s*(?:await\s+)?([A-Za-z_$][\w$]*)\s*\(/g)]
249
+ .map((m) => m[1]));
250
+ for (const fn of helpers) {
251
+ const at = src.search(new RegExp(`\\bfunction\\s+${fn.replace(/\$/g, "\\$")}\\s*\\(`));
252
+ const refuse = (why) => new Error(`env-classify: bin/onboard.mjs writes what ${fn}() returns, and ${why}, so `
253
+ + "the names it writes cannot be read. Read as none, they would land in `tuning` and on the deletion "
254
+ + "population; that is this script failing to look, not a finding about the wizard.");
255
+ if (at < 0) throw refuse(`no function ${fn} is defined there`);
256
+ // From the declaration on, with comments and the inside of every string blanked, so a brace, a comma or
257
+ // a `return` in either is not read as code. A parameter default can hold braces, so the body is the
258
+ // first `{` after the parameter list, not the first `{`.
259
+ const text = src.slice(at), code = blankNonCode(text);
260
+ const open = code.indexOf("{", closingOf(code, code.indexOf("(")));
261
+ const close = closingOf(code, open);
262
+ let found = 0;
263
+ for (const r of code.slice(open, close).matchAll(/\breturn\s*\{/g)) {
264
+ for (const key of topLevelKeys(text, code, open + r.index + r[0].length - 1)) { names.add(key); found++; }
265
+ }
266
+ if (!found) throw refuse("none of its returns is an object literal naming a key");
267
+ }
268
+ return names;
269
+ }
270
+
271
+ /** `text` with every comment and the inside of every string turned to spaces. Same length, same lines. */
272
+ function blankNonCode(text) {
273
+ let out = "", i = 0;
274
+ const blank = (to) => { out += text.slice(i, to).replace(/[^\n]/g, " "); i = to; };
275
+ while (i < text.length) {
276
+ const c = text[i], d = text[i + 1];
277
+ if (c === "/" && (d === "/" || d === "*")) {
278
+ const end = d === "/" ? text.indexOf("\n", i) : text.indexOf("*/", i + 2) + 2;
279
+ blank(end < i + 2 ? text.length : end);
280
+ } else if (c === '"' || c === "'" || c === "`") {
281
+ let j = i + 1;
282
+ while (j < text.length && text[j] !== c && (c === "`" || text[j] !== "\n")) j += text[j] === "\\" ? 2 : 1;
283
+ out += c; i += 1; blank(Math.min(j, text.length));
284
+ if (i < text.length) out += text[i++];
285
+ } else {
286
+ out += c; i += 1;
287
+ }
288
+ }
289
+ return out;
290
+ }
291
+
292
+ /** The index of the bracket closing the one at `at`, counting every kind; `code` is already blanked. */
293
+ function closingOf(code, at) {
294
+ let depth = 0;
295
+ for (let i = at; i < code.length; i++) {
296
+ if ("([{".includes(code[i])) depth++;
297
+ else if (")]}".includes(code[i]) && --depth === 0) return i;
298
+ }
299
+ return code.length;
300
+ }
301
+
302
+ /** The top-level keys of the object literal opening at `open`: a nested object's keys are its own. */
303
+ function topLevelKeys(text, code, open) {
304
+ const keys = [];
305
+ const close = closingOf(code, open);
306
+ let depth = 0, from = open + 1;
307
+ for (let i = open + 1; i <= close; i++) {
308
+ const c = code[i];
309
+ if (i < close && "([{".includes(c)) depth++;
310
+ else if (i < close && ")]}".includes(c)) depth--;
311
+ else if (i === close || (c === "," && depth === 0)) {
312
+ const m = /^\s*(["']?)([A-Z][A-Z0-9_]*)\1\s*:/.exec(text.slice(from, i));
313
+ if (m) keys.push(m[2]);
314
+ from = i + 1;
315
+ }
316
+ }
317
+ return keys;
318
+ }
319
+
202
320
  // THE AUDIENCE DECISION FOR NAMES A PREFIX USED TO CARRY — listed, because a rename must not move it.
203
321
  //
204
322
  // Every name here was classified `deployment` by one of DEPLOY_RE's PREFIX arms and by nothing else.
@@ -210,7 +328,9 @@ export function setupNames(root = ROOT) {
210
328
  //
211
329
  // Nothing would have said so. `--check` is the only thing that compares the artifact to the tree, and
212
330
  // it ran in no workflow, no hook and no test; the reproducibility arm deliberately does not re-run the
213
- // classifier. This commit wires `--check` into CI, which is the half that makes the drift audible.
331
+ // classifier. It cannot run in this repository's CI at all: the artifact and the production name list it
332
+ // needs are withheld from the public tree, so here it stops at "could not look". It runs where they are
333
+ // laid, in the private control, and a stale artifact there names the rows that moved.
214
334
  //
215
335
  // The comment beside AUDIENCE/ISSUER below already recorded this class, one name at a time:
216
336
  // "a rename is exactly when a shape-based classifier goes wrong, and this one is a rename programme."
@@ -226,6 +346,14 @@ export const DEPLOYMENT_NAMES = new Set([
226
346
  // by nothing else, so moving it to the house prefix dropped it through to `tuning` — the bucket step 3
227
347
  // deletes from. The audience did not change; only the spelling did, and this list is what says so.
228
348
  "CLEAROTRON_DEMO",
349
+ // Three names read only through optional chaining, so no catalogue check saw them until the scanner
350
+ // learned `?.`. Each row declares `deployment`, and no shape matches them (`_PORT$` does not take
351
+ // `_PORTS`), so unlisted each fell through to `tuning`, the bucket step 3 deletes from. The first is set
352
+ // by the dispatcher for the verb it runs and never by an operator; the other two are a server install's
353
+ // port and deploy-health settings.
354
+ "CLEAROTRON_INVOKED_AS",
355
+ "CLEAROTRON_REQUIRE_EXPLICIT_PORTS",
356
+ "CLEAROTRON_UPDATER_STAMP",
229
357
  "CLIENT_MCP_ACCOUNT_ACCESS",
230
358
  "CLIENT_MCP_ALLOWED_HOSTS",
231
359
  "CLIENT_MCP_AUTH_DISABLED",
@@ -270,6 +398,9 @@ export const DEPLOYMENT_NAMES = new Set([
270
398
  "TRADEMARK_MCP_AUTH_MODE",
271
399
  "TRADEMARK_MCP_DEV",
272
400
  "TRADEMARK_MCP_EMAIL_CLAIM",
401
+ // The key door's socket path. Its row declares `deployment`, and until it was listed the
402
+ // `TRADEMARK_MCP_` prefix arm was its only route there, which a house-prefix sweep would take away.
403
+ "TRADEMARK_MCP_KEY_SOCKET",
273
404
  "TRADEMARK_MCP_MAX_BYTES",
274
405
  "TRADEMARK_MCP_SESSION_MAX",
275
406
  "TRADEMARK_MCP_SESSION_TTL_MS",
@@ -373,6 +504,9 @@ export function classify({ catalogue, sources, setup = setupNames(), readSites =
373
504
  // `declared` is the catalogue's own `# effect:` for this name, carried on the row so the artifact
374
505
  // says what the document claims beside what this script derived. The two are different questions and
375
506
  // the row is where a reader compares them.
507
+ //
508
+ // `documented` is a row in any example file. The minted catalogue is itself read from two of those
509
+ // files, so every row there reads true; a false would mean a name reached it from somewhere else.
376
510
  return { name, class: cls(name), everSet: setIn, declared: declared.get(name) ?? null,
377
511
  documented: Boolean(sources.docs?.has(name)) };
378
512
  });
@@ -465,6 +599,60 @@ export function classify({ catalogue, sources, setup = setupNames(), readSites =
465
599
  } };
466
600
  }
467
601
 
602
+ /**
603
+ * PURE. Which rows of the classification moved between the committed artifact and the tree.
604
+ *
605
+ * @param {object|null} prev the committed artifact, parsed; null when it could not be read
606
+ * @param {object} next the classification this tree produces
607
+ * @returns {{ added: string[], removed: string[], changed: Array<{ name: string, fields: string[] }>, header: string[] }}
608
+ * `changed` lists every field that differs, compared as JSON, so a field one side lacks counts as changed.
609
+ * `header` names the top-level keys other than `rows` that differ, such as the counts.
610
+ */
611
+ export function classificationDrift(prev, next) {
612
+ // A NAME CAN SIT ON MORE THAN ONE ROW. The catalogue can list a name more than once, and the
613
+ // classification then carries it once per listing, the rows identical in every field. So rows are
614
+ // grouped by name and compared as lists. Keyed by name alone, the last row of each name hid the others,
615
+ // and a change to the first of two rows read as "only the formatting differs".
616
+ const rowsOf = (a) => {
617
+ const m = new Map();
618
+ for (const r of Array.isArray(a?.rows) ? a.rows : []) m.set(r?.name, [...(m.get(r?.name) ?? []), r]);
619
+ return m;
620
+ };
621
+ const was = rowsOf(prev), now = rowsOf(next);
622
+ const added = [...now.keys()].filter((n) => !was.has(n)).sort();
623
+ const removed = [...was.keys()].filter((n) => !now.has(n)).sort();
624
+ const changed = [];
625
+ for (const [name, rows] of now) {
626
+ const before = was.get(name);
627
+ if (!before) continue;
628
+ if (before.length !== rows.length) { changed.push({ name, fields: [`${before.length} row${before.length === 1 ? "" : "s"}, now ${rows.length}`] }); continue; }
629
+ const fields = new Set();
630
+ rows.forEach((row, i) => {
631
+ for (const k of new Set([...Object.keys(before[i]), ...Object.keys(row)]))
632
+ if (JSON.stringify(before[i][k]) !== JSON.stringify(row[k])) fields.add(k);
633
+ });
634
+ if (fields.size) changed.push({ name, fields: [...fields].sort() });
635
+ }
636
+ changed.sort((a, b) => a.name.localeCompare(b.name));
637
+ const header = [...new Set([...Object.keys(prev ?? {}), ...Object.keys(next ?? {})])]
638
+ .filter((k) => k !== "rows" && JSON.stringify(prev?.[k]) !== JSON.stringify(next?.[k])).sort();
639
+ return { added, removed, changed, header };
640
+ }
641
+
642
+ /** PURE. The drift as lines a reader can act on, each list capped so a large drift stays readable. */
643
+ export function describeDrift(d, { cap = 12, unreadable = false, missing = false } = {}) {
644
+ const list = (names) => names.length > cap ? `${names.slice(0, cap).join(", ")} and ${names.length - cap} more` : names.join(", ");
645
+ const out = [];
646
+ if (missing) out.push(" the committed artifact is absent, so every row below reads as added");
647
+ if (unreadable) out.push(" the committed artifact is not valid JSON, so every row below reads as added");
648
+ if (d.added.length) out.push(` ${d.added.length} row(s) added: ${list(d.added)}`);
649
+ if (d.removed.length) out.push(` ${d.removed.length} row(s) removed: ${list(d.removed)}`);
650
+ if (d.changed.length) out.push(` ${d.changed.length} row(s) changed: ${list(d.changed.map((c) => `${c.name} (${c.fields.join(", ")})`))}`);
651
+ if (d.header.length) out.push(` outside the rows: ${d.header.join(", ")}`);
652
+ if (!out.length) out.push(" no row differs, and neither does anything outside the rows: the difference is in the file's formatting");
653
+ return out;
654
+ }
655
+
468
656
  function build() {
469
657
  const audit = JSON.parse(execFileSync(process.execPath, [join(ROOT, "scripts/env-audit.mjs"), "--json"],
470
658
  { encoding: "utf8", cwd: ROOT, maxBuffer: 1e8 }));
@@ -534,13 +722,8 @@ function main() {
534
722
  // AND IT REPORTS WHAT IT DROPPED. A filter that drops silently replaces one invisible loss with
535
723
  // another; the shape goes to stderr so the artifact stays exactly the names, and it is grouped by
536
724
  // first token and COUNTED rather than listed, because a dropped name can identify someone.
537
- const catalogueNames = [];
538
- for (const f of [".env.example", ".env.deployment.example", ".env.dev.example", ".env.prod.example"]) {
539
- try {
540
- const txt = readFileSync(new URL(`../${f}`, import.meta.url), "utf8");
541
- for (const m of txt.matchAll(/^#?\s*([A-Z][A-Z0-9_]*)\s*=/gm)) catalogueNames.push(m[1]);
542
- } catch { /* an absent example file narrows the allowlist; the drop report shows the effect */ }
543
- }
725
+ // An absent example file narrows the allowlist; the drop report shows the effect.
726
+ const catalogueNames = exampleNames();
544
727
  const isOwned = makeIsProductOwned({ credentialNames: AMBIENT_KEYS, catalogueNames });
545
728
  const { kept, dropped } = partitionByOwnership([...names], isOwned);
546
729
  console.error(`# ${FILTER_DECLARATION}`);
@@ -564,10 +747,20 @@ function main() {
564
747
  }
565
748
  if (arg === "--apply") { writeFileSync(ART, JSON.stringify(next, null, 2) + "\n"); console.log(`wrote ${ART}`); return; }
566
749
  if (arg === "--check") {
567
- const prev = existsSync(ART) ? readFileSync(ART, "utf8") : "";
750
+ // `--artifact <path>` compares against another copy instead of the committed one, so a driven check
751
+ // can hand it an artifact with a row removed without touching the file a reviewer reads.
752
+ const against = flag("artifact") || ART;
753
+ const prev = existsSync(against) ? readFileSync(against, "utf8") : "";
568
754
  const same = prev === JSON.stringify(next, null, 2) + "\n";
569
755
  console.log(same ? "CHECK — the committed classification matches this tree." : "CHECK FAILED — the classification is stale. Re-stamp: node scripts/env-classify.mjs --apply");
570
- if (!same) process.exitCode = 1;
756
+ if (!same) {
757
+ // SAY WHICH ROWS. "Stale" alone sends the reader to diff two large JSON files by eye; the rows that
758
+ // moved are what they have to judge, and they are the part a regeneration would silently absorb.
759
+ let before = null;
760
+ try { before = JSON.parse(prev); } catch { /* unreadable: said below */ }
761
+ for (const line of describeDrift(classificationDrift(before, next), { unreadable: prev !== "" && before === null, missing: prev === "" })) console.log(line);
762
+ process.exitCode = 1;
763
+ }
571
764
  return;
572
765
  }
573
766
  console.log(JSON.stringify(next._counts));
@@ -88,7 +88,7 @@ import { rosterVerdict } from "../driver/roster-verdict.mjs";
88
88
  import { triggerCapGap, triggerCapWarning } from "../driver/trigger-cap.mjs"; // the portal's own sentence about a key's gap
89
89
  import { bundledDemoKeys } from "../driver/bundled-demos.mjs";
90
90
  import { unitsActiveVerdict } from "../driver/unit-state-verdict.mjs";
91
- import { deploymentBox } from "../shared/deployment-box.mjs"; // — extracted; one allowlist, two readers
91
+ import { deploymentBox, DEPLOYMENT_BOXES } from "../shared/deployment-box.mjs"; // — extracted; one allowlist, every reader
92
92
  import { unitFileDriftVerdict } from "../driver/unit-file-drift.mjs"; //
93
93
  import { placeholdersIn, resolveValues, renderUnit } from "../driver/systemd/render-units.mjs"; //
94
94
  import { CHECKED_UNITS, CHECKED_TIMERS, timerVerdict, unitInventoryVerdict, serviceCommitVerdict, unitWorkingDirectory, unitClone } from "../driver/unit-inventory.mjs"; // · -bundle ·
@@ -100,6 +100,7 @@ import { config } from "../driver/driver.config.mjs"; /
100
100
  import { probeQueueWatch, probeWorker, probeTimer } from "../driver/queue-watch-probe.mjs"; // · and, tracker issue 206, the units that say HOW this box drains
101
101
  import { doorPostureVerdict } from "../mcp-server/door-posture.mjs"; // — a door whose mode came from another door's variables
102
102
  import { readDrainerStamp, drainerVerdict, defaultPpidOf } from "../driver/drainer-identity.mjs"; // — the process that EXECUTES runs
103
+ import { readUpdaterStamp, updaterVerdict, resolveUpdaterStampPath, updaterAbsentHere, UPDATER_STAMP_BASENAME } from "../driver/updater-identity.mjs"; // — the mechanism that PLACES commits
103
104
  import { claimerIsAlive } from "../driver/claim-liveness.mjs"; // the shared liveness test, same polarity as the queue's
104
105
  import { processTable } from "../shared/process-table.mjs"; // — /proc is not the only box
105
106
  import { envFrom } from "../shared/env-aliases.mjs"; // — the name a reader is told to set is the one in force
@@ -801,6 +802,77 @@ else {
801
802
  }
802
803
  }
803
804
 
805
+ // ── 8c. — THE MECHANISM THAT PLACES THE COMMITS, WHICH ARM 8 CANNOT ATTRIBUTE ────────────
806
+ //
807
+ // Arm 8 attributes a unit to a commit two ways: the tree under its WorkingDirectory, and the tree the
808
+ // running process was launched from. Both fail for exactly one unit, and it is the updater — a oneshot
809
+ // running from a home directory with no checkout under it, whose command line is the update script
810
+ // rather than the product entry point. So the unit that places every commit on this box was the one
811
+ // unit reported as "could NOT be read and was NOT compared". An updater running from a stale copy
812
+ // would place a stale tree and this check would pass, which is the straddle pointed at the mechanism
813
+ // that does the deploying.
814
+ //
815
+ // IT IS JUDGED ON A STAMP IT WRITES ABOUT ITSELF, the way the drainer above is, and arm 8 now names it
816
+ // as stamped rather than dropping it into the unreadable pile. The two arms cannot disagree because
817
+ // neither judges the other's population: this one owns the updater, that one owns everything with a
818
+ // tree under it, and the inventory decides which is which.
819
+ //
820
+ // IT FAILS ON COULD-NOT-LOOK, for the reason arm 8b does and one more of its own: the stamp writer
821
+ // shipped IN the updater, so a copy old enough to predate it writes no stamp — and that is not a gap in
822
+ // the reporting, it is the stale updater this arm exists to catch, arriving as an absence.
823
+ {
824
+ // WHERE THE STAMP IS, WITHOUT SPELLING A LOGIN INTO A PUBLIC FILE. The environment wins, because a
825
+ // box that redirects its updater must not be told it has no updater; otherwise the directory is the
826
+ // one the unit itself reports, which is where the updater runs and therefore where it stamps. A
827
+ // hardcoded home would publish an account name AND red every box that moved it.
828
+ //
829
+ // THE ENVIRONMENT IS READ INSIDE THE MODULE, under the name the WRITER already honours, so the two
830
+ // halves cannot look in different places. A reader-only variable would let a redirected updater stamp
831
+ // one path while this looked at another and reported "no stamp" — the loud branch — about a
832
+ // deployment that is working. What is derived here is only the fallback.
833
+ //
834
+ // A BOX WITH NO UPDATER IS ASKED FIRST (updaterAbsentHere): skipped with its reason, never failed.
835
+ const absent = updaterAbsentHere(clones.find((c) => c.unit === "clearotron-deploy"));
836
+ if (absent) skip("the updater that deploys this box is the current one", absent);
837
+ else {
838
+ let deployDir = null, resolveWhy = null;
839
+ {
840
+ const row = clones.find((c) => c.unit === "clearotron-deploy");
841
+ if (!row) resolveWhy = "no updater unit was probed on this box";
842
+ else {
843
+ // The unit is a oneshot: between ticks it is inactive and reports its WorkingDirectory anyway,
844
+ // which is the whole reason this attribution works where the process-based one cannot.
845
+ let wd = null;
846
+ try {
847
+ const shown = execFileSync("systemctl", ["--user", "show", "clearotron-deploy", "-p", "WorkingDirectory"],
848
+ { encoding: "utf8", env: userBusEnv(), stdio: ["ignore", "pipe", "pipe"] });
849
+ wd = /^WorkingDirectory=(.*)$/m.exec(shown)?.[1]?.trim() || null;
850
+ } catch (e) { resolveWhy = `systemctl could not be asked where the updater runs: ${String(e?.message ?? e).slice(0, 100)}`; }
851
+ if (wd !== null) {
852
+ const parsed = unitWorkingDirectory(wd);
853
+ if (!parsed.path) resolveWhy = parsed.why;
854
+ else deployDir = join(parsed.path, "deploy");
855
+ } else if (!resolveWhy) resolveWhy = "the updater unit reported no WorkingDirectory, so where it stamps could not be derived";
856
+ }
857
+ }
858
+
859
+ if (!resolveUpdaterStampPath(deployDir)) {
860
+ fail("the updater that deploys this box is the current one",
861
+ `where the updater stamps itself could not be resolved (${resolveWhy ?? "no value"}), so `
862
+ + `${UPDATER_STAMP_BASENAME} was not looked for. This is a failure to look, never a pass.`);
863
+ } else {
864
+ const v = updaterVerdict({
865
+ stamp: readUpdaterStamp(deployDir),
866
+ now: Math.floor(Date.now() / 1000),
867
+ // The same clone arm 8 scoped itself to, so the two arms cannot hold different opinions about
868
+ // which tree this deploy is. Reading it twice is how they would come to.
869
+ deployClone,
870
+ });
871
+ record("the updater that deploys this box is the current one", v.state, v.message);
872
+ }
873
+ }
874
+ }
875
+
804
876
  //: "0 active" is not a pass. It is either "I could not look" or "there is nothing running here".
805
877
  //: and "not active" is not "broken". Both state comparisons this arm used to make lived here as
806
878
  // inline string equality over a two-word vocabulary, which is why a `Type=oneshot` doing its job read as
@@ -940,7 +1012,7 @@ else {
940
1012
  // unit missing. Deployments set it in the service environment file beside the other box-scoped vars.
941
1013
  const box = deploymentBox(); // — the shared rule, so /portal/health cannot disagree with this
942
1014
  const v = unitInventoryVerdict({ live: liveUnits, files: walk.files, collisions: walk.collisions,
943
- filesError: walk.error, box, probe });
1015
+ filesError: walk.error, box, probe, boxNames: DEPLOYMENT_BOXES });
944
1016
  record("every live unit is declared", v.state, v.message);
945
1017
 
946
1018
  // — AND WHETHER ANYTHING STILL STARTS THE TIMER-DRIVEN ONES. Reported separately from the line above