clearotron 0.3.2-beta.7 → 0.3.2-beta.8

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 (85) hide show
  1. package/.env.example +24 -23
  2. package/INSTALL.md +142 -75
  3. package/README.md +3 -3
  4. package/bin/onboard.mjs +637 -216
  5. package/bin/start.mjs +133 -23
  6. package/bin/update.mjs +58 -11
  7. package/build-info.json +2 -2
  8. package/docs/architecture/04-configuration-reference.md +26 -11
  9. package/docs/architecture/05-config-governance.md +17 -7
  10. package/driver/CHANGELOG.md +76 -0
  11. package/driver/band-size.mjs +59 -0
  12. package/driver/config-inventory.mjs +112 -9
  13. package/driver/contract-arm2-baseline.json +1 -3
  14. package/driver/contract-e3-backlog.mjs +26 -26
  15. package/driver/contract-vocabulary.mjs +44 -10
  16. package/driver/door-gates.mjs +41 -7
  17. package/driver/driver.config.mjs +272 -59
  18. package/driver/engine/CONTRACT.md +10 -3
  19. package/driver/engine/README.md +2 -2
  20. package/driver/engine/anthropic-agent.mjs +77 -21
  21. package/driver/engine/auth.mjs +129 -10
  22. package/driver/engine/jx-turn.mjs +7 -6
  23. package/driver/engine/mcp/recording-server.mjs +13 -0
  24. package/driver/engine/openai-agent.mjs +4 -2
  25. package/driver/engine/probe.mjs +110 -23
  26. package/driver/findings-model.mjs +1 -1
  27. package/driver/flag-snapshot.mjs +28 -5
  28. package/driver/gateway.mjs +24 -18
  29. package/driver/jx-lanes.mjs +21 -2
  30. package/driver/jx-units.mjs +6 -3
  31. package/driver/jx.mjs +4 -2
  32. package/driver/matter-frame-record.mjs +90 -1
  33. package/driver/named-band.mjs +34 -2
  34. package/driver/package.json +1 -1
  35. package/driver/pipeline.mjs +200 -23
  36. package/driver/portal-config-view.mjs +30 -1
  37. package/driver/portal-report.mjs +15 -1
  38. package/driver/portal-service.mjs +46 -6
  39. package/driver/predelivery-lint.mjs +12 -2
  40. package/driver/publish/index.mjs +46 -5
  41. package/driver/publish/knockout.mjs +10 -1
  42. package/driver/publish/render-knockout.mjs +69 -7
  43. package/driver/publish/render.mjs +170 -59
  44. package/driver/publish/report-data.mjs +4 -1
  45. package/driver/publish/report-topbar.mjs +58 -0
  46. package/driver/publish/templates/report.css +18 -1
  47. package/driver/publish/xlsx.mjs +13 -1
  48. package/driver/register-availability.mjs +2 -2
  49. package/driver/register-coverage.mjs +94 -1
  50. package/driver/register-digest-record.mjs +236 -11
  51. package/driver/register-plan.mjs +170 -0
  52. package/driver/result-noun-fields.mjs +2 -2
  53. package/driver/run-economics.mjs +41 -10
  54. package/driver/run-requirements.mjs +173 -9
  55. package/driver/runner.mjs +3 -3
  56. package/driver/stages.mjs +12 -8
  57. package/driver/suite-census.json +142 -64
  58. package/driver/systemd/README.md +7 -4
  59. package/driver/terminal-clamp.mjs +107 -1
  60. package/driver/tokens.mjs +169 -3
  61. package/driver/unit-environment.mjs +42 -15
  62. package/driver/unit-inventory.mjs +19 -2
  63. package/driver/verify.mjs +27 -0
  64. package/mcp-server/CHANGELOG.md +4 -0
  65. package/mcp-server/package.json +1 -1
  66. package/mcp-server/server.mjs +15 -1
  67. package/package.json +1 -1
  68. package/portal-ui/dist/assets/{index-5UyqAyNM.js → index-6jzO9HiX.js} +155 -79
  69. package/portal-ui/dist/index.html +1 -1
  70. package/portal-ui/package.json +1 -1
  71. package/providers/jx/README.md +2 -1
  72. package/providers/jx/src/turn-envelope.mjs +8 -3
  73. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  74. package/providers/oauth-mcp-bridge/package.json +1 -1
  75. package/providers/uspto-local/README.md +1 -1
  76. package/scripts/authority-boundary-probe.mjs +4 -2
  77. package/scripts/env-audit.mjs +12 -6
  78. package/scripts/freeze-example-run.mjs +49 -16
  79. package/scripts/generated-files-are-current.mjs +69 -4
  80. package/scripts/settings-render-check.mjs +75 -2
  81. package/scripts/test-full.mjs +96 -3
  82. package/scripts/test-run.mjs +10 -0
  83. package/shared/deployment-box.mjs +7 -2
  84. package/shared/driver-dir.mjs +1 -1
  85. package/shared/names-in-force.mjs +1 -1
@@ -29,9 +29,11 @@
29
29
  // _driver/run.jsonl the event log
30
30
  // _driver/stage-inputs/ what each stage was handed
31
31
  // _history/ pre-reopen snapshots
