clearotron 0.3.2-beta.6 → 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 (98) 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 +82 -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 +83 -0
  11. package/driver/band-size.mjs +59 -0
  12. package/driver/config-inventory.mjs +112 -9
  13. package/driver/connotation-search.mjs +45 -0
  14. package/driver/contract-arm2-baseline.json +1 -3
  15. package/driver/contract-e3-backlog.mjs +29 -29
  16. package/driver/contract-vocabulary.mjs +59 -25
  17. package/driver/door-gates.mjs +41 -7
  18. package/driver/driver.config.mjs +272 -59
  19. package/driver/engine/CONTRACT.md +10 -3
  20. package/driver/engine/README.md +2 -2
  21. package/driver/engine/anthropic-agent.mjs +77 -21
  22. package/driver/engine/auth.mjs +129 -10
  23. package/driver/engine/jx-turn.mjs +7 -6
  24. package/driver/engine/mcp/recording-server.mjs +13 -0
  25. package/driver/engine/openai-agent.mjs +4 -2
  26. package/driver/engine/probe.mjs +110 -23
  27. package/driver/findings-model.mjs +1 -1
  28. package/driver/flag-snapshot.mjs +28 -5
  29. package/driver/gateway.mjs +30 -21
  30. package/driver/jx-lanes.mjs +21 -2
  31. package/driver/jx-units.mjs +6 -3
  32. package/driver/jx.mjs +4 -2
  33. package/driver/matter-frame-record.mjs +90 -1
  34. package/driver/named-band.mjs +34 -2
  35. package/driver/package.json +1 -1
  36. package/driver/pipeline.mjs +391 -26
  37. package/driver/portal-config-view.mjs +30 -1
  38. package/driver/portal-report.mjs +15 -1
  39. package/driver/portal-service.mjs +46 -6
  40. package/driver/predelivery-lint.mjs +12 -2
  41. package/driver/publish/index.mjs +46 -5
  42. package/driver/publish/knockout.mjs +10 -1
  43. package/driver/publish/render-knockout.mjs +69 -7
  44. package/driver/publish/render.mjs +170 -59
  45. package/driver/publish/report-data.mjs +4 -1
  46. package/driver/publish/report-topbar.mjs +58 -0
  47. package/driver/publish/templates/report.css +28 -2
  48. package/driver/publish/xlsx.mjs +13 -1
  49. package/driver/register-availability.mjs +2 -2
  50. package/driver/register-coverage.mjs +94 -1
  51. package/driver/register-digest-record.mjs +236 -11
  52. package/driver/register-plan.mjs +170 -0
  53. package/driver/result-noun-fields.mjs +7 -4
  54. package/driver/run-economics.mjs +41 -10
  55. package/driver/run-requirements.mjs +173 -9
  56. package/driver/runner.mjs +3 -3
  57. package/driver/stages.mjs +12 -8
  58. package/driver/suite-census.json +162 -72
  59. package/driver/systemd/README.md +7 -4
  60. package/driver/terminal-clamp.mjs +107 -1
  61. package/driver/tokens.mjs +169 -3
  62. package/driver/unit-environment.mjs +42 -15
  63. package/driver/unit-inventory.mjs +19 -2
  64. package/driver/verify.mjs +50 -5
  65. package/mcp-server/CHANGELOG.md +8 -0
  66. package/mcp-server/http-server.mjs +4 -0
  67. package/mcp-server/lib/audit.mjs +11 -1
  68. package/mcp-server/lib/http-handler.mjs +6 -2
  69. package/mcp-server/package.json +1 -1
  70. package/mcp-server/server.mjs +16 -2
  71. package/package.json +1 -1
  72. package/portal-ui/dist/assets/{index-5UyqAyNM.js → index-6jzO9HiX.js} +155 -79
  73. package/portal-ui/dist/index.html +1 -1
  74. package/portal-ui/package.json +1 -1
  75. package/providers/clarivate/src/capabilities.js +5 -5
  76. package/providers/clarivate/src/core.js +1 -1
  77. package/providers/corsearch/src/core.js +2 -2
  78. package/providers/jx/README.md +2 -1
  79. package/providers/jx/src/turn-envelope.mjs +8 -3
  80. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  81. package/providers/oauth-mcp-bridge/package.json +1 -1
  82. package/providers/perplexity/src/core.js +3 -3
  83. package/providers/signa/src/capabilities.js +5 -6
  84. package/providers/signa/src/core.js +1 -1
  85. package/providers/uspto-local/README.md +1 -1
  86. package/scripts/authority-boundary-probe.mjs +4 -2
  87. package/scripts/env-audit.mjs +12 -6
  88. package/scripts/freeze-example-run.mjs +49 -16
  89. package/scripts/generated-files-are-current.mjs +69 -4
  90. package/scripts/release-duplicate-notes.mjs +246 -0
  91. package/scripts/release-publish-guard.mjs +64 -6
  92. package/scripts/report-print-check.mjs +194 -0
  93. package/scripts/settings-render-check.mjs +75 -2
  94. package/scripts/test-full.mjs +96 -3
  95. package/scripts/test-run.mjs +10 -0
  96. package/shared/deployment-box.mjs +7 -2
  97. package/shared/driver-dir.mjs +1 -1
  98. package/shared/names-in-force.mjs +1 -1
