clearotron 0.2.2 → 0.2.3

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 (118) hide show
  1. package/.env.example +28 -0
  2. package/.nvmrc +1 -1
  3. package/INSTALL.md +13 -11
  4. package/README.md +11 -16
  5. package/THIRD-PARTY-NOTICES.md +1 -1
  6. package/bin/clearotron.mjs +22 -0
  7. package/bin/example.mjs +4 -0
  8. package/bin/onboard.mjs +195 -19
  9. package/bin/start.mjs +154 -8
  10. package/build-info.json +2 -2
  11. package/demo/full-country-search/run/_driver/framework.json +1 -1
  12. package/demo/global-preliminary-search/run/_driver/framework.json +1 -1
  13. package/demo/knockout-search/run/_driver/framework.json +1 -1
  14. package/demo/multi-country-focus-search/run/_driver/framework.json +1 -1
  15. package/docs/E2E.md +5 -5
  16. package/docs/PORTAL.md +2 -2
  17. package/docs/README.md +5 -4
  18. package/docs/SECURITY.md +17 -0
  19. package/docs/architecture/04-configuration-reference.md +2 -0
  20. package/docs/architecture/05-config-governance.md +14 -0
  21. package/docs/architecture/05-customer-profiles.md +2 -2
  22. package/docs/configuration.md +4 -3
  23. package/driver/CHANGELOG.md +56 -0
  24. package/driver/common-law-receipts.mjs +1 -1
  25. package/driver/config-staleness.mjs +1 -1
  26. package/driver/dev-portal.mjs +2 -2
  27. package/driver/driver.config.mjs +1 -1
  28. package/driver/engine/cli-version.mjs +103 -0
  29. package/driver/enqueue-schema.mjs +8 -8
  30. package/driver/feedback-store.mjs +1 -1
  31. package/driver/findings-model.mjs +52 -2
  32. package/driver/flag-snapshot.mjs +20 -0
  33. package/driver/framework.mjs +1 -1
  34. package/driver/gateway.mjs +18 -1
  35. package/driver/package.json +3 -3
  36. package/driver/party-facts.mjs +2 -2
  37. package/driver/phase0.mjs +1 -1
  38. package/driver/pipeline.mjs +5 -5
  39. package/driver/plain-register.mjs +120 -0
  40. package/driver/portal-access.mjs +1 -1
  41. package/driver/portal-config-view.mjs +40 -1
  42. package/driver/portal-families.mjs +1 -1
  43. package/driver/portal-report.mjs +1 -1
  44. package/driver/portal-service.mjs +26 -7
  45. package/driver/portal-upstream.mjs +2 -2
  46. package/driver/predelivery-lint.mjs +121 -1
  47. package/driver/profile-page.html +10 -2
  48. package/driver/profile-service.mjs +4 -4
  49. package/driver/profiles/README.md +3 -3
  50. package/driver/profiles.mjs +144 -12
  51. package/driver/publish/index.mjs +2 -2
  52. package/driver/publish/knockout.mjs +7 -1
  53. package/driver/publish/render-knockout.mjs +453 -118
  54. package/driver/publish/render.mjs +2 -2
  55. package/driver/reference-strip-signatures.mjs +20 -0
  56. package/driver/search-policy.mjs +2 -2
  57. package/driver/skills/blind-frame/SKILL.md +1 -1
  58. package/driver/skills/knockout-assess/SKILL.md +84 -0
  59. package/driver/skills/matter-frame/SKILL.md +6 -6
  60. package/driver/skills/matter-frame/watchlist-reference.md +2 -2
  61. package/driver/skills/prelim-common-law/SKILL.md +1 -1
  62. package/driver/skills/prelim-search/SKILL.md +1 -1
  63. package/driver/skills/prelim-search/report-prose.md +82 -0
  64. package/driver/skills/prelim-search/risk-framework-demo.manifest.json +1 -1
  65. package/driver/skills/prelim-search/risk-framework-triage.manifest.json +1 -1
  66. package/driver/skills/prelim-search/risk-framework.manifest.json +1 -1
  67. package/driver/skills/prelim-search/synthesis-rules.md +1 -1
  68. package/driver/skills/prelim-variants/SKILL.md +3 -3
  69. package/driver/stages.mjs +4 -4
  70. package/driver/suite-census.json +134 -38
  71. package/driver/unit-inventory.mjs +184 -27
  72. package/driver/verify.mjs +1 -1
  73. package/driver/whatif-memo-run.mjs +26 -4
  74. package/driver/whatif-memo.mjs +19 -1
  75. package/examples/README.md +2 -2
  76. package/examples/grants.example.json +6 -7
  77. package/mcp-server/CHANGELOG.md +2 -0
  78. package/mcp-server/lib/scrub.mjs +1 -1
  79. package/mcp-server/lib/whatif.mjs +2 -2
  80. package/mcp-server/package.json +1 -1
  81. package/mcp-server/packs/ops/EXAMPLES.md +2 -2
  82. package/mcp-server/server.mjs +1 -1
  83. package/package.json +28 -10
  84. package/portal-ui/dist/assets/{index-Ct3iQtZ2.css → index-1ziUJX1E.css} +43 -2
  85. package/portal-ui/dist/assets/{index-BUGgiYtF.js → index-CEYvXdqH.js} +226 -49
  86. package/portal-ui/dist/index.html +2 -2
  87. package/portal-ui/package.json +1 -1
  88. package/providers/_shared/term-shape.mjs +1 -1
  89. package/providers/oauth-mcp-bridge/CHANGELOG.md +2 -0
  90. package/providers/oauth-mcp-bridge/package.json +1 -1
  91. package/providers/uspto-local/src/index-store.js +2 -2
  92. package/scripts/citation-line-check.mjs +121 -2
  93. package/scripts/env-classify.mjs +67 -3
  94. package/scripts/live-surface-check.mjs +38 -1
  95. package/scripts/nightly-notice.mjs +0 -2
  96. package/scripts/preinstall-node-check.mjs +24 -0
  97. package/scripts/strip-tracker-citations.mjs +29 -6
  98. package/scripts/test-run.mjs +16 -0
  99. package/shared/anon-overlay.mjs +3 -3
  100. package/shared/customer-store.mjs +1 -1
  101. package/shared/invocation.mjs +28 -0
  102. package/shared/names-in-force.mjs +2 -0
  103. package/shared/node-floor.mjs +97 -0
  104. package/shared/staff-domain.mjs +154 -0
  105. package/shared/store-in-repo.mjs +23 -2
  106. package/skills/clearotron-ops/SKILL.md +1 -1
  107. package/driver/recipes/README.md +0 -49
  108. package/driver/skills/prelim-search/risk-framework-aurora.manifest.json +0 -19
  109. package/driver/skills/prelim-search/risk-framework-aurora.md +0 -77
  110. package/driver/skills/prelim-search/risk-framework-zephyr.manifest.json +0 -14
  111. package/driver/skills/prelim-search/risk-framework-zephyr.md +0 -33
  112. package/driver/skills/prelim-search/worked-examples-aurora.md +0 -10
  113. package/driver/skills/prelim-search/worked-examples-zephyr.md +0 -10
  114. package/scripts/ai-page-render-check.mjs +0 -532
  115. package/scripts/clearances-render-check.mjs +0 -480
  116. package/scripts/composer-render-check.mjs +0 -1027
  117. package/scripts/home-render-check.mjs +0 -526
  118. package/scripts/portal-lifecycle-check.mjs +0 -639
