clearotron 0.3.2-beta.10 → 0.3.2-beta.12

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 (74) hide show
  1. package/.env.example +1 -1
  2. package/CONTRIBUTING.md +1 -0
  3. package/INSTALL.md +49 -46
  4. package/README.md +8 -7
  5. package/bin/clearotron.mjs +14 -0
  6. package/bin/connect.mjs +68 -3
  7. package/bin/key.mjs +6 -1
  8. package/bin/onboard.mjs +51 -14
  9. package/bin/passphrase.mjs +4 -2
  10. package/bin/start.mjs +43 -9
  11. package/build-info.json +2 -2
  12. package/docs/architecture/05-config-governance.md +1 -1
  13. package/driver/CHANGELOG.md +416 -388
  14. package/driver/contract-vocabulary.mjs +5 -5
  15. package/driver/engine/mcp/recording-server.mjs +21 -1
  16. package/driver/engine/mcp/supplemental.mjs +22 -5
  17. package/driver/knockout-next-step.mjs +72 -0
  18. package/driver/named-band.mjs +1 -1
  19. package/driver/package.json +1 -1
  20. package/driver/phase0.mjs +16 -7
  21. package/driver/pipeline-knockout.mjs +26 -0
  22. package/driver/pipeline.mjs +48 -1
  23. package/driver/portal-local-auth.mjs +14 -4
  24. package/driver/portal-report.mjs +21 -2
  25. package/driver/portal-service.mjs +20 -11
  26. package/driver/publish/attr.mjs +36 -0
  27. package/driver/publish/index.mjs +10 -10
  28. package/driver/publish/parse.mjs +1 -1
  29. package/driver/publish/render-knockout.mjs +14 -9
  30. package/driver/publish/render.mjs +9 -9
  31. package/driver/publish/xlsx.mjs +7 -1
  32. package/driver/queue-watch-verdict.mjs +1 -1
  33. package/driver/register-availability.mjs +1 -1
  34. package/driver/register-plan.mjs +81 -11
  35. package/driver/result-noun-fields.mjs +3 -1
  36. package/driver/stages-knockout.mjs +1 -1
  37. package/driver/suite-census.json +104 -38
  38. package/driver/unit-inventory.mjs +7 -7
  39. package/driver/verify-knockout.mjs +0 -27
  40. package/mcp-server/CHANGELOG.md +27 -19
  41. package/mcp-server/lib/audit-view.mjs +4 -4
  42. package/mcp-server/lib/trace.mjs +1 -1
  43. package/mcp-server/package.json +1 -1
  44. package/package.json +2 -2
  45. package/portal-ui/dist/assets/{index-CtvwLCti.css → index-7Lq-dXDV.css} +12 -9
  46. package/portal-ui/dist/assets/{index-DXSRxPV_.js → index-w8GFZftk.js} +110 -69
  47. package/portal-ui/dist/index.html +2 -2
  48. package/portal-ui/package.json +1 -1
  49. package/providers/jx/README.md +2 -2
  50. package/providers/jx-subclass/README.md +1 -1
  51. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  52. package/providers/oauth-mcp-bridge/package.json +1 -1
  53. package/scripts/README.md +5 -10
  54. package/scripts/ask-ai-render-check.mjs +26 -1
  55. package/scripts/citation-line-check.mjs +2 -2
  56. package/scripts/dead-names.mjs +17 -16
  57. package/scripts/e2e.mjs +8 -8
  58. package/scripts/env-audit.mjs +6 -0
  59. package/scripts/generated-files-are-current.mjs +35 -4
  60. package/scripts/live-surface-check.mjs +17 -17
  61. package/scripts/release-code-scanning-check.mjs +114 -0
  62. package/scripts/release-visible-check.mjs +117 -0
  63. package/scripts/repo-writes.mjs +42 -0
  64. package/scripts/report-sections-render-check.mjs +245 -0
  65. package/scripts/report-theme-render-check.mjs +31 -3
  66. package/scripts/settings-render-check.mjs +10 -7
  67. package/scripts/test-run.mjs +63 -4
  68. package/shared/client-door.mjs +20 -0
  69. package/shared/invocation.mjs +2 -2
  70. package/shared/parent-watch.mjs +33 -0
  71. package/shared/register-selection.mjs +4 -1
  72. package/shared/root-doc-commands.mjs +10 -4
  73. package/shared/running-start.mjs +14 -3
  74. package/shared/scope.mjs +24 -5
@@ -21,7 +21,7 @@
21
21
  // D4 verify.mjs parseCoverageLedgerJson, same shape
22
22
  // D5 verify.mjs:1504 fail(`${unaccounted[0].token}:…`) — token minted in a DATA ROW
23
23
  // D6 verify.mjs:1567 fail(`${violations[0].token}…`) — validatePlanFeasibility in register-plan.mjs
24
- // D7 verify.mjs:1558 fail(`${v2[0].token}${detail}…`) — register-plan.mjs:2183 disclosureTextByAxis
24
+ // D7 verify.mjs:1558 fail(`${v2[0].token}${detail}…`) — register-plan.mjs:2270 disclosureTextByAxis
25
25
  // D8 verify.mjs:2470 caseLawLedgerFail fail(caseLawLedgerFail(…)) — token built in case-law-ledger.mjs:195 caseLawLedgerFail
26
26
  //
27
27
  // A partition built on the 60 tokens a regex CAN see would run green while blind to the rest, which is