32
- // Dropping the telemetry drops `meta.tokens` (driver/publish/index.mjs:1136 rollupTokens — the only consumer of
33
- // rollupTokens). That is the one difference step 5 is told to expect, and it says so out loud rather than
34
- // normalising it away in silence.
32
+ // Dropping the telemetry drops `meta.tokens` (driver/publish/index.mjs rollupTokens — the only consumer of
33
+ // rollupTokens), and with it the record of which models served the run (servedModels in
34
+ // driver/tokens.mjs): `servedModels` on meta.json and report-data.json, and the one line that closes the
35
+ // report's footer. Those are the differences step 5 is told to expect, and it says so out loud rather
36
+ // than normalising them away in silence.
35
37
  //
36
38
  // WHAT THIS SCRIPT DOES NOT DO
37
39
  // It does not decide the sample is publishable. It greps for the shapes that must never leave the VM
@@ -59,21 +61,21 @@ const FROZEN_FILES = [
59
61
  { path: "audit.md", why: "publish/index.mjs:1022 auditMd, the audit workbook source" },
60
62
  { path: "findings.json", why: "publish/index.mjs:715 readStore, the per-finding machine contract" },
61
63
  { path: "status.json", why: "publish/index.mjs:913 machineLedgerNote + markName" },
62
- { path: "case-law-findings.md", why: "publish/index.mjs:849 clPath, the case-law section" },
63
- { path: "common-law-grid.json", why: "publish/index.mjs:973 commonLawJoinedTerms, common-law coverage" },
64
+ { path: "case-law-findings.md", why: "publish/index.mjs:875 clPath, the case-law section" },
65
+ { path: "common-law-grid.json", why: "publish/index.mjs:1006 commonLawJoinedTerms, common-law coverage" },
64
66
  // publish/index.mjs — the _driver sidecars it reads by name
65
67
  { path: "_driver/receipts.json", why: "publish/index.mjs:761 fetchReceipts" },
66
68
  { path: "_driver/senior-rights.json", why: "publish/index.mjs:787 seniorRights" },
67
69
  { path: "_driver/verdict.json", why: "publish/index.mjs:792 verdictInfo" },
68
70
  { path: "_driver/framework.json", why: "publish/index.mjs, the frozen band vocabulary the run was rated under" },
69
- { path: "_driver/register-plan.json", why: "publish/index.mjs:820 scopeBasis" },
70
- { path: "_driver/instructed-scope.json", why: "publish/index.mjs:821 searchedJurisdictions, the fallback for register-plan" },
71
- { path: "_driver/enforcer-signals.json", why: "publish/index.mjs:861 esPath" },
72
- { path: "_driver/predelivery-lint.json", why: "publish/index.mjs:170 lintSink" },
73
- { path: "_driver/escalation-state.json", why: "publish/index.mjs:171 escSink" },
74
- { path: "_driver/reasoning-integrity.json", why: "publish/index.mjs:898 integritySink" },
75
- { path: "_driver/corrections-state.json", why: "publish/index.mjs:172 correctionsSink" },
76
- { path: "_driver/search-policy.json", why: "publish/index.mjs:955 searchPolicy, level + stage label" },
71
+ { path: "_driver/register-plan.json", why: "publish/index.mjs:846 scopeBasis" },
72
+ { path: "_driver/instructed-scope.json", why: "publish/index.mjs:847 searchedJurisdictions, the fallback for register-plan" },
73
+ { path: "_driver/enforcer-signals.json", why: "publish/index.mjs:887 esPath" },
74
+ { path: "_driver/predelivery-lint.json", why: "publish/index.mjs:172 lintSink" },
75
+ { path: "_driver/escalation-state.json", why: "publish/index.mjs:173 escSink" },
76
+ { path: "_driver/reasoning-integrity.json", why: "publish/index.mjs:924 integritySink" },
77
+ { path: "_driver/corrections-state.json", why: "publish/index.mjs:174 correctionsSink" },
78
+ { path: "_driver/search-policy.json", why: "publish/index.mjs:987 searchPolicy, level + stage label" },
77
79
  { path: "_driver/profile.json", why: "publish/index.mjs reads the frozen profile; report-registry.mjs:42 republishRun, customer key" },
78
80
  ];
79
81
 
@@ -106,7 +108,7 @@ const KNOCKOUT_FILES = [
106
108
  // knockout report render empty (publish/knockout.mjs:140-155). Named by stages-knockout.mjs:32,41.
107
109
  { path: "_driver/register-counts.json", why: "publish/knockout.mjs:140-155 counted figures + the Register column" },
108
110
  { path: "_driver/register-records.json", why: "stages-knockout.mjs:41 the terms behind the close-variation axis" },
109
- { path: "_driver/instructed-scope.json", why: "publish/index.mjs:821 searchedJurisdictions, the fallback for register-plan" },
111
+ { path: "_driver/instructed-scope.json", why: "publish/index.mjs:847 searchedJurisdictions, the fallback for register-plan" },
110
112
  ];
111
113
 
112
114
  /** The allowlist for a template. One place, so a new template cannot half-exist. */