@@ -9,7 +9,8 @@ validated at parse.
9
9
  **One vendor, one billing mode, decided by the run and not by this directory.** The lanes go through
10
10
  `engine.runTurn()` — the same door all fourteen agentic stages use — via
11
11
  [`../../driver/engine/jx-turn.mjs`](../../driver/engine/jx-turn.mjs). Whatever program the customer configured
12
- (`CLEAROTRON_AI`) and whatever billing mode the run is on (API key or subscription) carries these calls too.
12
+ (`CLEAROTRON_AI`) and whatever billing mode the run is on (subscription, API key or, for Claude, a cloud account)
13
+ carries these calls too.
13
14
  That is the owner's standing rule — *one LLM provider only ever, API or auth, no mix* — and until 2026-08-20
14
15
  these three lanes were the one place in the product that broke it: they POSTed to the Anthropic Messages API on
15
16
  `ANTHROPIC_API_KEY` at a hardcoded cheap tier no matter what the rest of the run was doing.
@@ -106,15 +106,20 @@ export function envelopeFromTurnText(text, toolName) {
106
106
  * so the ORDER of those checks lives here once rather than three times.
107
107
  *
108
108
  * `turn` is supplied by the driver and is injectable for tests:
109
- * async ({prompt, kind}) => { ok, text, truncated, usage:{input,output}, model, vendor, authMode, cause }
109
+ * async ({prompt, kind}) => { ok, text, truncated, usage:{input,output}, model, vendor, authMode, cloud, cause }
110
110
  *
111
111
  * ATTRIBUTION RIDES EVERY RETURN, including the failures. A degrade still spent tokens, and without the
112
112
  * model, vendor and billing mode beside them they cannot be attributed in the run's rollup — which is
113
113
  * the half of that says a run must be able to state who did the work.
114
+ *
115
+ * `cloud` is part of that attribution: under the cloud billing mode it names the account that paid
116
+ * ("foundry", "vertex", "bedrock", "gateway"), and it is null under every other mode. The ledger stamp
117
+ * reads it from THIS return, so a field left off here reached every native-language record as
118
+ * `cloud: null` while the main steps of the same run said which cloud paid.
114
119
  */
115
120
  export async function runJxTurn({ body, turn, kind, started, truncatedCause, parse }) {
116
121
  const t0 = Number.isFinite(started) ? started : Date.now();
117
- const blank = { tookMs: 0, model: null, vendor: null, authMode: null, usage: null };
122
+ const blank = { tookMs: 0, model: null, vendor: null, authMode: null, cloud: null, usage: null };
118
123
  if (typeof turn !== "function") return { ok: false, cause: `${kind}: no turn runner was supplied`, ...blank };
119
124
 
120
125
  const { prompt, toolName } = promptFromRequest(body);
@@ -124,7 +129,7 @@ export async function runJxTurn({ body, turn, kind, started, truncatedCause, par
124
129
 
125
130
  const attribution = {
126
131
  tookMs: Date.now() - t0,
127
- model: r?.model ?? null, vendor: r?.vendor ?? null, authMode: r?.authMode ?? null,
132
+ model: r?.model ?? null, vendor: r?.vendor ?? null, authMode: r?.authMode ?? null, cloud: r?.cloud ?? null,
128
133
  // Passed through WHOLE, and `null` stays null. The driver hands over the engine contract's canonical
129
134
  // Usage ({input, output, cacheRead, cacheWrite, total}); re-shaping it to two fields here would drop
130
135
  // cache and total tokens from the rollup, and a zeroed object in place of null would report a
@@ -1,5 +1,13 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.3.2-beta.8
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.3.2-beta.7
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.3.2-beta.6
4
12
 
5
13
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.3.2-beta.6",
3
+ "version": "0.3.2-beta.8",
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).",
@@ -523,9 +523,9 @@ export function reconcileGridLedger(stdoutStr, spec) {
523
523
  *
524
524
  * Why identity and not a count: the sandbox program TRANSCRIBES the dictated queries into its own source,
525
525
  * and a mutated string is a DIFFERENT SEARCH. The count stays right while the dictated query never ran, so
526
- * every count-based check upstream and downstream reads clean. Observed: the dictated
527
- * `提基斯拉什 offensive meaning` came back as `提基斯ラッシュ offensive meaning` — the katakana of the
528
- * sibling Japanese row fused into the Chinese transliteration. 27 dictated, 27 recorded, one never searched.
526
+ * every count-based check upstream and downstream reads clean. The substitution this catches fuses one
527
+ * row's script into another row's transliteration: the two strings differ, the dictated and recorded
528
+ * counts still match exactly, and one dictated query is never searched by anybody.
529
529
  *
530
530
  * A query that THREW and said so is not this defect — it owns a gap row (`<query> | connotation | <error>`,
531
531
  * or the reconciled object form) and the driver's merge gate weighs it separately. Only a silent
@@ -34,7 +34,7 @@
34
34
  // it beats a number that is right for one shape and wrong for the other. See
35
35
  // OWNER_SCOPED_WINDOW below, which is the machine-readable half.
36
36
  //
37
- // What IS observed about the total is separate: it saturates at 10000 and flags itself
37
+ // The total is a separate matter: it saturates at 10000 and flags itself
38
38
  // approximate there — a fact about the count, not about the window.
39
39
  //
40
40
  // PURE: no node imports, no vendor HTTP.
@@ -129,7 +129,7 @@ export const CAPABILITIES = Object.freeze({
129
129
  classFilter: "native",
130
130
  // Search rows already carry status / nice_classes / owner_name → screening is inline, zero extra calls.
131
131
  screenSource: "search-row",
132
- // No documented or observed hard result ceiling, and no total to compare one against.
132
+ // No hard result ceiling, and no total to compare one against.
133
133
  resultCeiling: null,
134
134
 
135
135
  // ── the predicates, and WHICH REQUEST SHAPE each one rides ──────────────────────────────────────
@@ -247,10 +247,9 @@ export const CAPABILITIES = Object.freeze({
247
247
  // `legacy_code`. asks for `code` to be primary, and it now is, in the one place it decides
248
248
  // anything: `SIGNA_OFFICE_CODES` is what a reader and a future translate() should reach for.
249
249
  //
250
- // THE MIGRATION IS NOT URGENT AND THE REASON IS MEASURED, not assumed. All eleven legacy keys were
251
- // sent to the live wire beside their ISO codes, and every pair returned an identical
252
- // total (cipo/CA 83, euipo/EM 80, inpi-fr/FR 19, ipau/AU 72, ipi/CH 46, ipos/SG 58, nipo/NO 42,
253
- // prv/SE 22, ukipo/GB 96, uspto/US 169, wipo/WO 68). Nothing on the wire moves if `translate` keeps
250
+ // THE MIGRATION IS NOT URGENT, AND THE REASON IS A PROPERTY OF THE REGISTER rather than a guess:
251
+ // each legacy key and its ISO code address the same office, so the two spellings are interchangeable
252
+ // for every office this deployment reaches. Nothing on the wire moves if `translate` keeps
254
253
  // emitting keys, so it does — a vocabulary swap under the executor buys nothing and risks a live
255
254
  // office lookup.
256
255
  //
@@ -316,7 +316,7 @@ export function isSearchResponseBody(body) {
316
316
  // 21, 101, 18) and — the case that matters — an empty band answered `total_count: 0, approximate:
317
317
  // false`, an EXACT zero, which is the only kind this repository is allowed to render.
318
318
  //
319
- // Every approximate answer came back as exactly 10000: it is a saturation marker, not an estimate.
319
+ // An approximate total of exactly 10000 is a saturation marker rather than an estimate.
320
320
  // The vendor is saying "at least ten thousand", and it says so on the broad sweeps (a bare owner
321
321
  // filter, `match: similar`, an unanchored `contains`) — precisely the bands a clearance cannot
322
322
  // enumerate anyway.
@@ -155,7 +155,7 @@ what an index actually holds, including whether the 1884 backfile is in it (a da
155
155
  identical on every other number and is missing a century).
156
156
 
157
157
  This replaces only the *register* half of a clearance
158
- — the reasoning engine still needs its own subscription or API key, and the unregistered-use half
158
+ — the reasoning engine still needs its own subscription, API key or cloud account, and the unregistered-use half
159
159
  still wants `PERPLEXITY_API_KEY`.
160
160
 
161
161
  **It refuses rather than answering zero.** An absent index, a schema with no rows, or an index whose
@@ -28,7 +28,7 @@ import { join } from "node:path";
28
28
  import { driverDir, ensureDriverDir } from "../shared/driver-dir.mjs"; // — one definition of where `_driver/` is
29
29
  import { tmpdir } from "node:os";
30
30
  import { buildClaudeArgs, spawnEnv } from "../driver/engine/anthropic-agent.mjs";
31
- import { envFrom } from "../shared/env-aliases.mjs"; // — resolves EITHER spelling; names the retired one because that is the live-writable half
31
+ import { resolveEngineProgram } from "../driver/driver.config.mjs"; // — the program a run would spawn, found the way a run finds it
32
32
 
33
33
  const arg = (f, d) => { const i = process.argv.indexOf(f); return i > 0 ? process.argv[i + 1] : d; };
34
34
  const model = arg("--model", "claude-sonnet-5");
@@ -73,7 +73,9 @@ console.log(`probe root: ${root}`);
73
73
  console.log(`--add-dir roots: ${args.filter((a, i) => args[i - 1] === "--add-dir").join(", ")}`);
74
74
  console.log(`--settings present: ${args.includes("--settings")}`);
75
75
 
76
- const child = spawn(envFrom(process.env, "CLEAROTRON_CLAUDE_PATH") || "claude", [...args, "--include-hook-events"],
76
+ // The program a run would spawn (driver.config.mjs resolveEngineProgram), so this probes the same copy.
77
+ const program = resolveEngineProgram("anthropic-agent");
78
+ const child = spawn(program.resolved ?? program.bin, [...args, "--include-hook-events"],
77
79
  { stdio: ["pipe", "pipe", "pipe"], cwd: runDir, env: spawnEnv() });
78
80
  let out = "", err = "";
79
81
  child.stdout.on("data", (d) => { out += d; });
@@ -535,13 +535,18 @@ export function auditEnv(root = ROOT) {
535
535
  // roster, while ADR-0003 had already ruled case-law setup an OAuth flow
536
536
  // and not a variable at all. Evidence of a reader is not evidence of a
537
537
  // READ: the roster is a list of names to look for, not a call site.
538
- // 4 the AZURE_OPENAI_* block, an external contract (below).
538
+ // 4 the AZURE_OPENAI_* block, then kept as an external contract. That call did
539
+ // not hold: nothing the product runs read them, and the rows were removed
540
+ // from `.env.example` on 2026-09-15 (below). They are no longer counted
541
+ // among the wrong deletions.
539
542
  // 1 CLEAROTRON_SEND_TOOL_PREFIX — genuinely dead, and this direction does not
540
543
  // catch it either: its one surviving mention is a governance-doc line, and
541
544
  // a mention is enough to spare a row. Under-firing is the cost of the
542
545
  // trade, taken deliberately. It is the prose-sweep class.
543
546
  //
544
- // So SIX of ten deletions would have been wrong, two of them credential rows.
547
+ // So FOUR of ten deletions would have been wrong, the four live reads above,
548
+ // one of them a credential row. (This said six until 2026-09-15; that count
549
+ // did not follow from the rows as listed, and it included the Azure block.)
545
550
  // Evidence used instead: the bare NAME, on a name boundary, anywhere in the
546
551
  // tracked tree. The accessor family is filed as and does NOT belong here —
547
552
  // see below.
@@ -561,10 +566,11 @@ export function auditEnv(root = ROOT) {
561
566
  // ── AND IT IS NOT A SUPPRESSION LIST ────────────────────────────────────────────────────────────
562
567
  //
563
568
  // forbids one, rightly: "if a row is deliberately readerless, the row goes, not the guard."
564
- // Applied literally that ruling deletes three rows it should not. `.env.example`'s Azure block
565
- // documents the variables an EXTERNAL agent platform consumes — the block says so itself, ruled
566
- // it, and `MODELS.azure` / `CLEAROTRON_AZURE_MODEL` / `jxPolicy.providerStance: "azure-only"` are live.
567
- // There is no reader in this tree and there never was one to retire.
569
+ // Applied literally, that ruling deletes a row whose consumer is not this tree at all: a variable read
570
+ // by a program the product spawns, such as the Claude program's own sign-in token. The row documents a
571
+ // contract with that consumer, and there is no reader here to retire. (The four AZURE_OPENAI_* rows that
572
+ // first raised this were a different case. They named another platform's settings, nothing the product
573
+ // runs read them, and they were removed from `.env.example` on 2026-09-15.)
568
574
  //
569
575
  // So a row is ACCOUNTED FOR two ways, and this is one rule applied to every row rather than a list of
570
576
  // exempt names: something in the tree names it, OR the row carries an inline `# external:` line
@@ -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);