@@ -139,10 +139,10 @@ export const VOCABULARY = [
139
139
  { token: "coverage_form_missing", stages: ["register-digest"], site: "driver/verify.mjs" },
140
140
  { token: "coverage_form_empty", stages: ["register-digest"], site: "driver/verify.mjs" },
141
141
  { token: "coverage_status_offenum", stages: ["register-digest"], site: "driver/verify.mjs:2050" },
142
- { token: "coverage_deferred_unaccounted", stages: ["register-digest"], site: "driver/verify.mjs coverageFormFail", family: "driver/register-plan.mjs:1860 PROVIDER_HARD_ERROR_PREFIX — token on a data row", dynamic: "D5" },
143
- { token: "coverage_clean_unexecuted", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:1594 validatePlanFeasibility", dynamic: "D6" },
144
- { token: "coverage_clean_skipped", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:1955 searchedJurisdictionsFromPlan", dynamic: "D6" },
145
- { token: "coverage_clean_unverified_incomplete", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:2183 disclosureTextByAxis", dynamic: "D7" },
142
+ { token: "coverage_deferred_unaccounted", stages: ["register-digest"], site: "driver/verify.mjs coverageFormFail", family: "driver/register-plan.mjs:1947 PROVIDER_HARD_ERROR_PREFIX — token on a data row", dynamic: "D5" },
143
+ { token: "coverage_clean_unexecuted", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:1681 validatePlanFeasibility", dynamic: "D6" },
144
+ { token: "coverage_clean_skipped", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:2042 searchedJurisdictionsFromPlan", dynamic: "D6" },
145
+ { token: "coverage_clean_unverified_incomplete", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:2270 disclosureTextByAxis", dynamic: "D7" },
146
146
  { token: "coverage_clean_tainted", stages: ["register-digest"], site: "driver/verify.mjs" },
147
147
  { token: "coverage_ledger_", stages: ["register-digest"], site: "driver/verify.mjs", family: "driver/coverage-ledger.mjs (parseCoverageLedgerJson token-first throws)", dynamic: "D4" },
148
148
  { token: "coverage_key_unknown", stages: ["register-digest"], site: "driver/verify.mjs", family: "driver/coverage-ledger.mjs", dynamic: "D4" },
@@ -826,6 +826,26 @@ serve({
826
826
  },
827
827
  },
828
828
  },
829
+ // OFFERED HERE, OR NEVER SENT. The acceptor took this field for a beta and the plan acted on it,
830
+ // and no frame ever proposed one, because the schema a model is given did not offer it. Worded as
831
+ // the owner approved it; it is model-facing prose, so its wording is his.
832
+ house_element_candidate: {
833
+ type: "object",
834
+ description:
835
+ "Only when an element of the mark is one the CLIENT already owns as a registered mark in the " +
836
+ "instructed classes (a house mark before a tagline, for instance): name that element, the remainder " +
837
+ "the analysis should be limited to, and why you read it as the client's own. Ownership is checked " +
838
+ "on the register, by owner, before anything is excluded; if it cannot be verified, nothing is. Omit " +
839
+ "the field when no element is the client's own.",
840
+ required: ["element", "remainder", "owner_basis"],
841
+ properties: {
842
+ element: { type: "string", description: "The client's own element, exactly as it appears in the mark." },
843
+ remainder: { type: "string",
844
+ description: "The rest of the mark: the part the analysis is limited to. Never empty, never the element itself." },
845
+ owner_basis: { type: "string",
846
+ description: "Why you read the element as the client's own — what in the matter says so." },
847
+ },
848
+ },
829
849
  },
830
850
  },
831
851
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
@@ -1112,7 +1132,7 @@ serve({
1112
1132
  factors: { type: "array", minItems: 2, maxItems: 4, items: { type: "string" }, description: "2–4 one-line load-bearing observations behind the band." },
1113
1133
  counterFactors: { type: "array", minItems: 1, maxItems: 3, items: { type: "string" }, description: "1–3 one-line statements of what holds this name at this band rather than the next, either way." },
1114
1134
  mitigation: { type: "string", description: "May be \"\" when nothing would move the band — but SEND THE KEY, so a considered \"none\" is not confusable with an omission." },
1115
- assessment: { type: "string", description: "The opening paragraph a reader of THIS MARK'S own report sees first: what the name is, what the landscape looks like, what drives the rating, what to do with it." },
1135
+ assessment: { type: "string", description: "The opening paragraph a reader of THIS MARK'S own report sees first: what the name is, what the landscape looks like, what drives the rating." },
1116
1136
  contextFraming: { type: "string" },
1117
1137
  registerEstimate: { type: "string" },
1118
1138
  parodyNote: { type: ["string", "null"] },
@@ -30,7 +30,7 @@
30
30
  import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from "node:fs";
31
31
  import { dirname, join } from "node:path";
32
32
  import { driverDir } from "../../../shared/driver-dir.mjs"; //
33
- import { PLAN_PREDICATES, PLAN_MAX_OR_WIDTH, PLAN_MAX_NAME_LENGTH, fingerprint, ownerIntersectionGap, resolveRegions } from "../../register-plan.mjs";
33
+ import { PLAN_PREDICATES, PLAN_MAX_OR_WIDTH, PLAN_MAX_NAME_LENGTH, fingerprint, ownerIntersectionGap, resolveRegions, houseElementOf, withoutHouseElementTerms } from "../../register-plan.mjs";
34
34
  import { entryTermIssues } from "../../../providers/_shared/term-shape.mjs";
35
35
  import { isNonLatinTerm, romanizationRefusal, romanizationSpellings, nativeScriptIndexGap } from "../../../providers/_shared/script-form.mjs";
36
36
 
@@ -289,9 +289,10 @@ export async function proposeSupplemental(params, tctx, deps) {
289
289
  // execute-plan kernel hands it to the provider's buildEntryQuery, which backfills only entries that
290
290
  // declare none (makeRegionRequiredBuildEntryQuery). A proposal that DOES declare regions is
291
291
  // untouched, qid fingerprints are unchanged, and providers that do not require regions ignore it.
292
- let planRegions = [], planClasses = [];
292
+ let planRegions = [], planClasses = [], house = null;
293
293
  try {
294
294
  const frozen = JSON.parse(readFileSync(driverDir(dirname(dirname(outPath)), "register-plan.json"), "utf8"));
295
+ house = houseElementOf(frozen);
295
296
  planRegions = (Array.isArray(frozen?.regions) ? frozen.regions : []).map((r) => String(r).trim()).filter(Boolean);
296
297
  // C3 — the frozen plan's own class list is the priority set: proposals intersecting it compete
297
298
  // for the per-call/per-axis caps first (mintSupplementalEntries stable-sorts; values unchanged).
@@ -307,8 +308,24 @@ export async function proposeSupplemental(params, tctx, deps) {
307
308
  }
308
309
  const perCall = 12; // step 3 — was a knob; no environment ever set it
309
310
  const axisMax = 24; // step 3 — was a knob; no environment ever set it
311
+ // ── THE CLIENT'S OWN ELEMENT IS LEFT OUT BEFORE ANYTHING RUNS ───────────────────────────────────
312
+ //
313
+ // This tool EXECUTES what it mints, before the fold adds it to the plan, so the fold's own exclusion
314
+ // (`houseElementOf` in register-plan.mjs) comes too late here: the query would already have run. On
315
+ // the first live run that proposed an exclusion, this session proposed an exact search on the bare
316
+ // house element the compile had set aside. The same rule is applied here, before the mint. Not a
317
+ // rejection: rejected[] becomes an OPEN ask on the report, and the element is not an open question.
318
+ const excludedHouse = [];
319
+ const offered = [];
320
+ for (const p of proposals) {
321
+ const kept = house ? withoutHouseElementTerms({ ...(p ?? {}), predicate: String(p?.predicate ?? "default") }, house) : p;
322
+ if (!kept) { excludedHouse.push(String(p?.term ?? p?.terms?.[0] ?? "")); continue; }
323
+ offered.push(kept === p || !Array.isArray(kept.terms) ? p : { ...p, terms: kept.terms });
324
+ }
325
+ if (!offered.length)
326
+ return { type: "text", text: JSON.stringify({ minted: [], reused: [], rejected: [], excluded_house_element: excludedHouse, executed: false }, null, 2) };
310
327
  const existingQids = new Set(supp.entries.map((e) => e.qid));
311
- const { minted, reused, rejected, enriched, narrowed } = mintSupplementalEntries(axis, proposals,
328
+ const { minted, reused, rejected, enriched, narrowed } = mintSupplementalEntries(axis, offered,
312
329
  { existingQids, perCall, axisMax, existingCount: supp.entries.length, capabilities: deps.capabilities ?? null, priorityClasses: planClasses });
313
330
 
314
331
  // Field-level romanisation enrichment of a REUSED qid (2026-07-30 review round): the natural retry —
@@ -373,7 +390,7 @@ export async function proposeSupplemental(params, tctx, deps) {
373
390
  const r = await executePlan({ plan_path: suppPath, axis, output_path: outPath, qids }, tctx);
374
391
  const text = r && typeof r === "object" ? (r.text ?? "") : String(r ?? "");
375
392
  if (!text || text.startsWith("ERROR")) {
376
- return { type: "text", text: JSON.stringify({ minted: minted.map((e) => e.qid), reused, ...(enrichedQids.length ? { enriched: enrichedQids } : {}), ...(conflicts.length ? { conflict: conflicts } : {}), rejected, ...(narrowed.length ? { narrowed } : {}), executed: false, error: text.slice(0, 300) || "executor returned nothing" }, null, 2) };
393
+ return { type: "text", text: JSON.stringify({ minted: minted.map((e) => e.qid), reused, ...(enrichedQids.length ? { enriched: enrichedQids } : {}), ...(conflicts.length ? { conflict: conflicts } : {}), rejected, ...(excludedHouse.length ? { excluded_house_element: excludedHouse } : {}), ...(narrowed.length ? { narrowed } : {}), executed: false, error: text.slice(0, 300) || "executor returned nothing" }, null, 2) };
377
394
  }
378
395
  try { summary = JSON.parse(text); } catch { summary = { raw: text.slice(0, 300) }; }
379
396
  }
@@ -398,5 +415,5 @@ export async function proposeSupplemental(params, tctx, deps) {
398
415
  }
399
416
  } catch { /* band unreadable — the executor summary still crosses */ }
400
417
 
401
- return { type: "text", text: JSON.stringify({ minted: minted.map((e) => e.qid), reused, ...(enrichedQids.length ? { enriched: enrichedQids } : {}), ...(conflicts.length ? { conflict: conflicts } : {}), rejected, ...(narrowed.length ? { narrowed } : {}), executed: qids.length > 0, summary, results }, null, 2) };
418
+ return { type: "text", text: JSON.stringify({ minted: minted.map((e) => e.qid), reused, ...(enrichedQids.length ? { enriched: enrichedQids } : {}), ...(conflicts.length ? { conflict: conflicts } : {}), rejected, ...(excludedHouse.length ? { excluded_house_element: excludedHouse } : {}), ...(narrowed.length ? { narrowed } : {}), executed: qids.length > 0, summary, results }, null, 2) };
402
419
  }
@@ -0,0 +1,72 @@
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
+ // knockout-next-step.mjs — a next-step section the assessing model wrote into a name's read comes off
4
+ // before the report is published.
5
+ //
6
+ // A knockout states findings and a rating; what to do with the name is the reading lawyer's. The assessing
7
+ // instructions stopped asking for a next step, and the model still writes one now and then, under a heading
8
+ // of its own: "What to do with it", "Practical next step". Refusing the turn and re-asking cost a full
9
+ // re-dispatch each time, and the owner ruled for delivery over the loop (2026-09-19). So the section is
10
+ // removed here, in code, with no model call, and the run record names every heading removed.
11
+ //
12
+ // WHAT COMES OFF: a heading whose own words are a next step, and the section under it — nothing else. A
13
+ // hashed heading's section runs to the next heading at its level or above; a line that is only bold text
14
+ // runs to the next heading of either kind; a bold label opening a paragraph ("**Practical next step** —
15
+ // …") takes that paragraph. Position does not matter: removing a section cannot reorder what is left.
16
+ //
17
+ // WHAT STAYS, BY DESIGN: the same advice written as an ordinary sentence with no heading over it. Nothing
18
+ // bounds that sentence but judgement, and cutting a guess out of the client's read is worse than leaving
19
+ // it. The instruction not to write one is the only thing that reaches it.
20
+
21
+ // A heading's own words that make it a next step. Whole-heading: "What drives the rating" and "What is
22
+ // still open" are not next steps, and a word appearing somewhere inside a heading proves nothing.
23
+ export const NEXT_STEP_HEADING_RE =
24
+ /^(?:(?:practical|immediate|suggested|recommended|possible)\s+)?(?:next\s+steps?\b.*|what\s+to\s+do\b.*|what\s+happens\s+next|recommendations?\b.*|our\s+recommendations?\b.*|(?:the\s+)?way\s+forward)$/i;
25
+
26
+ const label = (s) => String(s).replace(/[*_`]/g, "").replace(/[\s:.–—-]+$/, "").trim();
27
+
28
+ /** What kind of heading this line is, and its words — or null for a line that heads nothing. PURE. */
29
+ function headingAt(line) {
30
+ const atx = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
31
+ if (atx) return { kind: "atx", level: atx[1].length, text: label(atx[2]) };
32
+ const bold = /^\s*(\*\*|__)(.+?)\1\s*:?\s*$/.exec(line);
33
+ if (bold) return { kind: "bold", text: label(bold[2]) };
34
+ const lead = /^\s*(\*\*|__)(.+?)\1\s*[:.–—-]?\s+\S/.exec(line);
35
+ if (lead) return { kind: "lead", text: label(lead[2]) };
36
+ return null;
37
+ }
38
+
39
+ /** Is this whole line a heading over a next step? PURE. */
40
+ export function isNextStepHeading(line) {
41
+ const h = headingAt(String(line ?? ""));
42
+ return Boolean(h && NEXT_STEP_HEADING_RE.test(h.text));
43
+ }
44
+
45
+ /**
46
+ * The text with every next-step section removed, and what was removed: `{ text, removed: [{ heading,
47
+ * chars }] }`. Text with nothing to remove comes back byte-identical. PURE.
48
+ */
49
+ export function stripNextStepSections(text) {
50
+ const src = String(text ?? "");
51
+ const lines = src.split("\n");
52
+ const keep = [];
53
+ const removed = [];
54
+ for (let i = 0; i < lines.length;) {
55
+ const h = headingAt(lines[i]);
56
+ if (!h || !NEXT_STEP_HEADING_RE.test(h.text)) { keep.push(lines[i]); i++; continue; }
57
+ let j = i + 1;
58
+ if (h.kind === "lead") {
59
+ while (j < lines.length && lines[j].trim() !== "") j++;
60
+ } else {
61
+ for (; j < lines.length; j++) {
62
+ const n = headingAt(lines[j]);
63
+ if (!n || n.kind === "lead") continue;
64
+ if (h.kind === "atx" ? n.kind === "atx" && n.level <= h.level : true) break;
65
+ }
66
+ }
67
+ removed.push({ heading: h.text, chars: lines.slice(i, j).join("\n").trim().length });
68
+ i = j;
69
+ }
70
+ if (!removed.length) return { text: src, removed };
71
+ return { text: keep.join("\n").replace(/\n[ \t]*\n(?:[ \t]*\n)+/g, "\n\n").trim(), removed };
72
+ }
@@ -137,7 +137,7 @@ export function parseNamedBand(raw) {
137
137
  // byte-identical to a slice the plan deliberately counted without fetching. Measured on a real
138
138
  // run: four capability-gap blocks carried `error:true, deferred:true` into this function and
139
139
  // reached record-carry.json with both fields gone and a sentence claiming the run "has a hit
140
- // COUNT for this slice". register-plan.mjs:1594 validatePlanFeasibility already enforces the same rule one layer up
140
+ // COUNT for this slice". register-plan.mjs:1681 validatePlanFeasibility already enforces the same rule one layer up
141
141
  // ("a transient must not ship indistinguishable from a sanctioned descriptor") — it reads the
142
142
  // RAW blocks, which is why it could. Every consumer that reads THIS projection could not.
143
143
  // Conditional like the four keys above, so old bands carry neither key and nothing shifts.
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.2-beta.10",
5
+ "version": "0.3.2-beta.12",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {
package/driver/phase0.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { existsSync, appendFileSync, readFileSync, mkdirSync } from "node:fs";
7
7
  import { join, dirname } from "node:path";
8
- import { createHash, randomUUID } from "node:crypto";
8
+ import { createHash, randomUUID, randomInt } from "node:crypto";
9
9
  import { ledgerPath } from "../providers/_shared/ledger-path.mjs";
10
10
  import { config } from "./driver.config.mjs";
11
11
  import { resolveProfile } from "./profiles.mjs";
@@ -26,7 +26,15 @@ const NOUN = [
26
26
  "warren", "beacon", "quarry", "willow", "kestrel", "monolith", "estuary", "bramble", "compass", "drift",
27
27
  ];
28
28
 
29
- export function genCodename(rand = Math.random) {
29
+ // THE CODENAME IS DRAWN FROM THE OPERATING SYSTEM'S RANDOM SOURCE, not Math.random. It is a label, not a
30
+ // secret: it names a run's directory and appears in its reports, and the keys built from it name a run's
31
+ // attempts and ledgers — nothing is authorised by knowing one. But it is the first thing the run identifier
32
+ // is made of, and a label that is also unpredictable costs nothing here, while a predictable one invites
33
+ // every later reader to wonder what else leans on it. Same [0, 1) shape as Math.random, so the injected
34
+ // `rand` the tests pass for determinism works unchanged.
35
+ const cryptoRand = () => randomInt(0, 2 ** 32) / 2 ** 32;
36
+
37
+ export function genCodename(rand = cryptoRand) {
30
38
  return `${ADJ[Math.floor(rand() * ADJ.length)]}-${NOUN[Math.floor(rand() * NOUN.length)]}`;
31
39
  }
32
40
 
@@ -186,7 +194,7 @@ export function claimRunCodename({ slug, date, codename, registryPath = codename
186
194
  // a pre-mint through raw genCodename would silently lose this collision protection.
187
195
  // 20 straight collisions ⇒ the 400-name space is exhausted for this slug+date — suffix for freshness.
188
196
  export function mintFreshCodename({ slug, date, studioRoot = config.studioRoot, archiveRoot = config.archiveRoot,
189
- rand = Math.random, claim = claimRunCodename }) {
197
+ rand = cryptoRand, claim = claimRunCodename }) {
190
198
  for (let i = 0; i < 20; i++) {
191
199
  const c = genCodename(rand);
192
200
  if (!existsSync(runDirFor({ slug, date, codename: c, studioRoot }))
@@ -198,22 +206,23 @@ export function mintFreshCodename({ slug, date, studioRoot = config.studioRoot,
198
206
  return `${genCodename(rand)}-${Date.now().toString(36)}`;
199
207
  }
200
208
 
201
- // Assemble the immutable run identity for a job. rand is injectable for tests. studioRoot/archiveRoot are the
209
+ // Assemble the immutable run identity for a job. rand and claim are injectable for tests — claim so a test's
210
+ // mints go to a registry of its own rather than the box-wide one every run on this account shares. studioRoot/archiveRoot are the
202
211
  // FORWARDING agent's (derived from its queue dir by the runner); they default to clawdi's for back-compat.
203
212
  // `codename`/`date` overrides exist for RESUME: re-driving a failed run must rebuild the SAME run identity
204
213
  // (slug/date/codename → the same run-dir) so the idempotency skip reuses the prior stages instead of minting
205
214
  // a fresh codename and re-spending everything (the "pearl-keystone" trap). A bare new run leaves both unset.
206
215
  export function buildRunContext(
207
216
  job,
208
- { rand = Math.random, now = new Date(), studioRoot = config.studioRoot, archiveRoot = config.archiveRoot,
209
- codename: codenameOverride, date: dateOverride } = {},
217
+ { rand = cryptoRand, now = new Date(), studioRoot = config.studioRoot, archiveRoot = config.archiveRoot,
218
+ codename: codenameOverride, date: dateOverride, claim = claimRunCodename } = {},
210
219
  ) {
211
220
  const slug = deriveSlug(job);
212
221
  const date = dateOverride ?? todayISO(now);
213
222
  // Fresh mints go through mintFreshCodename (above) so they never land in a dir another run already owns.
214
223
  // Overrides skip the check: RESUME (and the runner's dispatch pre-mint, which already minted freshly)
215
224
  // wants the identity verbatim.
216
- const codename = codenameOverride ?? mintFreshCodename({ slug, date, studioRoot, archiveRoot, rand });
225
+ const codename = codenameOverride ?? mintFreshCodename({ slug, date, studioRoot, archiveRoot, rand, claim });
217
226
  return {
218
227
  slug,
219
228
  codename,
@@ -56,6 +56,7 @@ import { registerUnavailableOffices } from "./register-unreachable.mjs";
56
56
  import { runRecordLogPath } from "../providers/_shared/ledger-path.mjs"; // — this run's record log
57
57
  import { validators as koValidators, validateMergedFindings, worstBand, registerSurfacedFilings, raterCaveats, SURVIVOR_BOUNDARY_RE } from "./verify-knockout.mjs";
58
58
  import { reviewAbout, reviewEvidence, reviewEvidenceLines, applyKnockoutReview, knockoutReviewFile } from "./knockout-review-record.mjs";
59
+ import { stripNextStepSections } from "./knockout-next-step.mjs";
59
60
  import { publishKnockout, composeKnockoutEmail } from "./publish/knockout.mjs";
60
61
  import { writeRunStatus, rollupStatus, atomicWrite, identitySeed } from "./progress.mjs"; // — the identity seed is shared; the stepper is not
61
62
  import { batchMarkName } from "./mark-name.mjs";
@@ -349,6 +350,28 @@ export function survivorBoundaryNote(policy) {
349
350
  return `This is ${screenName}, not a clearance. A mark not knocked out here is not clear — it is not knocked out at this screen's depth, and proceeds to clearance. Nothing above is a finding of availability.`;
350
351
  }
351
352
 
353
+ // ── a next-step section the model wrote comes off, in code ──────────────────────────────────────────
354
+ //
355
+ // Each name's read is published as its own report, and the screen states findings and a rating — what to
356
+ // do with the name is the reading lawyer's. A heading the model wrote over a next step, and the section
357
+ // under it, are removed here and named in the run record; `knockout-next-step.mjs` says what counts. This
358
+ // replaced a pre-delivery refusal that re-asked the whole chunk for the same edit (owner, 2026-09-19).
359
+ // EXPORTED so the record line is driven directly, not only through a whole pipeline.
360
+ export function removeNextStepSections(runDir, marks) {
361
+ const out = [];
362
+ for (const m of marks ?? []) {
363
+ if (typeof m?.assessment !== "string") continue;
364
+ const { text, removed } = stripNextStepSections(m.assessment);
365
+ if (!removed.length) continue;
366
+ m.assessment = text;
367
+ const row = { event: "knockout-next-step-removed", mark: m.name, headings: removed.map((r) => r.heading),
368
+ chars: removed.reduce((n, r) => n + r.chars, 0), lane: "knockout" };
369
+ runLog(runDir, row);
370
+ out.push(row);
371
+ }
372
+ return out;
373
+ }
374
+
352
375
  // ── — the knockout lane's recovery park ───────────────────────────────────────
353
376
  //
354
377
  // EXPORTED so it can be armed directly. The clearance lane's equivalent is inline in a 6,000-line catch
@@ -964,6 +987,9 @@ export async function knockoutInner(ctx, job, opts = {}) {
964
987
  // it runs BEFORE the artifact is written, not after. The counts are logged because a receipts pass
965
988
  // over zero citations is a different fact from a receipts pass, and only the count can tell them
966
989
  // apart afterwards.
990
+ // BEFORE THE GATE AND THE WRITE, so the record on disk is the record that ships, and the reviewing
991
+ // pass never addresses a line inside a section that is about to go.
992
+ removeNextStepSections(run.runDir, merged.marks);
967
993
  const mv = validateMergedFindings(run.runDir, merged, plan);
968
994
  if (!mv.ok) throw new StageFailure("knockout-assess", `merged findings failed the lint: ${mv.failures.join("; ")}`, null);
969
995
  runLog(run.runDir, { event: "knockout-receipts", ...mv.receipts });
@@ -13518,7 +13518,7 @@ async function pipelineInner(job, opts = {}) {
13518
13518
  // structured-only). Each stage is file-gated/resumable; per-card sessions feed the lint repair below.
13519
13519
  // C2 — fold same-owner+same-mark duplicate filings into one finding BEFORE the overview + cards read
13520
13520
  // findings.json, so the whole delivery phase (and the published copy) sees the single consolidated set.
13521
- injectDeferralCoverage(P, run.runDir, note); // A3: unclosed reopen directives become reader-visible coverage rows first
13521
+ injectDeferralCoverage(P, run.runDir, note); injectMeaningGapCoverage(P, run.runDir, note); // A3: unclosed reopen directives, and meaning searches that did not complete, become reader-visible coverage rows first
13522
13522
  // qw/cn-scope-honesty — the sibling injection: a CN-family-scope run whose zh lane did not run
13523
13523
  // discloses what the native-language investigation would have searched, and where it is offered
13524
13524
  // (coverage-limited: never clamps, never gates).
@@ -16831,3 +16831,50 @@ export function registerGapConditions(regGap) { // @internal
16831
16831
  });
16832
16832
  return out;
16833
16833
  }
16834
+
16835
+ // ── A MEANING SEARCH THAT DID NOT COMPLETE REACHES THE CLIENT'S OWN PAGE, NOT ONLY THE AUDIT ────────────
16836
+ //
16837
+ // The connotation gate delivers a run whose dictated meaning searches did not all complete, and records
16838
+ // each unfinished one as a gap carrying its term — a gap the engine wrote for a search that never came
16839
+ // back, or the provider's own row for one it refused. The audit read those gaps; the report was never
16840
+ // handed the grid, so whether a client learned a meaning search was missing was up to the synthesis
16841
+ // model. This puts each on the coverage the report renders, the way an unclosed follow-up already is:
16842
+ // the same row, `deferralCoverageRow`, and an approved reader sentence that says it is left open — true
16843
+ // of a drop and of a refusal alike. Telling the two apart on the page
16844
+ // would need a sentence nobody has approved, and is not attempted here.
16845
+ //
16846
+ // The same guard as `injectDeferralCoverage`: a gap synthesis already weighed in is not added twice, and
16847
+ // a findings.json that fails its own schema after the push is left as it was.
16848
+ // THE WORDS ARE CHOSEN, NOT THE CAUSE: of the approved reasons, "nothing in the run's own record confirms it
16849
+ // was searched" is true of a search that never came back AND of one the provider refused, and does not
16850
+ // repeat the row's own "not completed this run" the way the generic fallback does. It borrows that arm's
16851
+ // sentence only; if the arm is ever reworded for its own case, re-read this row against it.
16852
+ const MEANING_SEARCH_UNFINISHED = "not-verified-closed";
16853
+ export function injectMeaningGapCoverage(P, runDir, note) { // @internal — exported so the refusal shape is testable without a run
16854
+ try {
16855
+ if (!existsSync(P.commonLawGrid) || !existsSync(P.findings)) return;
16856
+ const grid = JSON.parse(readFileSync(P.commonLawGrid, "utf8"));
16857
+ const terms = [...new Set((Array.isArray(grid?.gaps) ? grid.gaps : [])
16858
+ .filter((g) => String(g?.platform ?? "").toLowerCase() === "connotation")
16859
+ .map((g) => String(g?.term ?? "").trim()).filter(Boolean))];
16860
+ if (!terms.length) return;
16861
+ const doc = JSON.parse(readFileSync(P.findings, "utf8"));
16862
+ if (!Array.isArray(doc.coverage)) doc.coverage = [];
16863
+ const normTxt = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
16864
+ const covText = normTxt(doc.coverage.map((c) => `${c.area} ${c.note ?? ""}`).join(" "));
16865
+ let added = 0;
16866
+ for (const term of terms) {
16867
+ const slice = normTxt(term).split(" ").filter((w) => w.length >= 4).slice(0, 4).join(" ");
16868
+ if (slice && covText.includes(slice)) continue; // synthesis already weighed it in
16869
+ doc.coverage.push(deferralCoverageRow(term, MEANING_SEARCH_UNFINISHED));
16870
+ added++;
16871
+ }
16872
+ if (!added) return;
16873
+ parseFindingsJson(JSON.stringify(doc)); // re-validate the shape (throws → catch keeps original)
16874
+ atomicWrite(P.findings, `${JSON.stringify(doc, null, 2)}\n`);
16875
+ note(`[common-law] ${added} meaning search(es) that did not complete now reach the report's coverage`);
16876
+ runLog(runDir, { event: "meaning-gap-coverage", added });
16877
+ } catch (e) {
16878
+ note(`[common-law] meaning-gap coverage skipped: ${String(e?.message || e).replace(/\s+/g, " ").slice(0, 100)}`);
16879
+ }
16880
+ }
@@ -422,10 +422,20 @@ export function firstRunCredentialLines({ handoff, credentialPath, email, passph
422
422
  return [head, ` PASSPHRASE: ${passphrase}`,
423
423
  " Write it down now. It is not stored anywhere in a form that can be read back, and this line will not be printed again."];
424
424
  }
425
- return [head,
426
- " The passphrase is NOT printed here: stderr is not a terminal, so this line would outlive the moment \u2014 a journal, a CI log, or a test's captured output.",
427
- " Nothing holds it now, this process included: what is on disk is a digest. To get one you can sign in with, run:",
428
- ` ${resetCommand}`];
425
+ return [head, ...passphraseWithheldLines({ stream: "stderr", resetCommand }).map((line) => ` ${line}`)];
426
+ }
427
+
428
+ /**
429
+ * What stands where the passphrase would, on an output that is not a terminal. ONE composer for the two
430
+ * places a first run can hand the passphrase over \u2014 the portal's standard error and the launcher's closing
431
+ * box on standard output \u2014 so they give one reason and one way back in. It is never handed the
432
+ * passphrase, so no caller can wire the value through it.
433
+ */
434
+ export function passphraseWithheldLines({ stream, resetCommand = "" }) {
435
+ return [
436
+ `The passphrase is NOT printed here: ${stream} is not a terminal, so this line would outlive the moment \u2014 a journal, a CI log, or a test's captured output.`,
437
+ "Nothing holds it now, this process included: what is on disk is a digest. To get one you can sign in with, run:",
438
+ ` ${resetCommand}`];
429
439
  }
430
440
 
431
441
  export const SESSION_DOMAIN = "portal-session.v1|";
@@ -122,6 +122,13 @@ const CHROME_RES = [
122
122
  // has stopped matching. Removing the wrapper first would take the nav with it, drive the count to
123
123
  // zero, and turn a security assertion into a permanent false alarm.
124
124
  { tag: "div", open: /<div class="[^"]*\brep-stickyhead\b[^"]*"[^>]*>/ },
125
+ // THE SECTION MENU, WHEREVER THE RENDERER PUT IT. A report rendered today carries it inside the header
126
+ // above, and it goes with that. A report rendered before 2026-09-18 carries it just AFTER the header, so
127
+ // stripping the header alone left it in the frame: a second copy of the menu the portal draws, pinned
128
+ // over the report's title band, its current item red on red in the dark theme. Measured on an archived
129
+ // global preliminary report served by a published beta. Archived reports are served from their baked
130
+ // bytes, so this has to happen here rather than in the renderer. `sectionsOf` has already read it.
131
+ { tag: "nav", open: /<nav class="[^"]*\bstrip\b[^"]*"[^>]*>/ },
125
132
  // the "Internal review copy — stripped on export" bar, which hosted the quality-capture controls
126
133
  { tag: "div", open: /<div class="[^"]*\breview\b[^"]*\binternal\b[^"]*"[^>]*>/ },
127
134
  // per-finding flag buttons and their popovers
@@ -529,7 +536,7 @@ const EMBED_JS = `
529
536
  function schedule(){
530
537
  if(queued)return;
531
538
  queued=true;
532
- requestAnimationFrame(function(){queued=false;post();});
539
+ requestAnimationFrame(function(){queued=false;post();sections();});
533
540
  }
534
541
  // WHICH CONTROLS THIS DOCUMENT ACTUALLY HAS.
535
542
  //
@@ -559,12 +566,24 @@ const EMBED_JS = `
559
566
  // script) and the shell draws it in its own header. Only the ids that are really in the document are
560
567
  // announced: a report whose renderer named a section it did not draw would otherwise offer the reader
561
568
  // a breadcrumb entry that jumps nowhere.
569
+ //
570
+ // AND WHERE EACH ONE STARTS, so the shell can show how far the reader has got (owner, 2026-09-19): every
571
+ // section reached so far is marked, the rest are not. The page scrolls, not this frame, so only the shell
572
+ // knows where the reader is. It needs each section's top in this document to compare. Said again whenever
573
+ // the layout moves: a panel opening pushes every section below it down.
574
+ var saidSections='';
562
575
  function sections(){
563
576
  try{
564
577
  var list=window.__CORD_SECTIONS;
565
578
  if(!list||!list.length)return;
566
579
  var live=[];
567
- for(var i=0;i<list.length;i++) if(document.getElementById(list[i].id)) live.push(list[i]);
580
+ for(var i=0;i<list.length;i++){
581
+ var el=document.getElementById(list[i].id);
582
+ if(el) live.push({id:list[i].id,label:list[i].label,top:Math.max(0,Math.round(el.getBoundingClientRect().top+window.scrollY))});
583
+ }
584
+ var said=JSON.stringify(live);
585
+ if(said===saidSections)return;
586
+ saidSections=said;
568
587
  parent.postMessage({source:TAG,type:'sections',sections:live},'*');
569
588
  }catch(e){}
570
589
  }
@@ -968,6 +968,15 @@ const DENIAL_REASON = Object.freeze({
968
968
  429: "rate limited",
969
969
  });
970
970
 
971
+ /**
972
+ * WHAT A REFUSED CLIENT IS TOLD — a closed set, keyed on the status, never the error's message. The message
973
+ * can come out of a third-party token check with a claim value, an address or a fragment of the rejected
974
+ * token in it, and some of our own refusals put the caller's address in theirs. A 401 keeps the words the
975
+ * browser contract already decodes ("not signed in"); every other status says the journal's code-owned
976
+ * reason. The message itself is still journalled nowhere a client reads. PURE.
977
+ */
978
+ export const refusalWords = (status) => (status === 401 ? "not signed in" : DENIAL_REASON[status] ?? "refused at the door");
979
+
971
980
  /**
972
981
  * — THE ROW A REFUSAL FILES. One shape, one sink, whichever side of `route` decided the answer.
973
982
  *
@@ -3922,7 +3931,9 @@ const escHtml = (t) => String(t).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<"
3922
3931
  // and brand pack §01 fixes dark at #0f0e0c near-black + #f0e8d8 parchment. The pack wins.
3923
3932
  // ── THE PLAIN LINE LEADS; THE ADMINISTRATOR'S TWO LINES STEP BACK INTO A FOLD ────────────────────────
3924
3933
  //
3925
- // A person arriving here needs the field, the button, and one fact: this install signs in one person.
3934
+ // A person arriving here needs the field, the button, and one fact: this install has one user, and which
3935
+ // address that is. The owner's words, 2026-09-19: "Clearotron portal, as <address>." read as "you are
3936
+ // <address>", and a person who found `key issue` first minted a key and could not get in.
3926
3937
  // The reset command and the way to add people are an administrator's business, so they sit in a closed
3927
3938
  // "Administrator help" fold, word for word as they were, with the same link People gives to putting a
3928
3939
  // login system in front. A `<details>` needs no script, which this door must render without.
@@ -3967,9 +3978,7 @@ ${DOOR_THEME_INIT}
3967
3978
  .hint { margin-top:18px; font-size:13px; }
3968
3979
  code { font-family:var(--mono); font-size:12.5px; background:var(--code-bg);
3969
3980
  padding:1px 5px; border-radius:4px; overflow-wrap:anywhere; }
3970
- .lead { margin:18px 0 0; padding-top:14px; border-top:1px solid var(--line); font-size:13.5px; color:var(--ink); }
3971
- .lead b { font-weight:600; }
3972
- .fold { margin-top:10px; }
3981
+ .fold { margin-top:18px; padding-top:14px; border-top:1px solid var(--line); }
3973
3982
  .fold > summary { display:inline-flex; align-items:center; gap:6px; list-style:none; cursor:pointer;
3974
3983
  font-size:13px; color:var(--link); }
3975
3984
  .fold > summary::-webkit-details-marker { display:none; }
@@ -3984,7 +3993,7 @@ ${signedIn
3984
3993
  ? `<p>You are signed in as <span class="who">${escHtml(email)}</span>.</p>
3985
3994
  <div><a href="/portal">Go to the portal</a></div>
3986
3995
  <form method="post" action="/portal/logout"><button type="submit">Sign out</button></form>`
3987
- : `<p>${escHtml(BRAND.name)} portal, as <span class="who">${escHtml(email)}</span>.</p>
3996
+ : `<p>This ${escHtml(BRAND.name)} has one user: <b class="who">${escHtml(email)}</b>. Enter its passphrase.</p>
3988
3997
  ${discarded ? `<p class="hint">A session this portal did not start, from another ${escHtml(BRAND.name)} on this address or an expired one, was set aside. Sign in below.</p>` : ""}
3989
3998
  ${error ? `<p class="err">${escHtml(error)}</p>` : ""}
3990
3999
  <form method="post" action="/portal/login">
@@ -3992,10 +4001,10 @@ ${error ? `<p class="err">${escHtml(error)}</p>` : ""}
3992
4001
  <input id="passphrase" name="passphrase" type="password" autocomplete="new-password" autofocus>
3993
4002
  <button type="submit">Sign in</button>
3994
4003
  </form>
3995
- <p class="lead"><b>This ${escHtml(BRAND.name)} signs in one person: you.</b></p>
3996
4004
  <details class="fold"><summary><span>Administrator help</span><svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m9 18 6-6-6-6"/></svg></summary>
3997
- <p class="hint">Lost the passphrase? Run <code>${escHtml(reset)}</code> on the machine
3998
- running this portal. It mints a new one and prints it once.</p>
4005
+ <p class="hint">The passphrase was printed once when this ${escHtml(BRAND.name)} first started. Lost it? Run
4006
+ <code>${escHtml(reset)}</code> on the machine running this portal. It prints a new one, once, for the same user.</p>
4007
+ <p class="hint">A key from <code>${escHtml(bareInvocation("key"))} issue</code> is for an AI assistant, not for this page.</p>
3999
4008
  <p class="hint">To add people, put it behind a login system such as your company single sign-on. <a href="${escHtml(LOGIN_IN_FRONT_DOC)}" target="_blank" rel="noreferrer">How to set that up</a></p>
4000
4009
  </details>`}
4001
4010
  </div></body></html>`;
@@ -4399,7 +4408,7 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
4399
4408
  // row must not become a place a caller can write into by sending a body that fails to parse.
4400
4409
  let body;
4401
4410
  try { body = await readJsonBody(req); }
4402
- catch (e) { journal(400, "unreadable body", identity?.email); return send(res, 400, { error: String(e.message) }); }
4411
+ catch { journal(400, "unreadable body", identity?.email); return send(res, 400, { error: "unreadable body" }); } // the parser's own message describes the bytes it was sent, and a response is not where a parse error is read back
4403
4412
  const query = Object.fromEntries(url.searchParams.entries());
4404
4413
  const r = await service.route(req.method, url.pathname, identity, body, query);
4405
4414
  // A PLAIN DOCUMENT this server renders itself. Escaped into a `<pre>`, so nothing in the file can
@@ -4466,11 +4475,11 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
4466
4475
  // person who typed the address gets a door.
4467
4476
  const wantsHtml = String(req.headers.accept ?? "").includes("text/html");
4468
4477
  if (wantsHtml) {
4469
- const html = denialPage(e.status, e.message);
4478
+ const html = denialPage(e.status, refusalWords(e.status));
4470
4479
  res.writeHead(e.status, { "content-type": "text/html; charset=utf-8", "content-length": Buffer.byteLength(html), "x-content-type-options": "nosniff" });
4471
4480
  return res.end(html);
4472
4481
  }
4473
- return send(res, e.status, { error: e.message });
4482
+ return send(res, e.status, { error: refusalWords(e.status) });
4474
4483
  }
4475
4484
  log(`500 ${String(e?.message || e)}`);
4476
4485
  if (!alreadyFiled(e)) journal(500, e?.message ?? String(e), identity?.email);
@@ -0,0 +1,36 @@
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
+ // attr.mjs — a value placed inside a quoted HTML attribute, and a URL placed inside an href.
4
+ //
5
+ // Every renderer here has its own `esc`, and each escapes `&`, `<` and `>` — which is right for text and
6
+ // wrong inside an attribute: a `"` in the value closes the attribute and whatever follows is markup. So a
7
+ // value that sits between quotes goes through `attrValue`, which also encodes both quote characters.
8
+ //
9
+ // An href needs one more guard, because a correctly escaped `javascript:` URL is still a link that runs
10
+ // code when clicked, and the same for `data:` and `vbscript:`. So an href is built by `hrefAttr` or not at
11
+ // all: http(s) only — or, when the caller says so, a reference with no scheme (the audit download is a file
12
+ // beside the report). A browser drops tabs, newlines and other control characters from a URL before it
13
+ // reads the scheme, so the scheme is read with those removed: `java` + newline + `script:` is `javascript:`.
14
+
15
+ // Every C0 control character, space and DEL — what a URL parser strips or ignores before the scheme.
16
+ const IGNORED_BEFORE_SCHEME = /[\x00-\x20\x7f]/g;
17
+
18
+ /** A value safe between the quotes of an HTML attribute: & < > " and ' all encoded. PURE. */
19
+ export const attrValue = (s) => String(s ?? "")
20
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
21
+ .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
22
+
23
+ /**
24
+ * An href value, attribute-safe, or null when it must not be a link — the caller then renders its text
25
+ * with no anchor. `relative: true` also admits a reference with no scheme, never a protocol-relative one
26
+ * (`//host/…` is an external address by another spelling). PURE.
27
+ */
28
+ export function hrefAttr(u, { relative = false } = {}) {
29
+ const s = String(u ?? "").trim();
30
+ if (!s) return null;
31
+ const probe = s.replace(IGNORED_BEFORE_SCHEME, "");
32
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(probe)?.[1];
33
+ if (scheme) { if (!/^https?$/i.test(scheme)) return null; }
34
+ else if (!relative || probe.startsWith("//") || probe.startsWith("\\\\")) return null;
35
+ return attrValue(s);
36
+ }