@@ -162,7 +164,7 @@ const SCRUB = [
162
164
  // hides the next real difference.
163
165
  const VOLATILE = [
164
166
  { id: "issued", re: /\d{4}-\d{2}-\d{2} · \d{2}:\d{2} [A-Z]{2,5}/g, sub: "<issued>", why: "publish/index.mjs, the generation stamp in the firm locale" },
165
- { id: "iso-timestamp", re: /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/g, sub: "<ts>", why: "publish/index.mjs:666 asOf" },
167
+ { id: "iso-timestamp", re: /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/g, sub: "<ts>", why: "publish/index.mjs:668 asOf" },
166
168
  ];
167
169
 
168
170
  // ── REWRITES — what is CHANGED on the way out, as opposed to what is refused ───────────────────────
@@ -226,8 +228,19 @@ const substituteVendorKey = (key) => {
226
228
  // meta.json keys the freeze is EXPECTED to change, with the reason. Anything else differing is a finding.
227
229
  const EXPECTED_META_DELTA = {
228
230
  tokens: "telemetry pruned — _driver/*.jsonl is the only source (driver/tokens.mjs:82)",
231
+ servedModels: "telemetry pruned — the attempt rows in _driver/*.jsonl are the only source (servedModels in driver/tokens.mjs)",
229
232
  };
230
233
 
234
+ // THE SAME CAUSE, ON THE TWO OTHER SURFACES THAT SHOW IT. report-data.json carries `servedModels` beside the
235
+ // report's content, and the page renders it as the scope section's closing line (render.mjs
236
+ // servedModelsLine, class "servedby"). Only that key and that one paragraph are set aside, on both sides
237
+ // and out loud; a difference anywhere else in either file is still a finding. The paragraph holds escaped
238
+ // text and no markup, so the pattern cannot run past its own closing tag. It takes the whitespace before
239
+ // the paragraph with it: the clearance page joins its scope parts with a line break and an indent, which
240
+ // exists only because the line does.
241
+ const EXPECTED_DATA_DELTA = ["servedModels"];
242
+ const SERVED_LINE_RE = /\s*<p class="servedby"[^>]*>[^<]*<\/p>/g;
243
+
231
244
  // ── args ─────────────────────────────────────────────────────────────────────────────────────────────
232
245
  const argv = process.argv.slice(2);
233
246
  const flag = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : null; };
@@ -594,7 +607,27 @@ if (proofOk) {
594
607
  }
595
608
  continue;
596
609
  }
597
- if (normalise(rawA) === normalise(rawB)) { note(`${name} identical (${rawB.length} bytes)`); continue; }
610
+ // The served-model record is set aside by name on both sides (EXPECTED_DATA_DELTA, SERVED_LINE_RE),
611
+ // and only when it is what differed does the note say so.
612
+ let sA = normalise(rawA), sB = normalise(rawB), aside = [];
613
+ if (/^report-data(?:-.+)?\.json$/.test(name)) {
614
+ let dA = null, dB = null;
615
+ try { dA = JSON.parse(rawA); dB = JSON.parse(rawB); } catch { dA = dB = null; }
616
+ if (dA && dB) {
617
+ aside = EXPECTED_DATA_DELTA.filter((k) => JSON.stringify(dA[k]) !== JSON.stringify(dB[k]));
618
+ for (const k of EXPECTED_DATA_DELTA) { delete dA[k]; delete dB[k]; }
619
+ sA = normalise(JSON.stringify(dA, null, 2)); sB = normalise(JSON.stringify(dB, null, 2));
620
+ }
621
+ } else if (name.endsWith(".html") && sA !== sB) {
622
+ const tA = sA.replace(SERVED_LINE_RE, ""), tB = sB.replace(SERVED_LINE_RE, "");
623
+ if (tA === tB) { aside = ["the footer's served-models line"]; sA = tA; sB = tB; }
624
+ }
625
+ if (sA === sB) {
626
+ note(aside.length
627
+ ? `${name} identical apart from ${aside.join(", ")}, which differs as expected — ${EXPECTED_META_DELTA.servedModels}`
628
+ : `${name} identical (${rawB.length} bytes)`);
629
+ continue;
630
+ }
598
631
  finding(`${name} differs between the source run and the frozen copy — the allowlist dropped an input the renderer reads`);
599
632
  }
600
633
  }