@@ -2,7 +2,7 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.2.2",
5
+ "version": "0.2.3",
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": {
@@ -115,7 +115,7 @@ const MARKUP_ARMS = [
115
115
  * long-form arm. A slogan mark has a legitimately long dominant element and cannot be restated any
116
116
  * shorter, so refusing a value for BEING LONG is right at the compiler and wrong at a corrective
117
117
  * stage gate — it hands back a reason with no remedy. An annotation always has a remedy: delete the
118
- * note, keep the term. — that is also why it may sit ABOVE a word-count floor: `ZEPHYR (root)`
118
+ * note, keep the term. — that is also why it may sit ABOVE a word-count floor: `COASTLINE (root)`
119
119
  * is an annotation by no measure a word count can see, exactly as the one-word `**BIOVELTRIN**`
120
120
  * was markup by no measure a word count could see.
121
121
  *
@@ -1,5 +1,7 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.2.3
4
+
3
5
  ## 0.2.2
4
6
 
5
7
  ## 0.3.0-beta.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
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).",
@@ -489,8 +489,8 @@ function predicateClause(predicate, term) {
489
489
  // predicate. Unanchored, because corporate names arrive in every spelling there is.
490
490
  //
491
491
  // NO FTS NARROWER HERE, deliberately, and it is the same trap as the suffix one. An FTS
492
- // token-prefix candidate set is NOT a superset of an unanchored contains: owner "AURORA INTERACTIVE"
493
- // searched for "URORA" satisfies the LIKE, but no token starts with URORA, so the narrower
492
+ // token-prefix candidate set is NOT a superset of an unanchored contains: owner "FOXGLADE INTERACTIVE"
493
+ // searched for "OXGLADE" satisfies the LIKE, but no token starts with URORA, so the narrower
494
494
  // would drop a true match and the verification could never put it back. A narrower may only
495
495
  // ever be a superset of its verifier. So this is an honest full scan, and how expensive that is
496
496
  // at register scale is UNMEASURED.
@@ -31,12 +31,15 @@
31
31
  // That last paragraph prints on every run, including a clean one, because a guard whose "clean" is
32
32
  // near-meaningless and does not say so is worse than no guard: its presence reads as coverage.
33
33
  //
34
- // The remedy is the convention, not this script — CONTRIBUTING.md, "Cite the SYMBOL, not the line
35
- // number". A symbol survives every move; a number survives none. This guard cannot enforce that (the
34
+ // The remedy is the convention, not this script — ADR-0005, "Cite the symbol, not the line": a symbol
35
+ // survives every move and a number survives none, and where the target is not a named symbol, quote a few
36
+ // words of it. This used to name CONTRIBUTING.md, which contains no such section — that record says in as
37
+ // many words that it SUPERSEDES CONTRIBUTING's instruction, so the pointer was to the superseded half. This guard cannot enforce that (the
36
38
  // ruling explicitly declines a form guard without an allowlist for the ~800 existing citations), so it
37
39
  // holds the decidable perimeter and names the gap.
38
40
 
39
41
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
42
+ import { execFileSync } from "node:child_process"; // — the ratchet reads a range; injected in tests
40
43
  import { fileURLToPath } from "node:url";
41
44
  import { join, dirname, basename } from "node:path";
42
45
 
@@ -370,6 +373,41 @@ function main() {
370
373
  // — the no-symbol slice: a cited span that is entirely blank or brace-only.
371
374
  const { misses: blankTarget, unshipped: blankUnshipped } = structuralMisses(citations, readLines);
372
375
 
376
+ // ── `--ratchet`: refuse a NEW citation that carries a line number and no symbol ────────────────────
377
+ //
378
+ // Separate mode rather than folded into the default run, because it answers about a RANGE and the rest
379
+ // of this script answers about the tree. Mixing them would make one exit code stand for two questions.
380
+ if (process.argv.includes("--ratchet")) {
381
+ const i = process.argv.indexOf("--base");
382
+ const base = i >= 0 ? process.argv[i + 1] : "origin/main";
383
+ const { error, lines } = addedLinesSince(base);
384
+ if (error) {
385
+ // A RANGE THAT COULD NOT BE READ IS NOT A RANGE THAT ADDED NOTHING, and 2 keeps the two apart —
386
+ // 1 already means "found something" here.
387
+ console.error(`${GUARD} --ratchet: ${error}`);
388
+ console.error(" That is this check failing to look, not a clean range. Nothing was judged.");
389
+ process.exit(2);
390
+ }
391
+ const bare = newBareCitations(lines);
392
+ console.log(`${GUARD} --ratchet: ${lines.length} added line(s) against ${base}, ${bare.length} new bare citation(s)`);
393
+ for (const b of bare) console.log(` ${b.file}:${b.line} cites ${b.cited}:${b.start} with no symbol\n ${b.text.slice(0, 110)}`);
394
+ if (bare.length) {
395
+ console.error("\nA citation carrying a line number and no symbol is checked for EXISTENCE and nothing");
396
+ console.error("else, so it drifts onto a different real line and every test here still passes.");
397
+ console.error("");
398
+ console.error("The documented form (ADR-0005) carries no line number at all:");
399
+ console.error(" `toolGroupsForStage()` in `gather-config.mjs`");
400
+ console.error("Where the target is not a named symbol, quote a few words of it. A line number is also");
401
+ console.error("accepted when a bare identifier sits directly beside it — `pipeline.mjs:875 recordsFromSearch`");
402
+ console.error("— because that one this script can check. Backticks around the symbol are NOT stepped over,");
403
+ console.error("so a line number with a backticked symbol still reads as bare.");
404
+ console.error("");
405
+ console.error("The existing corpus is not your problem: this judges only what the range ADDS.");
406
+ process.exit(1);
407
+ }
408
+ process.exit(0);
409
+ }
410
+
373
411
  if (process.argv.includes("--json")) {
374
412
  console.log(JSON.stringify({
375
413
  files: corpus.length, citations: citations.length, resolved: resolved.length,
@@ -595,6 +633,87 @@ const CALLS = /^\s*\(/;
595
633
  const OWN_NUMBER = /^\s*(?:at\s+\d|:\d)/;
596
634
 
597
635
  /** The symbol-shaped tokens adjoining a citation, in BOTH grammars: `cite SYM` and `SYM (cite)`. PURE. */
636
+ // ── THE RATCHET: A NEWLY ADDED CITATION CARRIES A SYMBOL (tracker issue 125) ────────────────────────
637
+ //
638
+ // The blindness above is not closeable on the existing corpus. Of the line citations in this tree only a
639
+ // minority name a symbol beside the number, and the rest are checked for EXISTENCE and nothing more — a
640
+ // citation that drifted onto a different real line reads as correct to every test here. Repointing them by
641
+ // hand is not the repair: the two hand measurements recorded in BLINDNESS both found wrong lines
642
+ // introduced by exactly that kind of pass.
643
+ //
644
+ // So this closes the class GOING FORWARD, at the only moment the correct symbol is cheap to write — when
645
+ // the author still knows what they meant. Same shape as the catalogue and release-note ratchets: the
646
+ // existing population is left to convert by attrition, and the number that must not grow is the number of
647
+ // citations nothing can check.
648
+ //
649
+ // WHAT IS REFUSED IS NARROW, and the two questions the issue left open are answered here rather than
650
+ // assumed:
651
+ //
652
+ // · a line number WITH a symbol is fine. The symbol is what makes it checkable, and `symbolClaims`
653
+ // above already verifies it; forbidding the number as well would be stricter than the harm.
654
+ // · a citation with NO line number is fine, symbol or not. It cannot go stale from an edit above it,
655
+ // which is the whole failure. This is also the answer for a data file, a fixture or a generated
656
+ // table, where there is no symbol to name — cite the file, leave the line off.
657
+ //
658
+ // Refused, therefore, is exactly one shape: a NEW citation carrying a line number and no symbol.
659
+ //
660
+ // PURE, and the added lines are injected, so a test drives it without a git repository — a ratchet that
661
+ // can only run against the real tree cannot be shown to fail.
662
+ // THE RULE'S OWN DEFINITION IS EXEMPT, AND NAMED RATHER THAN PATTERN-MATCHED. Its specimens are bare
663
+ // citations on purpose — a test that cannot write the shape it refuses cannot exercise it — so scanning it
664
+ // puts the guard's own examples into the population it polices, and the ratchet refuses its own arrival.
665
+ // Caught by driving the check against a range containing it, not by review.
666
+ //
667
+ // The cost of naming a file is that a REAL bare citation added there also escapes. That is the same trade
668
+ // the reference-strip signatures make for the same reason, and it is preferable to a pattern, which would
669
+ // quietly widen to files nobody considered.
670
+ export const RATCHET_EXEMPT = ["driver/test/a-new-citation-carries-something-that-can-be-checked.test.mjs"];
671
+
672
+ export function newBareCitations(addedLines) {
673
+ const out = [];
674
+ for (const { file, line, text } of addedLines ?? []) {
675
+ if (RATCHET_EXEMPT.includes(file)) continue;
676
+ for (const m of String(text ?? "").matchAll(CITE_RE)) {
677
+ // The same discriminator the corpus scan uses: a second `:number` closing a paren is a V8 stack
678
+ // frame, not a citation. Reading them as citations would refuse a fixture that captured a trace.
679
+ if (/^:\d+\)/.test(String(text).slice(m.index + m[0].length))) continue;
680
+ if (symbolsBeside(String(text), m.index, m[0].length).length > 0) continue;
681
+ out.push({ file, line, cited: m[1], start: Number(m[2]), text: String(text).trim() });
682
+ }
683
+ }
684
+ return out;
685
+ }
686
+
687
+ /**
688
+ * The lines a range ADDS, as `{ file, line, text }`. `run` is injected for the same reason as above.
689
+ *
690
+ * `--unified=0` so context lines are not read as additions — with context, every citation near an edit
691
+ * would be reported as new and the ratchet would refuse changes that added nothing.
692
+ */
693
+ export function addedLinesSince(base = "origin/main", run = null) {
694
+ const exec = run ?? ((...a) => execFileSync("git", ["-C", ROOT, ...a], { encoding: "utf8", maxBuffer: 1e9 }));
695
+ let diff;
696
+ try { diff = exec("diff", "--unified=0", "--no-color", `${base}...HEAD`); }
697
+ catch (e) {
698
+ // A range that could not be read is not a range that added nothing, and the caller must not confuse them.
699
+ return { error: `could not diff ${base}...HEAD (${String(e?.message ?? e).split("\n")[0].slice(0, 90)})`, lines: [] };
700
+ }
701
+ const lines = [];
702
+ let file = null, next = 0;
703
+ for (const raw of diff.split("\n")) {
704
+ // The two file headers git writes, matched in full rather than by a "+++" prefix. An added line
705
+ // beginning with "++" arrives here as "+++...", so a prefix test drops it AND numbers every line
706
+ // after it in the hunk one low, because the line it skipped never advanced the counter. Both
707
+ // forms are exact: a deletion's header is the bare string, with no path and no timestamp.
708
+ if (raw.startsWith("+++ b/")) { file = raw.slice(6); continue; }
709
+ if (raw === "+++ /dev/null") { file = null; continue; }
710
+ const h = raw.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
711
+ if (h) { next = Number(h[1]); continue; }
712
+ if (raw.startsWith("+")) { lines.push({ file, line: next++, text: raw.slice(1) }); }
713
+ }
714
+ return { error: null, lines };
715
+ }
716
+
598
717
  export function symbolsBeside(line, index, length) {
599
718
  const after = String(line).slice(index + length).replace(SEP_AFTER, "");
600
719
  const am = after.match(/^[A-Za-z_$][A-Za-z0-9_$]*/);
@@ -321,7 +321,7 @@ export function defaultAtReadSite(name, root = ROOT) {
321
321
 
322
322
  /** The whole classification, pure over its inputs so a test drives it without a box. */
323
323
  export function classify({ catalogue, sources, setup = setupNames(), readSites = defaultAtReadSite,
324
- deploymentNames = DEPLOYMENT_NAMES } = {}) {
324
+ deploymentNames = DEPLOYMENT_NAMES, declared = new Map() } = {}) {
325
325
  // NAMED OVERRIDES, listed rather than folded into a pattern so that each can be argued with.
326
326
  //
327
327
  // `CLEAROTRON_AGENT_WHATSAPP` matches no deployment shape and is not a knob: production sets it to a
@@ -370,7 +370,11 @@ export function classify({ catalogue, sources, setup = setupNames(), readSites =
370
370
 
371
371
  const rows = catalogue.map((name) => {
372
372
  const setIn = ["prod", "test", "config", "ci", "e2e"].filter((k) => sources[k]?.has(name));
373
- return { name, class: cls(name), everSet: setIn, documented: Boolean(sources.docs?.has(name)) };
373
+ // `declared` is the catalogue's own `# effect:` for this name, carried on the row so the artifact
374
+ // says what the document claims beside what this script derived. The two are different questions and
375
+ // the row is where a reader compares them.
376
+ return { name, class: cls(name), everSet: setIn, declared: declared.get(name) ?? null,
377
+ documented: Boolean(sources.docs?.has(name)) };
374
378
  });
375
379
 
376
380
  // "Set nowhere" is not by itself a licence to delete, and this is where 62 of 84 left the list.
@@ -378,6 +382,57 @@ export function classify({ catalogue, sources, setup = setupNames(), readSites =
378
382
  const GATE_RE = /(===|!==|==|!=)\s*"[01]"|"[01]"\s*(===|!==|==|!=)|\|\|\s*"1"/;
379
383
  const sub = {};
380
384
  for (const n of tuning) {
385
+ // ── A DOCUMENT SAYING "THIS IS NOT A KNOB" KEEPS ITS NAME OFF THIS LIST ────────────────────────
386
+ //
387
+ // The classifier keys on the NAME and `tuning` is its residual — what a name falls to when no shape
388
+ // matches. So a name whose catalogue row declares any other effect reaches the deletion walk on a
389
+ // class its own documentation contradicts.
390
+ //
391
+ // THE TEST IS `!== "tuning"`, SO IT EXCLUDES ALL FIVE OTHER CLASSES. `tuning` is the only declared
392
+ // effect that AGREES with being a knob, so it is the only one that may reach the walk. Each of the
393
+ // five, in the catalogue's own words, and why deleting the name would cost something:
394
+ //
395
+ // silent-output-change "changes what a run produces, and nothing in the run's own artifacts says
396
+ // so" — the worst one to delete, because the loss is invisible in the output.
397
+ // disclosed-gate "changes what a run covers, AND the run discloses the gap" — deleting it
398
+ // silently restores coverage the operator chose to switch off, or removes
399
+ // their ability to switch it off at all.
400
+ // credential "absent, the run refuses at preflight by name" — deleting it turns a
401
+ // named refusal into an unexplained one.
402
+ // deployment "where input and output live; the conclusion a run reaches is unchanged"
403
+ // — unchanged CONCLUSION is not unchanged behaviour. This is the class the
404
+ // whole rule comes from: two notification addresses, legitimately unset on
405
+ // the deployment being read, were proposed for deletion on exactly this
406
+ // mismatch. Excluding it is the original finding, not an extension of it.
407
+ // harness "read only on a fixture, replay or self-test path; no production run
408
+ // reaches it" — the one that reads as safe to delete and is not. Deleting a
409
+ // harness name does not remove a knob nobody uses; it removes the only way
410
+ // a test can run. That argument is already made below for `instrument`, and
411
+ // it is the same argument. A declaration is a stronger version of it,
412
+ // because it does not depend on the spelling.
413
+ //
414
+ // The alternative — narrowing to the three classes this comment used to name — would put
415
+ // `deployment` back on the list and re-admit the defect the rule exists to stop.
416
+ //
417
+ // WHAT THIS TREE CAN SHOW YOU, and it is not the number to look for. Measured 2026-09-08: this tree
418
+ // carries 43 catalogued rows and 12 declarations, and NO name is in the contradicting position, so
419
+ // the rule changes nothing here and a check written against the live catalogue would pass while
420
+ // looking at nothing. That is why the checks plant a catalogue rather than reading this one. Over
421
+ // the full catalogue the same day the position holds seventeen names — ten declared
422
+ // `silent-output-change`, four `disclosed-gate`, two `harness`, one `credential` — and every one of
423
+ // them leaves the walk further down for an UNRELATED reason: the instrument regex matching their
424
+ // spelling, a non-numeric default, or no default found. The file already says what that is worth
425
+ // about a different name: exclusion "for an unrelated reason (no default found), which is luck, not
426
+ // a rule". Rename one of them to something without `DUMP` or `PROBE` in it and it joins the deletion
427
+ // population with a document beside it saying it changes what a run produces.
428
+ //
429
+ // Those counts are a dated reading and they move; two of the seventeen were added the same week.
430
+ // The RULE is what is being asserted here, not the population.
431
+ //
432
+ // So the declaration is read FIRST and it is the rule. Nothing is silently dropped: the names land
433
+ // in their own bucket, and the row carries the declaration that put them there.
434
+ const say = declared.get(n);
435
+ if (say && say !== "tuning") { sub[n] = "declared-not-a-knob"; continue; }
381
436
  const body = git("grep", "-n", "--", n, "--", "*.mjs", "*.js")
382
437
  .split("\n").filter((l) => l && !/(^|\/)test\//.test(l)).join("\n");
383
438
  const onlyTests = git("grep", "-l", "--", n, "--", "*.mjs", "*.js").split("\n").filter(Boolean)
@@ -405,6 +460,7 @@ export function classify({ catalogue, sources, setup = setupNames(), readSites =
405
460
  instrument: of("instrument"), "path-switch": of("path-switch"),
406
461
  "model-or-agent-selector": of("model-or-agent-selector"),
407
462
  "non-numeric-default": of("non-numeric-default"), "no-default-found": of("no-default-found"),
463
+ "declared-not-a-knob": of("declared-not-a-knob"),
408
464
  "deletable-number": of("deletable-number"),
409
465
  } };
410
466
  }
@@ -414,7 +470,11 @@ function build() {
414
470
  { encoding: "utf8", cwd: ROOT, maxBuffer: 1e8 }));
415
471
  const catalogue = audit.catalogue.rows.map((r) => r.name);
416
472
  const sources = gather();
417
- const { rows, buckets } = classify({ catalogue, sources });
473
+ // The catalogue's own declarations, handed in so the deletion walk can read them. Same rows the
474
+ // audit already produced — not a second parse of the same documents, which would be a second thing
475
+ // to keep in step.
476
+ const declared = new Map(audit.catalogue.rows.filter((r) => r.effect).map((r) => [r.name, r.effect]));
477
+ const { rows, buckets } = classify({ catalogue, sources, declared });
418
478
  const prodRead = sources.prodRead;
419
479
  const by = (k) => rows.filter((r) => r.class === k).length;
420
480
  return {
@@ -428,6 +488,10 @@ function build() {
428
488
  names: buckets["deletable-number"], count: buckets["deletable-number"].length,
429
489
  },
430
490
  _excludedFromDeletion: {
491
+ // FIRST, because it is the only one of these that is a RULE rather than an observation about a
492
+ // name's spelling or its default: the catalogue says this name changes what a run produces, so it
493
+ // is not a spare knob whatever its shape suggests.
494
+ "declared-not-a-knob": buckets["declared-not-a-knob"],
431
495
  instrument: buckets.instrument, "path-switch": buckets["path-switch"],
432
496
  "model-or-agent-selector": buckets["model-or-agent-selector"],
433
497
  "non-numeric-default": buckets["non-numeric-default"], "no-default-found": buckets["no-default-found"],
@@ -90,7 +90,7 @@ import { unitsActiveVerdict } from "../driver/unit-state-verdict.mjs";
90
90
  import { deploymentBox } from "../shared/deployment-box.mjs"; // — extracted; one allowlist, two readers
91
91
  import { unitFileDriftVerdict } from "../driver/unit-file-drift.mjs"; //
92
92
  import { placeholdersIn, resolveValues, renderUnit } from "../driver/systemd/render-units.mjs"; //
93
- import { CHECKED_UNITS, unitInventoryVerdict, serviceCommitVerdict, unitWorkingDirectory, unitClone } from "../driver/unit-inventory.mjs"; // · -bundle ·
93
+ import { CHECKED_UNITS, CHECKED_TIMERS, timerVerdict, unitInventoryVerdict, serviceCommitVerdict, unitWorkingDirectory, unitClone } from "../driver/unit-inventory.mjs"; // · -bundle ·
94
94
  import { entrypointOf } from "../driver/systemd/install-census.mjs"; // the ONE ExecStart parser — a unit says which module it runs
95
95
  import { treeOfRunning } from "../shared/checkout-move.mjs"; // …and the live argv says which tree that module came from
96
96
  import { findUnitFiles, unitFilePath } from "../driver/unit-files.mjs"; //
@@ -226,6 +226,35 @@ function userBusEnv() {
226
226
  // Returns {clones, probe}. `probe` is the honest answer to "could I look at all?" — {ok, why} — and it is
227
227
  // SEPARATE from the clones list on purpose. "enumerated, nothing to compare" and "could not enumerate"
228
228
  // were the same empty array before, and this suite exists because absences were read as successes.
229
+ /**
230
+ * Ask each declared TIMER about itself, as a timer.
231
+ *
232
+ * Deliberately not folded into the loop below. That loop asks a service for its WorkingDirectory and
233
+ * MainPID and attributes a running process to a checkout — none of which a `.timer` has, so putting
234
+ * timers through it would produce rows whose every attribution field is empty for a reason that is not
235
+ * a finding. Two questions, two calls, and each says what it could not answer.
236
+ */
237
+ function declaredTimers() {
238
+ if (!CHECKED_TIMERS.length) return { rows: [], probeFailed: null };
239
+ const env = userBusEnv();
240
+ const rows = [];
241
+ let probeFailed = null;
242
+ for (const unit of CHECKED_TIMERS) {
243
+ try {
244
+ const shown = execFileSync("systemctl", ["--user", "show", unit, "-p", "ActiveState", "-p", "LoadState"],
245
+ { encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"] });
246
+ const f = Object.fromEntries(shown.split("\n").filter(Boolean).map((l) => { const [k, ...v] = l.split("="); return [k, v.join("=")]; }));
247
+ // An EMPTY answer is the no-session-bus case, and it must not read as a stopped timer — that is
248
+ // the shape that turns "could not ask" into a finding about the box.
249
+ if (!f.ActiveState) { probeFailed = probeFailed ?? `systemctl --user answered nothing for ${unit}`; continue; }
250
+ rows.push({ unit, active: f.ActiveState || null, load: f.LoadState || null });
251
+ } catch (e) {
252
+ probeFailed = probeFailed ?? String(e?.message ?? e).slice(0, 120);
253
+ }
254
+ }
255
+ return { rows, probeFailed };
256
+ }
257
+
229
258
  function serviceClones() {
230
259
  // — the list is DECLARED, not written here. It used to be eight names inline, which put a unit
231
260
  // inside the drift guarantee or outside it by omission: `client-access` was live on production and in
@@ -889,6 +918,14 @@ else {
889
918
  const v = unitInventoryVerdict({ live: liveUnits, files: walk.files, collisions: walk.collisions,
890
919
  filesError: walk.error, box, probe });
891
920
  record("every live unit is declared", v.state, v.message);
921
+
922
+ // — AND WHETHER ANYTHING STILL STARTS THE TIMER-DRIVEN ONES. Reported separately from the line above
923
+ // because it answers a different question: that one says a declared unit exists and is not adrift,
924
+ // this one says its timer is still armed. A service whose timer was stopped satisfies the first and
925
+ // fails the second, and reads `inactive` for both — which is why one line could not carry both.
926
+ const t = declaredTimers();
927
+ const tv = timerVerdict(t.rows, { probeFailed: t.probeFailed });
928
+ record("every declared timer is still armed", tv.state, tv.message);
892
929
  }
893
930
 
894
931
  // ── — EVERY QUEUE THIS DEPLOYMENT WOULD DRAIN IS WATCHED BY SOMETHING ──────────────────────────
@@ -151,8 +151,6 @@ export function noticeBody({ runUrl, sha, subject, failedJobs = [], lastGreenAt,
151
151
  + "passes — a run that never started cannot make the platform green."
152
152
  : "This thread is reused for every red night. Close it once the platform is green again — the next "
153
153
  + "red opens a new one.",
154
- "",
155
- "Agent: role-dev · bergface",
156
154
  ].join("\n");
157
155
  }
158
156
 
@@ -0,0 +1,24 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ //
4
+ // The install refuses an unsupported Node BEFORE anything is written — tracker issue 364.
5
+ //
6
+ // Three declarations already stated the requirement and none of them bound anyone: `engines.node` is a
7
+ // WARNING unless the person installing has set `engine-strict`, `.nvmrc` is read by nvm and nothing
8
+ // else, and the install guide is prose. An outside report arrived as "some node issues, not as obvious
9
+ // as the README made it seem" — which is what a warning inside an install log looks like from outside.
10
+ //
11
+ // So this is a `preinstall` hook, node builtins only, because nothing is installed when it runs.
12
+ //
13
+ // IT NAMES THE LATER FAILURE, not just the version. The whole defect is that an unsupported Node
14
+ // installs cleanly and then fails somewhere else saying nothing about Node, so a message that only
15
+ // prints a semver range leaves the reader where they started.
16
+ import { declaredRange, floorOf, meetsFloor, nodeFloorRefusal } from "../shared/node-floor.mjs";
17
+
18
+ const range = declaredRange();
19
+ if (!meetsFloor(process.versions.node, floorOf(range))) {
20
+ process.stderr.write(`\n${nodeFloorRefusal({ current: process.versions.node, required: floorOf(range).join("."), range })}\n`);
21
+ process.stderr.write(" Nothing has been installed.\n\n nvm install 22 && nvm use 22\n\n");
22
+ process.stderr.write(" Without nvm, take the current 22.x from https://nodejs.org/.\n\n");
23
+ process.exit(1);
24
+ }
@@ -77,6 +77,15 @@ export const EXCLUDED = [
77
77
  "driver/test/a-bare-reference-added-in-a-diff-is-refused.test.mjs",
78
78
  "driver/test/prompt-payload-names-no-tracker-issue.test.mjs",
79
79
  "driver/test/euipo-environment-doctrine.test.mjs",
80
+ // THIS RULE'S OWN DEFINITION, and it caught itself. Both files below QUOTE the residue in order to
81
+ // define it — the specimens the sweep is tested against, and the worked examples in the header above.
82
+ // Sweeping them rewrites the corpus this instrument is checked by, and it would then pass on something
83
+ // it had stopped checking: the arms would assert that a stripped line equals a stripped line. The
84
+ // sibling module that counts the earlier strip's residue learned the same lesson and names its own
85
+ // three files for the same reason. Found by running the sweep after fixing its report, which is the
86
+ // only reason it surfaced: before that, these lines were counted and never listed.
87
+ "scripts/strip-tracker-citations.mjs",
88
+ "driver/test/the-citation-strip-removes-openers-and-nothing-else.test.mjs",
80
89
  // Pinned at a content hash; a prose repair is not worth spending a freeze on.
81
90
  "driver/publish/render.mjs",
82
91
  ];
@@ -86,28 +95,42 @@ export const isScannable = (f) =>
86
95
 
87
96
  /** Per-file classification. PURE, and `read` is injected so an arm can drive it over a synthetic tree. */
88
97
  export function surveyOf(files, read) {
89
- const stripped = {}, handoff = [];
98
+ const stripped = {}, handoff = [], unreadable = [];
90
99
  let strippedTotal = 0, remainingTotal = 0;
91
100
  for (const f of files.filter(isScannable)) {
92
101
  let text;
93
- try { text = read(f); } catch { continue; }
102
+ // AN UNREADABLE FILE IS A FINDING, NOT A QUIET SKIP. This used to `continue` into no counter and no
103
+ // error channel, so "I could not open this" and "there was nothing to do here" produced identical
104
+ // output — in the report whose whole job is to tell somebody a hundred-file rewrite is safe. The
105
+ // count is reported before anything else when it is non-zero.
106
+ try { text = read(f); } catch (e) { unreadable.push({ file: f, why: String(e?.message ?? e).slice(0, 120) }); continue; }
94
107
  let n = 0;
108
+ // CLASSIFY AFTER REPLACING, NOT INSTEAD OF IT. The replacement is not global, so a line carrying an
109
+ // opener AND a second citation further along was stripped once, counted as done, and never reached
110
+ // the hand-off list a person is told to read — its survivor was invisible in the one place it should
111
+ // have been named. Test the RESULT: a line can be both stripped and still owed to a reader.
95
112
  const out = text.split("\n").map((line, i) => {
96
- if (OPENER.test(line)) { n++; return line.replace(OPENER, "$1"); }
97
- if (ANY_CITATION.test(line)) handoff.push({ file: f, line: i + 1, text: line.trim() });
98
- return line;
113
+ const after = OPENER.test(line) ? (n++, line.replace(OPENER, "$1")) : line;
114
+ if (ANY_CITATION.test(after)) handoff.push({ file: f, line: i + 1, text: after.trim() });
115
+ return after;
99
116
  });
100
117
  if (n) { stripped[f] = n; strippedTotal += n; }
101
118
  remainingTotal += out.filter((l) => ANY_CITATION.test(l)).length;
102
119
  if (APPLY && n) writeFileSync(join(ROOT, f), out.join("\n"));
103
120
  }
104
- return { strippedTotal, remainingTotal, stripped, handoff };
121
+ return { strippedTotal, remainingTotal, stripped, handoff, unreadable };
105
122
  }
106
123
 
107
124
  if (import.meta.url === `file://${process.argv[1]}`) {
108
125
  const tracked = execFileSync("git", ["-C", ROOT, "ls-files"], { encoding: "utf8", maxBuffer: 1 << 28 })
109
126
  .split("\n").filter(Boolean);
110
127
  const s = surveyOf(tracked, (f) => readFileSync(join(ROOT, f), "utf8"));
128
+ // BEFORE ANYTHING ELSE, because every number under it is about the files that COULD be read.
129
+ if (s.unreadable.length) {
130
+ console.log(`${s.unreadable.length} file(s) COULD NOT BE READ — every count below excludes them:`);
131
+ for (const u of s.unreadable) console.log(` ${u.file} ${u.why}`);
132
+ console.log("");
133
+ }
111
134
  console.log(`${APPLY ? "stripped" : "would strip"} ${s.strippedTotal} opener(s) across ${Object.keys(s.stripped).length} file(s)`);
112
135
  console.log(`${s.handoff.length} citation(s) are NOT openers and need a reader — the hand-off list:`);
113
136
  for (const h of s.handoff) console.log(` ${h.file}:${h.line} ${h.text.slice(0, 110)}`);
@@ -223,6 +223,22 @@ const providerChosen = ["CLEAROTRON_DATABASE"]
223
223
  .some((n) => String(process.env[n] ?? "").trim() !== "");
224
224
  if (!providerChosen) process.env.CLEAROTRON_DATABASE = "corsearch";
225
225
 
226
+ // ── THE SUITE'S OWN BRAND-OWNER FIXTURES, ASKED FOR IN ONE PLACE ──────────────────────────────────
227
+ //
228
+ // Three profiles in `driver/profiles/` are the suite's, and a resolved roster no longer offers them:
229
+ // an outside user's brand-owner picker listed them on a `git clone` install, where the packaging
230
+ // exclusion that protects the tarball does not apply. They are marked `testFixture` and the loader
231
+ // refuses them on every route.
232
+ //
233
+ // The checks still need them, under the same keys the baselines use, and most do not call the loader —
234
+ // they exercise code that does, so there is no argument to pass. One variable here is the whole ask,
235
+ // and it is visible: a reader of this file can see that the suite runs against a roster no install
236
+ // resolves, which is a thing worth being able to see rather than a default nobody set.
237
+ process.env.CLEAROTRON_TEST_FIXTURE_PROFILES ??= "1";
238
+ // The demo account is refused from a fresh install's roster for the same reason and by the same
239
+ // mechanism; the suite asks for it here, once, so no check has to know it was hidden.
240
+ process.env.CLEAROTRON_DEMO_PROFILES ??= "1";
241
+
226
242
  // ── A PARTLY-BLIND SUITE IS NOT A FAILING SUITE ────────────────────────────────────────────
227
243
  //
228
244
  // `git worktree add` gives you no `node_modules`, nothing in this repo creates one, and the tracker asks
@@ -6,7 +6,7 @@
6
6
  // Feedback console went with the quality subsystem, so two of those five had not rendered for
7
7
  // months. Lets us screen-share the site without
8
8
  // revealing real client names or the marks being cleared: client names map to descriptive aliases
9
- // (e.g. Zephyr Beverages → "Beverage company") and marks blur out, behind ONE site-wide toggle (no
9
+ // (e.g. Coastline Drinks → "Beverage company") and marks blur out, behind ONE site-wide toggle (no
10
10
  // hover-reveal). Display-only — it never mutates stored data, run metadata, or form values.
11
11
  //
12
12
  // SHAPE (mirrors site-nav.mjs's NAV_CSS/siteNav split):
@@ -19,7 +19,7 @@
19
19
  // • anonToggle(cfg?) — the toggle button markup for the nav ('' when disabled).
20
20
  //
21
21
  // Tagging conventions the browser overlay understands:
22
- // <span data-anon="client" data-anon-key="zephyr">Zephyr Beverages</span> → text swapped for the alias
22
+ // <span data-anon="client" data-anon-key="coastline">Coastline Drinks</span> → text swapped for the alias
23
23
  // <span data-anon="mark" data-anon-key="…" data-anon-run="…">NOVA PULSE</span> → blurred (unless exempt)
24
24
  // <a data-anon-href data-anon-key="…" data-anon-run="…" href="…"> → href neutralised to # (unless exempt)
25
25
  // A run/client is EXEMPT (shown real) when its key ∈ demoAllow.clientKeys or its run matches
@@ -81,7 +81,7 @@ const OVERLAY_BODY = `(function(){
81
81
  var DEAD_TITLE = "hidden in privacy mode — toggle Privacy OFF to open";
82
82
  var DA = C.demoAllow || {clientKeys:[],runs:[]};
83
83
  // Case-insensitive client lookup, and the reason it is not redundant: surfaces tag rows by profile
84
- // KEY ("zephyr") and by display NAME ("Zephyr"), so lowercasing both makes a single-token name
84
+ // KEY ("coastline") and by display NAME ("Coastline"), so lowercasing both makes a single-token name
85
85
  // resolve to its key's alias. The two surfaces that tagged by NAME were Quality and Feedback, both
86
86
  // retired (tracker issue 265) — the fold stays because it is what makes the lookup total over either spelling,
87
87
  // not because those pages are coming back.
@@ -7,7 +7,7 @@
7
7
  // `process.env.PROFILE_DIR || join(HERE, "profiles")` while the runs, the roster, the account picker and
8
8
  // the artifacts door read `CLEAROTRON_CUSTOMERS_DIR`. Nothing set `PROFILE_DIR` — not `onboard`, not
9
9
  // `.env.example`, not the box's own env — so the settings surface served the product's BUNDLED DEMO
10
- // BUNDLE (aurora, generic, petcary, zephyr) while the runs served the real store.
10
+ // BUNDLE while the runs served the real store.
11
11
  //
12
12
  // WHAT THE CLIENT SAW: every brand owner added the documented way was "These settings are not available
13
13
  // to you." The clearance itself submitted and ran under their framework. Only the surface that
@@ -92,6 +92,34 @@ export function standFrom(dir) {
92
92
  return at === -1 ? s : s.slice(0, at);
93
93
  }
94
94
 
95
+ /**
96
+ * WHICH ROUTE THIS INSTALL ARRIVED BY — a package, or a source checkout.
97
+ *
98
+ * The setup wizard has two spellings and only one of them works for any given reader. Someone who ran
99
+ * `npm i clearotron` has no package scripts, so `npm run setup` is a command they cannot type; someone
100
+ * working in a clone of the source has the scripts but no `clearotron` binary linked for them, because
101
+ * npm links a package's bin for its dependencies and never for itself. Naming one of the two on a
102
+ * screen is a coin flip, and the screen that names it is read by people who have just installed and
103
+ * have nothing else to go on.
104
+ *
105
+ * The answer is a property of where this file sits on disk, so it is derived from that. A package —
106
+ * local or global — is unpacked inside a `node_modules` directory; a checkout is not. `standFrom`
107
+ * already draws that line for the sibling question of where to stand, and reusing it is deliberate:
108
+ * two predicates for "am I a package" would be two chances to disagree.
109
+ *
110
+ * NO PATH IS RETURNED, and that is the point of splitting it out from `invocationForm`. That function's
111
+ * `prefix` can be `cd /srv/whatever && npx `, which is the server's own layout, its account name and
112
+ * its directory structure. This answers the same question with a word, so the answer can travel to a
113
+ * browser without carrying the machine with it.
114
+ *
115
+ * @param {string} dir where this module is installed
116
+ * @returns {'packaged'|'checkout'}
117
+ */
118
+ export function installRoute(dir = INSTALL_DIR) {
119
+ const s = String(dir ?? "");
120
+ return standFrom(s) === s ? "checkout" : "packaged";
121
+ }
122
+
95
123
  /**
96
124
  * WHERE npm PUT THE EXECUTABLE for a GLOBAL install of this package —.
97
125
  *
@@ -43,6 +43,7 @@ export const NAMES_IN_FORCE = Object.freeze([
43
43
  "CLEAROTRON_DEFAULT_AGENT",
44
44
  "CLEAROTRON_DELIVERY",
45
45
  "CLEAROTRON_DEMO",
46
+ "CLEAROTRON_DEMO_PROFILES",
46
47
  "CLEAROTRON_DISPATCH_RECORD",
47
48
  "CLEAROTRON_DOCTOR_ASSUME_PINNED",
48
49
  "CLEAROTRON_DUMP_JSON",
@@ -162,6 +163,7 @@ export const NAMES_IN_FORCE = Object.freeze([
162
163
  "CLEAROTRON_SUITE_TELEMETRY_DIR",
163
164
  "CLEAROTRON_SYNTHESIS_MODEL",
164
165
  "CLEAROTRON_TAINT_CHAIN",
166
+ "CLEAROTRON_TEST_FIXTURE_PROFILES",
165
167
  "CLEAROTRON_TURN_CAP",
166
168
  "CLEAROTRON_TURN_LOCK_POLL_MS",
167
169
  "CLEAROTRON_UNREACHABLE_SENIOR",