@@ -53,6 +53,29 @@ export function treeState(root = ROOT) {
53
53
  * The alternative — dirtying a real generated file and restoring it — is a shared-file mutation, and
54
54
  * the test runner runs files in parallel, so it would be a race that reddens somebody else's arm.
55
55
  */
56
+ /**
57
+ * Which paths differ between two `git status --porcelain` readings, named so a reader can see WHAT moved.
58
+ *
59
+ * A porcelain line is a two-character state, a space, and the path. Lines are compared as a multiset so
60
+ * a path whose STATE changed — staged to modified, say — is reported as having moved, and the paths are
61
+ * returned rather than a count, because two numbers agreeing is not the same as two sets agreeing.
62
+ */
63
+ export function movedPaths(before, after) {
64
+ const bag = (s) => {
65
+ const m = new Map();
66
+ for (const line of String(s).split("\n")) {
67
+ if (!line.trim()) continue;
68
+ m.set(line, (m.get(line) ?? 0) + 1);
69
+ }
70
+ return m;
71
+ };
72
+ const [b, a] = [bag(before), bag(after)];
73
+ const out = new Set();
74
+ for (const [line, n] of a) if ((b.get(line) ?? 0) !== n) out.add(line.slice(3).trim() || line.trim());
75
+ for (const [line, n] of b) if ((a.get(line) ?? 0) !== n) out.add(line.slice(3).trim() || line.trim());
76
+ return [...out].sort();
77
+ }
78
+
56
79
  export function checkAll({ dir = HERE, root = ROOT, log = console.log, readTree = () => treeState(root) } = {}) {
57
80
  const found = minters(dir);
58
81
  if (!found.length) return { found, stale: [], unreadable: [], wrote: [], empty: true };
@@ -60,6 +83,7 @@ export function checkAll({ dir = HERE, root = ROOT, log = console.log, readTree
60
83
  const stale = [];
61
84
  const unreadable = [];
62
85
  const wrote = [];
86
+ const unattributable = [];
63
87
  for (const m of found) {
64
88
  // ── `--check` IS A CONTRACT, AND NOTHING WAS VERIFYING IT ──────────────────────────────────────
65
89
  //
@@ -73,13 +97,41 @@ export function checkAll({ dir = HERE, root = ROOT, log = console.log, readTree
73
97
  // one ran, it wrote. THE LIMIT, SAID RATHER THAN LEFT: this catches the harmful inert form, the
74
98
  // one that silently repairs. A minter that ignores the flag and does nothing at all still reports
75
99
  // `current`, and no probe from out here can tell that from a file that really is current.
100
+ // ── "DID THE TREE MOVE" IS NOT "DID THIS PROCESS WRITE" ────────────────────────────────────────
101
+ //
102
+ // Those are the same question only where nothing else can write, and this probe does not run
103
+ // there. Inside the suite it is a subprocess of one test file while every other file in its shard
104
+ // runs beside it, deliberately unserialised — dropping `--test-concurrency=1` is what made the
105
+ // suite 2.4 times faster. So a neighbour writing anywhere in the repository moved the snapshot and
106
+ // this reported it as the minter having written.
107
+ //
108
+ // Measured: a commit whose whole diff was one stylesheet's phone-width rules and a release note
109
+ // failed here naming TWO minters, and the same bytes passed on a rerun. Two is the tell — a minter
110
+ // that ignores `--check` and re-mints leaves its repair in the tree, so the NEXT minter's `before`
111
+ // already carries it and the next one is not flagged. Both being named cannot come from either.
112
+ //
113
+ // So the accusation is made only where it can be: from a tree that was CLEAN when this minter
114
+ // started, where a change appearing during its run has no other author available. A tree that was
115
+ // already dirty is one where somebody else is writing, and the honest answer is that this probe
116
+ // could not look — which is this file's own rule one level in, since it already refuses to read a
117
+ // minter that could not look as a pass.
118
+ //
119
+ // THE LIMIT, SAID RATHER THAN LEFT: a neighbour that begins writing after a clean reading and
120
+ // before the minter exits is still attributed here. Closing that needs the probe to own the tree,
121
+ // which is a change to where it runs rather than to what it asks.
76
122
  const before = readTree();
77
123
  const r = spawnSync(process.execPath, [join(dir, m), "--check"], { cwd: root, encoding: "utf8" });
78
124
  const after = readTree();
79
125
  const out = ((r.stdout || "") + (r.stderr || "")).trim();
80
126
  if (before !== null && after !== null && before !== after) {
81
- wrote.push({ m, out });
82
- log(` WROTE ${m} (during --check)`);
127
+ const moved = movedPaths(before, after);
128
+ if (before.trim() === "") {
129
+ wrote.push({ m, out, moved });
130
+ log(` WROTE ${m} (during --check): ${moved.join(", ") || "the tree moved"}`);
131
+ } else {
132
+ unattributable.push({ m, moved });
133
+ log(` ? ${m} — the tree moved and this probe cannot say who moved it: ${moved.join(", ") || "paths unknown"}`);
134
+ }
83
135
  continue;
84
136
  }
85
137
  // 0 is current, 1 is stale, anything else is a minter that could not look — reported separately,
@@ -89,11 +141,11 @@ export function checkAll({ dir = HERE, root = ROOT, log = console.log, readTree
89
141
  unreadable.push({ m, out, code: r.status });
90
142
  log(` ? ${m} (exit ${r.status})`);
91
143
  }
92
- return { found, stale, unreadable, wrote, empty: false, contractChecked: readTree() !== null };
144
+ return { found, stale, unreadable, wrote, unattributable, empty: false, contractChecked: readTree() !== null };
93
145
  }
94
146
 
95
147
  function main() {
96
- const { found, stale, unreadable, wrote, empty, contractChecked } = checkAll();
148
+ const { found, stale, unreadable, wrote, unattributable, empty, contractChecked } = checkAll();
97
149
  if (empty) {
98
150
  console.error("generated-files-are-current: no scripts/mint-*.mjs found. Either they moved or the "
99
151
  + "naming changed — and a pass over nothing is not a pass.");
@@ -111,6 +163,19 @@ function main() {
111
163
  + `commit without the repair, and reports current over a check that did not happen. Fix the minter.`);
112
164
  process.exit(2);
113
165
  }
166
+ // BEFORE the staleness verdict, and exit 2 rather than 1: a tree moving under the probe means the
167
+ // staleness answers were read off a tree that was changing while they were taken, so "out of date" is
168
+ // not a claim this run has the standing to make either. Could-not-look is the whole verdict.
169
+ if (unattributable.length) {
170
+ console.error(`\n${unattributable.length} minter(s) ran while the tree was ALREADY dirty and it moved `
171
+ + `underneath them. This probe cannot say whether the minter wrote or something running beside it `
172
+ + `did, so it names neither. That is a could-not-look, not a pass and not an accusation.\n`);
173
+ for (const { m, moved } of unattributable) {
174
+ console.error(` ${m} — moved: ${moved.join(", ") || "paths unknown"}`);
175
+ }
176
+ console.error(`\nRun it on a tree nobody else is writing to, and it will answer.`);
177
+ process.exit(2);
178
+ }
114
179
  if (unreadable.length) {
115
180
  console.error(`\n${unreadable.length} minter(s) could not look. That is not a pass; fix the minter first.`);
116
181
  process.exit(2);
@@ -89,6 +89,20 @@ const server = createServer((req, res) => {
89
89
  if (path === '/portal/admin/config' && !wantsPage) return json(adminConfig)
90
90
  if (path === '/portal/api/about') return json(about)
91
91
  if (path === '/portal/admin/roster' && !wantsPage) return json({ customers: [{ key: 'northwind', name: 'Northwind Foods' }] })
92
+ // THE PEOPLE SCREEN'S OWN DATA. Rows with real-length values on purpose: the defect this screen was
93
+ // filed for is a second column cut off at a phone's right edge, and a table of empty rows cannot show
94
+ // it. The permission phrases are the product's own words, the longest of them included.
95
+ if (path === '/portal/admin/access' && !wantsPage) {
96
+ return json({
97
+ note: '',
98
+ unknownAccounts: [],
99
+ people: [
100
+ { email: 'rosa.venn@northwind.example', permissions: { run: true, manage: true }, accounts: '*', listed: true },
101
+ { email: 'imani.oduya@northwind.example', permissions: { run: true, manage: false }, accounts: ['northwind'], listed: true },
102
+ { email: 'peter.halvorsen@northwind.example', permissions: { run: false, manage: false }, accounts: ['northwind'], listed: true },
103
+ ],
104
+ })
105
+ }
92
106
  if (path === '/portal/login') {
93
107
  res.writeHead(200, { 'content-type': 'text/html' })
94
108
  return res.end(loginPage({ email: EMAIL, resetCommand: state.reset }))
@@ -176,11 +190,16 @@ async function setTheme(theme) {
176
190
 
177
191
  // A FULL-HEIGHT VIEWPORT, not a beyond-viewport capture: a beyond-viewport capture paints the sticky rail
178
192
  // and top bar where the 900px viewport put them.
179
- async function capture(name) {
193
+ async function capture(name, width = 1280) {
180
194
  if (!shotDir) return
181
195
  await evalIn('window.scrollTo(0, 0)')
196
+ // THE WIDTH IS APPLIED BEFORE THE HEIGHT IS MEASURED. A narrow viewport reflows the page taller, and
197
+ // measuring first captures a page cut off at the fold — which is how a phone-width check ends up
198
+ // producing a picture that looks fine and shows half the screen.
199
+ await cmd('Emulation.setDeviceMetricsOverride', { width, height: 900, deviceScaleFactor: 1, mobile: width < 700 })
200
+ await sleep(250)
182
201
  const h = await evalIn('Math.max(document.documentElement.scrollHeight, document.body.scrollHeight)') ?? 900
183
- await cmd('Emulation.setDeviceMetricsOverride', { width: 1280, height: Math.max(900, h), deviceScaleFactor: 1, mobile: false })
202
+ await cmd('Emulation.setDeviceMetricsOverride', { width, height: Math.max(900, h), deviceScaleFactor: 1, mobile: width < 700 })
184
203
  await sleep(400)
185
204
  const shot = await cmd('Page.captureScreenshot', { format: 'png' })
186
205
  await cmd('Emulation.clearDeviceMetricsOverride', {})
@@ -544,6 +563,60 @@ for (const theme of ['light', 'dark']) {
544
563
  state.reset = null
545
564
  }
546
565
 
566
+ // ── People, at a desktop width and a phone's ────────────────────────────────────────────────────────
567
+ //
568
+ // THE SCREEN NOTHING COULD DRAW. It was reported as clipping its Permissions column at 400px "with no
569
+ // horizontal scroll offered", and the reading could not be settled from the source: the table sits in a
570
+ // wrapper set to scroll and carries a 760px floor, and both were already true at the sha the report was
571
+ // measured against. So either the scrollbar is an overlay one — real, invisible, and invisible in a
572
+ // capture — or something else clipped it and has since changed. No check reached this screen, so nobody
573
+ // could look. This is the looking.
574
+ //
575
+ // The rows carry real-length values deliberately. A table of empty rows cannot show a column being cut.
576
+ console.log('\nPeople, at 1280 and at 400:')
577
+ if (await open('/portal/people', "document.querySelector('table.data tbody tr')", 'People drew its table')) {
578
+ const peopleProbe = `(() => {
579
+ const wrap = document.querySelector('.table-wrap');
580
+ const table = document.querySelector('table.data');
581
+ const head = [...document.querySelectorAll('table.data thead th')].map((th) => th.innerText.trim());
582
+ const perm = [...document.querySelectorAll('table.data tbody tr')].map((tr) => tr.children[1]?.innerText.trim() ?? '');
583
+ return {
584
+ rows: document.querySelectorAll('table.data tbody tr').length,
585
+ head, perm,
586
+ // Does the wrapper actually have somewhere to scroll TO, and does it allow it?
587
+ scrollable: wrap ? wrap.scrollWidth > wrap.clientWidth + 1 : null,
588
+ overflowX: wrap ? getComputedStyle(wrap).overflowX : null,
589
+ wrapW: wrap ? Math.round(wrap.clientWidth) : null,
590
+ tableW: table ? Math.round(table.scrollWidth) : null,
591
+ // The page itself must NOT scroll sideways — that is the failure the clearances fix was about.
592
+ pageScrollsSideways: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
593
+ };
594
+ })()`
595
+
596
+ for (const [label, width] of [['desktop', 1280], ['phone', 400]]) {
597
+ await cmd('Emulation.setDeviceMetricsOverride', { width, height: 900, deviceScaleFactor: 1, mobile: width < 700 })
598
+ await sleep(350)
599
+ const p = (await evalIn(peopleProbe)) ?? {}
600
+ ok(p.rows === 3, `${label}: the three people are drawn (saw ${p.rows})`)
601
+ // The heading is upper-cased by CSS, so innerText returns it that way — compare the word, not the casing.
602
+ ok(p.head?.[1]?.toLowerCase() === 'permissions', `${label}: the second column is still the permissions one (saw ${JSON.stringify(p.head)})`)
603
+ ok(p.perm?.every((t) => t.length > 0), `${label}: every row states an access (saw ${JSON.stringify(p.perm)})`)
604
+ // THE PROPERTY, not the pixel. A column wider than its wrapper is fine as long as the wrapper
605
+ // scrolls; it is a defect only when the overflow has nowhere to go, or when the PAGE takes it.
606
+ ok(p.overflowX === 'auto' || p.overflowX === 'scroll',
607
+ `${label}: the table's wrapper can scroll sideways (overflow-x: ${p.overflowX})`)
608
+ ok(p.pageScrollsSideways === false,
609
+ `${label}: the page itself does not scroll sideways — the table's overflow stays in the table`)
610
+ console.log(` ${label}: wrapper ${p.wrapW}px, table ${p.tableW}px, wrapper scrollable: ${p.scrollable}`)
611
+ await cmd('Emulation.clearDeviceMetricsOverride', {})
612
+ for (const theme of ['light', 'dark']) {
613
+ await setTheme(theme)
614
+ await capture(`people-${label}-${theme}`, width)
615
+ }
616
+ await setTheme('light')
617
+ }
618
+ }
619
+
547
620
  // ── a browser whose storage refuses ──────────────────────────────────────────────────────────────────
548
621
  //
549
622
  // localStorage throws outright in a null-origin or locked-down document. The page must still draw, the
@@ -166,10 +166,89 @@ export function plan(root = ROOT, { providerFiles } = {}) {
166
166
  return { corpora, faults };
167
167
  }
168
168
 
169
+ /**
170
+ * ── SHARDING, AND WHY IT CANNOT SILENTLY DROP A FILE ──────────────────────────────────────────────
171
+ *
172
+ * The offline suite was the whole wall clock of CI: 12.8 minutes against 4.3 for the portal bundle and
173
+ * under one each for the other two jobs, which run beside it. Nearly all of that is `driver`, 925 of the
174
+ * ~980 test files in the tree, executed in one serial job. Agents write faster than that gate opens, so
175
+ * the gate — not the work — set the pace of the day.
176
+ *
177
+ * A shard is a SLICE OF THE SORTED FILE LIST TAKEN BY INDEX: shard i of n takes every file whose
178
+ * position satisfies `index % n === i - 1`. That is exhaustive and disjoint by construction — every
179
+ * index lands in exactly one shard, for any n — so the union of the shards is the whole list as a
180
+ * property of the arithmetic, not as something a check has to confirm afterwards. Round-robin rather
181
+ * than contiguous blocks because neighbouring files in a sorted list tend to be the same subsystem and
182
+ * to cost the same; interleaving spreads the slow ones.
183
+ *
184
+ * WHAT IS NOT SHARDED, and why that is stated rather than assumed. Only a corpus whose own test script
185
+ * is the plain `node --test test/*.test.mjs` shape can be expressed as an explicit file list and cut up
186
+ * this way. `portal-ui` runs a typecheck first and drives `.test.ts` through `--experimental-strip-types`,
187
+ * so it is not that shape: it runs WHOLE, on shard 1, and says so on every other shard rather than
188
+ * disappearing from them. Anything else that stops matching the shape falls into the same branch and is
189
+ * reported, never quietly skipped.
190
+ */
191
+ const PLAIN_GLOB = /^node \.\.\/scripts\/test-run\.mjs node --test test\/\*\.test\.mjs$/;
192
+
193
+ export function shardSlice(list, i, n) {
194
+ return list.filter((_, idx) => idx % n === i - 1);
195
+ }
196
+
197
+ function workspaceTestFiles(root, ws) {
198
+ const dir = join(root, ws, "test");
199
+ if (!existsSync(dir)) return [];
200
+ return readdirSync(dir).filter((f) => f.endsWith(".test.mjs")).sort().map((f) => `${ws}/test/${f}`);
201
+ }
202
+
203
+ /** Is this workspace's `test:full` the plain shape we can express as a file list? */
204
+ function isShardable(root, ws) {
205
+ try {
206
+ const scripts = JSON.parse(readFileSync(join(root, ws, "package.json"), "utf8")).scripts ?? {};
207
+ let cmd = scripts["test:full"] ?? "";
208
+ // `test:full` is often just `npm run test`; follow that one hop.
209
+ const hop = cmd.match(/^npm run (\S+)$/);
210
+ if (hop) cmd = scripts[hop[1]] ?? "";
211
+ return PLAIN_GLOB.test(cmd.trim());
212
+ } catch { return false; }
213
+ }
214
+
215
+ /**
216
+ * Rewrite the plan for one shard. Every corpus stays in the list and is reported; what changes is how
217
+ * many of its files THIS shard runs.
218
+ */
219
+ export function applyShard(corpora, { i, n }, root = ROOT) {
220
+ return corpora.map((c) => {
221
+ if (c.kind === "covered") return c;
222
+ if (c.kind === "files") {
223
+ const mine = shardSlice(c.files, i, n);
224
+ return { ...c, files: mine, total: c.files.length,
225
+ argv: ["node", "scripts/test-run.mjs", "node", "--test", ...mine] };
226
+ }
227
+ if (c.kind === "workspace" && isShardable(root, c.name)) {
228
+ const all = workspaceTestFiles(root, c.name);
229
+ const mine = shardSlice(all, i, n);
230
+ return { ...c, kind: "files", files: mine, total: all.length,
231
+ argv: ["node", "scripts/test-run.mjs", "node", "--test", ...mine] };
232
+ }
233
+ // Not expressible as a file list: run it whole, once, on shard 1.
234
+ return { ...c, wholeOnShard: 1, skippedHere: i !== 1 };
235
+ });
236
+ }
237
+
169
238
  function main() {
170
239
  const only = (() => { const i = process.argv.indexOf("--only"); return i === -1 ? null : process.argv[i + 1]; })();
171
240
  const listOnly = process.argv.includes("--list");
172
- const { corpora, faults } = plan();
241
+ const shard = (() => {
242
+ const i = process.argv.indexOf("--shard");
243
+ if (i === -1) return null;
244
+ const m = String(process.argv[i + 1] ?? "").match(/^(\d+)\/(\d+)$/);
245
+ if (!m) { console.error("test-full: --shard wants i/n, e.g. --shard 2/4"); process.exit(2); }
246
+ const [a, b] = [Number(m[1]), Number(m[2])];
247
+ if (a < 1 || b < 1 || a > b) { console.error(`test-full: --shard ${a}/${b} is not a shard of anything`); process.exit(2); }
248
+ return { i: a, n: b };
249
+ })();
250
+ const { corpora: full, faults } = plan();
251
+ const corpora = shard ? applyShard(full, shard, ROOT) : full;
173
252
 
174
253
  // FAULTS BEFORE ANYTHING RUNS. A missing corpus discovered after a green suite reads as an
175
254
  // afterthought; discovered first, it is the answer.
@@ -195,9 +274,13 @@ function main() {
195
274
  return;
196
275
  }
197
276
 
277
+ if (shard) console.log(`\ntest-full: shard ${shard.i} of ${shard.n} — slices are taken by index from the sorted file list, so the shards are disjoint and their union is the whole list.`);
278
+
198
279
  const ran = [];
199
280
  for (const c of chosen) {
200
281
  if (c.kind === "covered") { ran.push({ ...c, status: "covered" }); continue; }
282
+ if (c.skippedHere) { ran.push({ ...c, status: "elsewhere" }); continue; }
283
+ if (c.kind === "files" && c.files.length === 0) { ran.push({ ...c, status: "empty" }); continue; }
201
284
  console.log(`\n──── ${c.name} ────`);
202
285
  // STDIO INHERITED, NOT CAPTURED. CI reads the child stream for the corpus-guard markers, and a
203
286
  // runner that buffered its children would take those markers out of the log the check greps.
@@ -213,13 +296,23 @@ function main() {
213
296
  for (const c of ran) {
214
297
  if (c.status === "covered") {
215
298
  console.log(` covered ${c.name} — ${c.hasTests} file(s), run by the ${c.cover.corpus} corpus: ${c.cover.why}`);
299
+ } else if (c.status === "elsewhere") {
300
+ console.log(` shard ${c.wholeOnShard} ${c.name} — not expressible as a file list, so it runs WHOLE on shard ${c.wholeOnShard}, not here. It is not missing from this run; it is somewhere else in it.`);
301
+ } else if (c.status === "empty") {
302
+ console.log(` none ${c.name} — 0 of ${c.total} file(s) fell in this shard. Not an absence: the other shards hold them.`);
216
303
  } else {
217
- const n = c.kind === "files" ? `${c.files.length} file(s)` : "workspace suite";
304
+ const n = c.kind === "files"
305
+ ? `${c.files.length}${c.total != null ? ` of ${c.total}` : ""} file(s)`
306
+ : "workspace suite";
218
307
  console.log(` ${c.status === "ok" ? "ran " : "FAILED "} ${c.name} — ${n}${c.code ? ` (exit ${c.code})` : ""}`);
219
308
  }
220
309
  }
221
310
  const bad = ran.filter((c) => c.status === "FAILED");
222
- console.log(bad.length ? `\n${bad.length} corpus/corpora failed.` : `\nEvery corpus above was run or accounted for.`);
311
+ console.log(bad.length
312
+ ? `\n${bad.length} corpus/corpora failed.`
313
+ : shard
314
+ ? `\nEvery corpus above was run, accounted for, or named as belonging to another shard. This shard alone is not the suite: the gate is every shard green.`
315
+ : `\nEvery corpus above was run or accounted for.`);
223
316
  if (bad.length) process.exit(1);
224
317
  }
225
318
 
@@ -644,6 +644,16 @@ if (String(process.env[REAL_ENGINE_OVERRIDE] ?? "").trim()) {
644
644
  catch { copyFileSync(ENGINE_STUB, p); chmodSync(p, 0o755); }
645
645
  }
646
646
  process.env.PATH = shimDir + delimiter + (process.env.PATH ?? "");
647
+ // AND THE COPY CLEAROTRON INSTALLED, which no PATH can close. The resolver's last step
648
+ // (driver.config.mjs resolveEngineProgram) reads the engines folder under the home directory, where
649
+ // setup installs `@anthropic-ai/claude-code` or `@openai/codex`, never PATH, and on a developer's machine
650
+ // that folder can hold a REAL program. The shim above answers every child that keeps this PATH, because
651
+ // PATH is asked first; a child that composes its own PATH would fall straight through to a real binary.
652
+ // So the folder is pointed at an EMPTY directory for the whole suite: the suite keeps the no-installed-
653
+ // copy world it was written for, and an arm about the installed copy plants its own folder and names it.
654
+ const noEngines = join(root, "no-installed-engines");
655
+ mkdirSync(noEngines, { recursive: true });
656
+ process.env.CLEAROTRON_ENGINES_DIR = noEngines;
647
657
  // Names, never values — this line is read by whoever is wondering why a credential-reading test skipped.
648
658
  const withheld = Object.keys(process.env)
649
659
  .filter((n) => CREDENTIAL_RE.test(n) || CREDENTIAL_NAMES.includes(n))
@@ -16,10 +16,15 @@
16
16
  // of the same allowlist agree on the day they are written and drift afterwards; that is the defect
17
17
  // was filed about one field over, and the fix there was the same — import the function, never
18
18
  // re-derive the answer.
19
- export const DEPLOYMENT_BOXES = ["prod", "test"];
19
+ // `preprod` JOINED 2026-09-17. It is a packaged install like production, on its own account, which takes
20
+ // every published version before production does. Until it could name itself it ran with the variable
21
+ // UNSET — and an unset box does not fail loudly: `live-surface-check` SKIPS its expected-units check
22
+ // when the box is null, so the one install that most wants that check was the one not getting it.
23
+ // Setting `prod` instead would have made the box lie about which box it is.
24
+ export const DEPLOYMENT_BOXES = ["prod", "preprod", "test"];
20
25
 
21
26
  /**
22
- * @returns {"prod"|"test"|null} the self-declared box, or null when unset or unrecognised.
27
+ * @returns {"prod"|"preprod"|"test"|null} the self-declared box, or null when unset or unrecognised.
23
28
  *
24
29
  * Read at CALL time, deliberately. `shared/brand.mjs` builds its value at module scope and that is
25
30
  * exactly why a rename reached the report and not the portal — portal-service is outside
@@ -35,7 +35,7 @@
35
35
  // its own population needs an exception list — which would rebuild this issue's defect inside its fix:
36
36
  //
37
37
  // · `driver/stage-freshness.mjs` creates a CHILD, `join(runDir, "_driver", STAMP_DIR)`.
38
- // · `driver/pipeline.mjs:15945 shadowDir` passes a shadow dispatch sandbox under `_experiments/`, not a run
38
+ // · `driver/pipeline.mjs shadowDir` passes a shadow dispatch sandbox under `_experiments/`, not a run
39
39
  // directory. It is a run-dir-SHAPED base, which is why the parameter is `base` and not `runDir`.
40
40
  //
41
41
  // ── WHAT THIS DELIBERATELY DOES NOT DO ────────────────────────────────────────────────────────────
@@ -23,7 +23,6 @@ export const NAMES_IN_FORCE = Object.freeze([
23
23
  "CLEAROTRON_AI",
24
24
  "CLEAROTRON_AI_BILLING",
25
25
  "CLEAROTRON_AI_PATH",
26
- "CLEAROTRON_AZURE_MODEL",
27
26
  "CLEAROTRON_BAND_RESPONSE_CHARS",
28
27
  "CLEAROTRON_BAND_RUN_DIR",
29
28
  "CLEAROTRON_BAND_TRUTH_GATE",
@@ -54,6 +53,7 @@ export const NAMES_IN_FORCE = Object.freeze([
54
53
  "CLEAROTRON_E2E_EVIDENCE_DIR",
55
54
  "CLEAROTRON_E2E_EXPECT_DEMO_ROSTER",
56
55
  "CLEAROTRON_EDGE_ORIGIN",
56
+ "CLEAROTRON_ENGINES_DIR",
57
57
  "CLEAROTRON_ENGINE_CWD",
58
58
  "CLEAROTRON_ENGINE_MAX_BUFFER",
59
59
  "CLEAROTRON_ENUMERATE_CEILING",