clearotron 0.3.2-beta.12 → 0.3.2-beta.14

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 (100) hide show
  1. package/CONTRIBUTING.md +6 -5
  2. package/INSTALL.md +3 -4
  3. package/README.md +2 -1
  4. package/bin/clearotron.mjs +7 -1
  5. package/bin/example.mjs +61 -23
  6. package/bin/onboard.mjs +16 -2
  7. package/bin/start.mjs +6 -3
  8. package/build-info.json +2 -2
  9. package/docs/CLIENT-MCP.md +6 -6
  10. package/docs/DELIVERY.md +3 -3
  11. package/docs/ONBOARDING.md +1 -1
  12. package/docs/PORTAL.md +3 -3
  13. package/docs/RELEASES.md +1 -1
  14. package/docs/SECURITY.md +1 -1
  15. package/docs/architecture/04-configuration-reference.md +4 -4
  16. package/docs/architecture/05-config-governance.md +10 -10
  17. package/docs/architecture/06-operations-runbook.md +3 -3
  18. package/docs/architecture/07-quality-and-audit.md +1 -1
  19. package/docs/architecture/08-development-guide.md +2 -2
  20. package/docs/architecture/09-security-and-data.md +1 -1
  21. package/docs/decisions/0002-no-dark-functionality.md +1 -1
  22. package/docs/decisions/0006-what-the-public-repository-carries.md +3 -3
  23. package/driver/CHANGELOG.md +21 -0
  24. package/driver/ask-ledger.mjs +1 -1
  25. package/driver/band-shape.mjs +1 -1
  26. package/driver/bundled-demos.mjs +2 -2
  27. package/driver/card-budget.mjs +2 -2
  28. package/driver/case-law-ledger.mjs +2 -2
  29. package/driver/connotation-search.mjs +5 -5
  30. package/driver/contract-e3-backlog.mjs +13 -13
  31. package/driver/coverage-form.mjs +2 -2
  32. package/driver/demo-container.mjs +26 -2
  33. package/driver/disposition-tool.mjs +2 -2
  34. package/driver/engine/CONTRACT.md +3 -3
  35. package/driver/engine/mcp/codex-config.mjs +3 -11
  36. package/driver/engine/mcp/gather-config.mjs +1 -1
  37. package/driver/engine/openai-agent.mjs +2 -2
  38. package/driver/findings-model.mjs +21 -6
  39. package/driver/gateway.mjs +1 -1
  40. package/driver/package.json +1 -1
  41. package/driver/pipeline-knockout.mjs +1 -1
  42. package/driver/pipeline.mjs +4 -4
  43. package/driver/placement-union.mjs +1 -1
  44. package/driver/portal-mcp-client.mjs +1 -1
  45. package/driver/portal-report.mjs +4 -1
  46. package/driver/portal-service.mjs +17 -5
  47. package/driver/predelivery-lint.mjs +9 -4
  48. package/driver/progress.mjs +37 -1
  49. package/driver/publish/render-knockout.mjs +31 -6
  50. package/driver/publish/render.mjs +6 -6
  51. package/driver/record-discard.mjs +1 -1
  52. package/driver/register-count.mjs +1 -1
  53. package/driver/register-digest-record.mjs +1 -1
  54. package/driver/report-card-record.mjs +2 -2
  55. package/driver/roster-verdict.mjs +2 -2
  56. package/driver/search-policy.mjs +1 -1
  57. package/driver/skeptic-record.mjs +1 -1
  58. package/driver/stages.mjs +1 -1
  59. package/driver/suite-census.json +40 -10
  60. package/driver/systemd/README.md +1 -1
  61. package/driver/unit-inventory.mjs +2 -2
  62. package/driver/verify.mjs +1 -1
  63. package/mcp-server/CHANGELOG.md +8 -0
  64. package/mcp-server/CONNECT.md +8 -8
  65. package/mcp-server/lib/runs.mjs +1 -1
  66. package/mcp-server/package.json +1 -1
  67. package/mcp-server/serve.mjs +27 -0
  68. package/package.json +1 -1
  69. package/portal-ui/package.json +1 -1
  70. package/providers/free-tier/src/capabilities.js +2 -2
  71. package/providers/jx/src/core.js +3 -3
  72. package/providers/jx/src/turn-envelope.mjs +1 -1
  73. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  74. package/providers/oauth-mcp-bridge/package.json +1 -1
  75. package/providers/perplexity/README.md +1 -1
  76. package/providers/signa/src/capabilities.js +2 -2
  77. package/providers/signa/src/core.js +2 -2
  78. package/providers/uspto-local/src/core.js +1 -1
  79. package/providers/uspto-local/src/sync.js +1 -1
  80. package/scripts/README.md +2 -6
  81. package/scripts/citation-anchor-report.mjs +1 -1
  82. package/scripts/citation-line-check.mjs +3 -3
  83. package/scripts/e2e.mjs +1 -1
  84. package/scripts/env-audit.mjs +1 -1
  85. package/scripts/env-classify.mjs +1 -1
  86. package/scripts/pack-publishable.mjs +1 -1
  87. package/scripts/release-artifact-seal.mjs +2 -2
  88. package/scripts/settings-render-check.mjs +5 -1
  89. package/scripts/strip-tracker-citations.mjs +4 -4
  90. package/scripts/test-run.mjs +46 -3
  91. package/shared/browser-temp-root.mjs +10 -3
  92. package/shared/client-door.mjs +18 -6
  93. package/shared/connect-clients.mjs +2 -0
  94. package/shared/demo-start-args.mjs +10 -0
  95. package/shared/identifier-scan.mjs +2 -2
  96. package/shared/invocation.mjs +1 -1
  97. package/shared/reference-guard-classes.mjs +5 -3
  98. package/shared/stdio-connect.mjs +80 -28
  99. package/shared/toml-string.mjs +25 -0
  100. package/shared/writing-standard-classes.mjs +2 -3
@@ -60,7 +60,7 @@ Every `CLEAROTRON_*` name read by shipping code declares an effect class, and an
60
60
  whatever the declaration test enforces. Do not re-derive it by hand; that is how 172, 159 and 352 all
61
61
  came to describe the same repository.
62
62
 
63
- ## Addendum — 2026-08-20, on implementation ( item 8)
63
+ ## Addendum — 2026-08-20, on implementation
64
64
 
65
65
  All four switches are deleted, as ruled. One consequence above is narrowed by what the code turned out
66
66
  to be, and it is recorded here rather than in a pull request body, because this document is what the
@@ -1,6 +1,6 @@
1
1
  # 0006 — What the public repository carries
2
2
 
3
- **Accepted 2026-08-17, by the owner.**
3
+ **Accepted 2026-08-17.**
4
4
 
5
5
  ## Context
6
6
 
@@ -74,7 +74,7 @@ announces which mode it is in, once, so a reader of any run can tell which colum
74
74
  "change the code" is the clause it answers to and this repository's working practice is that agents change
75
75
  the code. `CLAUDE.md` is not carried: it was only ever a one-line pointer at `AGENTS.md`, a second copy of
76
76
  one subject is a future contradiction ([ADR-0004](0004-documentation-structure.md)), and the de-identified
77
- public cut names that file specifically. Ruled by the owner 2026-08-19, on the question raised against the
77
+ public cut names that file specifically. Decided 2026-08-19, on the question raised against the
78
78
  recovery — where the two files had been held back because the revert that stripped them recorded no
79
79
  decision either way, and `shared/withheld-paths.mjs` did not cover them.
80
80
 
@@ -89,7 +89,7 @@ vendor-branded duplicate of it does not, however small.
89
89
  citations across code, comments, env examples and docs; all but six were rewritten, and the six declared
90
90
  are this record's own, where naming what is dropped is the content.
91
91
  - **A binding to a withheld document is retired.** `driver/doc-constants.mjs` pinned four prose figures in
92
- `docs/KNOCKOUT.md` and `docs/REGISTER-HIT-COUNTS.md`; those rows are gone, and the test pinning 's
92
+ `docs/KNOCKOUT.md` and `docs/REGISTER-HIT-COUNTS.md`; those rows are gone, and the test pinning the
93
93
  one-file-states-it-twice case now anchors to the shape rather than to a named document.
94
94
  - **Withholding is reversible**, which is why this is a list rather than a `git rm`. This repository keeps
95
95
  every word.
@@ -1,5 +1,26 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.3.2-beta.14
4
+
5
+ ### Patch Changes
6
+
7
+ - Fixed: The lines for connecting an AI assistant now work when a folder name contains spaces, and the Codex settings block now works on Windows.
8
+ - Fixed: The demo now opens by saying what it shows, without internal diagnostics or a Node warning, and its knockout sample states the rating it reached.
9
+ - Fixed: A knockout report ordered for named countries now lists those countries under Registers counted, instead of a count of the provider's registers.
10
+
11
+ ## 0.3.2-beta.13
12
+
13
+ ### Patch Changes
14
+
15
+ - Fixed: A conditional verdict whose conditions are all kept in the run record no longer shows an empty "conditional on" line. The rating word stands alone.
16
+ - Fixed: A running clearance's card and row show the stage it is in now, including a step back during a correction pass.
17
+ - Fixed: The portal's health check no longer includes an internal error message when the instructions store cannot be read. The message goes to the service log instead.
18
+ - Fixed: `doctor` no longer describes another program on the client door's port as this install's door. It says only that a process holds the port.
19
+ - Fixed: The connect lines name the exact Node your install runs on, and an older Node gets one plain line instead of a crash.
20
+ - Fixed: The demo no longer leaves copies of its samples in your temp directory, including when it is stopped mid-way.
21
+ - Fixed: Running the test suite no longer leaves demo copies in the machine's temporary directory.
22
+ - Fixed: On WSL, the Windows connect line names your Linux distribution, or says plainly to fill it in, instead of leaving it out.
23
+
3
24
  ## 0.3.2-beta.12
4
25
 
5
26
  ### Patch Changes
@@ -170,7 +170,7 @@ function endingForQids(qids, join, ts) {
170
170
  return null;
171
171
  }
172
172
 
173
- /** The {ending, handoff} pair for one cross-check directive. Only `recall` is subject to 's
173
+ /** The {ending, handoff} pair for one cross-check directive. Only `recall` is subject to the
174
174
  * discharge rule; every other net keeps the ending it always had. PURE. */
175
175
  function recallEnding(name, qid, join, ts) {
176
176
  const ending = endingForQids([qid], join, ts);
@@ -769,7 +769,7 @@ export function buildBandShape(band, { targets = [], inScopeClasses = [], crowdC
769
769
  }
770
770
 
771
771
  const fmtN = (n) => Number(n ?? 0).toLocaleString("en-US");
772
- const cell = (v) => String(v ?? "").replace(/\|/g, "\\|").replace(/\s+/g, " ").trim() || "—";
772
+ const cell = (v) => String(v ?? "").replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\s+/g, " ").trim() || "—";
773
773
 
774
774
  // Bounded aggregate table: top rows by count, one honest remainder line — never for the floors.
775
775
  function topTable(md, title, cols, entries, cap = 30) {
@@ -3,8 +3,8 @@
3
3
  // bundled-demos.mjs — WHICH client bundles this repo ships, read from the directory that ships them.
4
4
  //
5
5
  // This was a hand-maintained triple. The directory grew a fourth bundle, the triple did not, and the
6
- // set-equality it feeds could no longer match anything — so the #83 detector, whose whole job is to
7
- // notice a door that has silently fallen back to the bundled roster, returned PASS on the #83
6
+ // set-equality it feeds could no longer match anything — so the roster-fallback detector, whose whole job is to
7
+ // notice a door that has silently fallen back to the bundled roster, returned PASS on exactly that
8
8
  // condition. A guard that cannot match reports that it found nothing wrong.
9
9
  //
10
10
  // The same shape had already produced two FALSE REFUSALS on this check, both from an exact match
@@ -74,13 +74,13 @@ function insertBulletAtSectionHead(text, headingRe, headingLine, bullet, { paren
74
74
  const m = text.match(headingRe);
75
75
  if (m) {
76
76
  const at = m.index + m[0].length;
77
- return `${text.slice(0, at)}\n${bullet}\n${text.slice(at).replace(/^\n/, "\n")}`;
77
+ return `${text.slice(0, at)}\n${bullet}\n${text.slice(at)}`;
78
78
  }
79
79
  if (parentHeadingRe) {
80
80
  const p = text.match(parentHeadingRe);
81
81
  if (p) {
82
82
  const at = p.index + p[0].length;
83
- return `${text.slice(0, at)}\n${headingLine}\n\n${bullet}\n${text.slice(at).replace(/^\n/, "\n")}`;
83
+ return `${text.slice(0, at)}\n${headingLine}\n\n${bullet}\n${text.slice(at)}`;
84
84
  }
85
85
  return `${text.replace(/\s*$/, "")}\n\n${parentHeadingLine}\n\n${headingLine}\n\n${bullet}\n`;
86
86
  }
@@ -114,8 +114,8 @@ export function parseCaseLawLedger(raw) {
114
114
  /**
115
115
  * The census a reader (and a scenario) asks the ledger for. PURE, and it never judges.
116
116
  *
117
- * `readByTerritory` is the one that answers the depth-dive condition: a dive names ONE territory
118
- * (/), so "did this dive read anything in its own territory" is a lookup, not an inference.
117
+ * `readByTerritory` is the one that answers the depth-dive condition: a dive names ONE territory,
118
+ * so "did this dive read anything in its own territory" is a lookup, not an inference.
119
119
  */
120
120
  export function caseLawRetrievalCensus(ledger) {
121
121
  const queries = ledger?.queries ?? [];
@@ -1363,7 +1363,7 @@ export function parseDispositionForm(raw) {
1363
1363
  // EVERY OBLIGATION SHIPS. No head(), no slice(), no "…and N more". The register-digest sibling
1364
1364
  // (gateway.mjs deferredSlicesRequiredRows) printed the first six qids per axis and elided the rest, and its
1365
1365
  // own doc block records what that cost: "Under advice that is an economy; under a requirement it makes
1366
- // compliance impossible." Same rule here, and 's: a value a model must copy is complete, or it is
1366
+ // compliance impossible." Same rule here: a value a model must copy is complete, or it is
1367
1367
  // visibly marked as cut. Nothing below is cut, so nothing below is marked.
1368
1368
  /**
1369
1369
  * The obligation set as the seat's own to-do list, for the tool result. Returns "" when nothing is owed —
@@ -1375,7 +1375,7 @@ export function renderConnotationObligations(ob, { ledgerPath = "your half ledge
1375
1375
  // ── — THE PAGE IS NUMBERED OFF THE SAME FUNCTION THAT RESOLVES THE NUMBER ─────────────────
1376
1376
  //
1377
1377
  // `obligationRows` is the driver's row list, and a `row_index` is a position into it. Numbering this
1378
- // page with its own counter would be two derivations of one ordering, which is the mistake 's
1378
+ // page with its own counter would be two derivations of one ordering, which is the mistake the
1379
1379
  // segment display already names in its own comment: "the display and the check cannot disagree, which
1380
1380
  // they would the moment either recomputed its own split". So the numbers come from the list itself.
1381
1381
  //
@@ -1610,7 +1610,7 @@ export function renderDispositionTable(rows, ob) {
1610
1610
  for (const e of ob?.queries ?? []) for (const r of e.results) if (r.id) byId.set(normId(r.id), r);
1611
1611
  // — the rows are the FORM's, and a row the seat never ruled on has nothing to render: it is
1612
1612
  // outstanding work, which the gate reports as a count, not a table row reading "—".
1613
- const cell = (s) => String(s ?? "").replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
1613
+ const cell = (s) => String(s ?? "").replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
1614
1614
  const usable = (rows ?? []).filter((d) => d?.receipt_id && d?.ruling);
1615
1615
  // ── — A PARKED ROW IS NOT AN OUTSTANDING ROW, AND THE FILTER ABOVE STOPPED BEING SAFE ─────────
1616
1616
  //
@@ -1683,7 +1683,7 @@ const NO_POLICED_SECTION = "(no PR / reputational / connotation section)";
1683
1683
  // id that is not among THAT row's own candidates — the driver wrote those ids, so this is a damaged
1684
1684
  // form, not outstanding work, and its remedy is different.
1685
1685
  // undisposed_cite_too_short deleted — cannot occur; nothing is retyped.
1686
- // recurrent_uncited_form deleted — the form has no shape for the seat to get wrong. 's
1686
+ // recurrent_uncited_form deleted — the form has no shape for the seat to get wrong. The old
1687
1687
  // cited-but-unbound state was a property of PROSE (right page, wrong query string on the line); a
1688
1688
  // form row IS its binding, so the state has no representative.
1689
1689
  // no_source_cite deleted — advisory, prose-only, never reached a token.
@@ -1786,7 +1786,7 @@ export const CONNOTATION_FORM_REASONS = Object.freeze(
1786
1786
  *
1787
1787
  * `quote_unbound` and `form_damaged` are deliberately absent: those rows ARE ruled.
1788
1788
  *
1789
- * `parked` (/) is absent for the opposite reason and it is the load-bearing one: a parked row
1789
+ * `parked` is absent for the opposite reason and it is the load-bearing one: a parked row
1790
1790
  * is NOT ruled, and it is not outstanding either. Outstanding means "the seat still owes this", and the
1791
1791
  * whole point of the park is that the seat has been told to stop — leaving it here would keep the stage
1792
1792
  * failing forever on a row nobody may re-send, which is the deterministic death the park introduced.
@@ -456,7 +456,7 @@ export const E3_BACKLOG = [
456
456
  where: "driver/stages.mjs:3182",
457
457
  surface: "stage-message",
458
458
  evidence: "MACHINE FINDINGS (MANDATORY): … a JSON OBJECT {\"schema_version\":<FINDINGS_SCHEMA_VERSION>,\"rated_under_framework\":\"…\",\"findings\":[...],\"coverage\":[...],\"context_notes\":[...],\"actions\":[...],\"ask_answers\":[...]} … Each finding object has EXACTLY these keys: {\"ordinal\",\"mark\",\"owner\",\"band\",\"net\",\"bor",
459
- reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson via validators.narrative",
459
+ reparsedBy: "driver/findings-model.mjs:844 parseFindingsJson via validators.narrative",
460
460
  removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
461
461
  },
462
462
  // RETIRED 2026-08-16 — synthesis's dictated disposition list, and I am the one who left it standing.
@@ -479,7 +479,7 @@ export const E3_BACKLOG = [
479
479
  where: "driver/stages.mjs:3219",
480
480
  surface: "stage-message",
481
481
  evidence: "- manageable …: {\"category\":\"<EXACTLY one of large-competitor / commercial-partner / troll / well-known-enforcer>\",\"reason\":\"<one-two lines…>\"}",
482
- reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson",
482
+ reparsedBy: "driver/findings-model.mjs:844 parseFindingsJson",
483
483
  removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
484
484
  },
485
485
  {
@@ -488,7 +488,7 @@ export const E3_BACKLOG = [
488
488
  where: "driver/stages.mjs:3235",
489
489
  surface: "stage-message",
490
490
  evidence: "- meters: {\"mark_similarity\":{...},\"goods_proximity\":{...},\"use\":{...},\"enforcer\":{...}} — all four present, each {\"token\",\"basis\",\"source\"}. … mark_similarity = high | medium | low. goods_proximity = high | medium | low. enforcer = high | medium | low | unknown. use = confirmed | not-confirmed | un",
491
- reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson; driver/verify.mjs:1171 checkFindingsSibling gates meters.*.source; finding_basis_source_missing",
491
+ reparsedBy: "driver/findings-model.mjs:844 parseFindingsJson; driver/verify.mjs:1171 checkFindingsSibling gates meters.*.source; finding_basis_source_missing",
492
492
  removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
493
493
  },
494
494
  {
@@ -497,7 +497,7 @@ export const E3_BACKLOG = [
497
497
  where: "driver/stages.mjs:3236",
498
498
  surface: "stage-message",
499
499
  evidence: "- quadrant: {\"x\",\"y\"} numbers in [0,1]. x = goods/services proximity (0 = distant, 1 = identical). y = mark similarity (0 = distinct, 1 = identical).",
500
- reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson",
500
+ reparsedBy: "driver/findings-model.mjs:844 parseFindingsJson",
501
501
  removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
502
502
  },
503
503
  {
@@ -506,7 +506,7 @@ export const E3_BACKLOG = [
506
506
  where: "driver/stages.mjs:3237",
507
507
  surface: "stage-message",
508
508
  evidence: "- source: {\"source_type\",\"resolved_link\"}. source_type EXACTLY one of: register-vendor / register-euipo / common-law-marketplace / common-law-web / case-law",
509
- reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson",
509
+ reparsedBy: "driver/findings-model.mjs:844 parseFindingsJson",
510
510
  removedByMove: "M1 removes the opaque `resolved_link` URI (ordinal / record-handle selection); the object shape and the source_type enum survive",
511
511
  },
512
512
  {
@@ -515,7 +515,7 @@ export const E3_BACKLOG = [
515
515
  where: "driver/stages.mjs:3238",
516
516
  surface: "stage-message",
517
517
  evidence: "coverage[]: ONE object per coverage AREA, EXACTLY {\"area\",\"state\",\"note\"}. … state EXACTLY one of: confirmed-clean / coverage-limited / open / not-searched / note.",
518
- reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson; the render owns the coverage panel from these typed states",
518
+ reparsedBy: "driver/findings-model.mjs:844 parseFindingsJson; the render owns the coverage panel from these typed states",
519
519
  removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
520
520
  },
521
521
  {
@@ -533,7 +533,7 @@ export const E3_BACKLOG = [
533
533
  where: "driver/stages.mjs:3304",
534
534
  surface: "stage-message",
535
535
  evidence: "MARK ASSESSMENT … STRUCTURED FORM …: either field may instead be an OBJECT {\"read\":\"…\",\"spectrum\":\"…\",\"per_class\":[{\"class\":\"5\",\"note\":\"…\"}],\"per_market\":[{\"market\":\"CN\",\"note\":\"…\"}],\"counter_registrations\":[{\"mark\":\"…\",\"uri\":\"/mark/…\",\"note\":\"…\"}],\"acquired\":\"<optional>\",\"note\":\"<optional residual>",
536
- reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson; the report collapses the rows behind toggles and the audit workbook renders them",
536
+ reparsedBy: "driver/findings-model.mjs:844 parseFindingsJson; the report collapses the rows behind toggles and the audit workbook renders them",
537
537
  removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
538
538
  },
539
539
  {
@@ -542,7 +542,7 @@ export const E3_BACKLOG = [
542
542
  where: "driver/stages.mjs:3310",
543
543
  surface: "stage-message",
544
544
  evidence: "FOUR ANSWERS …: \"four_answers\": {\"third_party_rights\":{...},\"objection_likelihood\":{...},\"registrability\":{...},\"client_enforceability\":{...}} … Each answer … is {\"read\":\"…\",\"token\":\"…\",\"basis\":\"…\",\"ordinals\":[…]}. Tokens (closed enums …): third_party_rights = strong|moderate|weak; objection_likelih",
545
- reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson",
545
+ reparsedBy: "driver/findings-model.mjs:844 parseFindingsJson",
546
546
  removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
547
547
  },
548
548
  {
@@ -551,7 +551,7 @@ export const E3_BACKLOG = [
551
551
  where: "driver/stages.mjs:3221",
552
552
  surface: "stage-message",
553
553
  evidence: "ACTIONS REGISTER …: emit \"actions\": [...] — ONE object per forward step …, each EXACTLY {\"id\",\"kind\",\"text\",\"ordinals\"} plus an OPTIONAL \"deadline\" and an OPTIONAL \"condition\". … kind: EXACTLY one of — consent / coexistence-agreement / territorial-delimitation / goods-amendment / mark-modification /",
554
- reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson; pipeline applyCoverageFloor legalActions arm derives the delivered disposition from the closed kind enum",
554
+ reparsedBy: "driver/findings-model.mjs:844 parseFindingsJson; pipeline applyCoverageFloor legalActions arm derives the delivered disposition from the closed kind enum",
555
555
  removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
556
556
  },
557
557
  {
@@ -560,7 +560,7 @@ export const E3_BACKLOG = [
560
560
  where: "driver/stages.mjs:3317",
561
561
  surface: "stage-message",
562
562
  evidence: "COVERAGE JUDGMENT …: emit \"coverage_judgment\": {\"sufficient\":<bool>, \"reason\":\"<one line…>\"} — EXACTLY those two keys. Do NOT emit \"rows\": the driver writes that register itself … anything you type there is replaced wholesale.",
563
- reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson. The \"Do NOT emit rows\" clause is the purest E3 case in the tree — the prompt names a field, dictates its shape and states in the same breath that code overwrites it",
563
+ reparsedBy: "driver/findings-model.mjs:844 parseFindingsJson. The \"Do NOT emit rows\" clause is the purest E3 case in the tree — the prompt names a field, dictates its shape and states in the same breath that code overwrites it",
564
564
  removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
565
565
  },
566
566
  {
@@ -575,7 +575,7 @@ export const E3_BACKLOG = [
575
575
  // in E3_EVIDENCE_UNRESOLVED instead would have grown the not-checked slice for a row that is
576
576
  // perfectly checkable, which is the avoidable coverage loss conversion 9's note names one row up.
577
577
  evidence: "SEND ONE \"ask_answers\" ENTRY PER ASK, as a TOP-LEVEL field of the findings record you hand to the call (never as a narrative section — the driver renders the labelled line",
578
- reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson (finding_ask_answer_answer_missing); assembleReportMd · buildAskAnswersSection joins on the retyped ask string",
578
+ reparsedBy: "driver/findings-model.mjs:844 parseFindingsJson (finding_ask_answer_answer_missing); assembleReportMd · buildAskAnswersSection joins on the retyped ask string",
579
579
  removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
580
580
  },
581
581
  // DELETED at conversion 10, recorded rather than absorbed — E3's own rule for a
@@ -611,7 +611,7 @@ export const E3_BACKLOG = [
611
611
  where: "driver/stages.mjs:3296",
612
612
  surface: "stage-message",
613
613
  evidence: "add it to the top-level \"context_notes\" array — each object EXACTLY {\"type\":\"famous-neighbour-ungrounded\",\"mark\",\"owner\",\"context\"}",
614
- reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson",
614
+ reparsedBy: "driver/findings-model.mjs:844 parseFindingsJson",
615
615
  removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
616
616
  },
617
617
  {
@@ -620,7 +620,7 @@ export const E3_BACKLOG = [
620
620
  where: "driver/stages.mjs:3184",
621
621
  surface: "stage-message",
622
622
  evidence: "- owner: {\"name\",\"country\",\"registrations\":[...]}. … Each registration: {\"uri\", optionally \"classes\":[\"9\",\"41\"],\"status\",\"filed\",\"expiry\",\"jurisdiction\"}. The \"uri\" is the ONLY field that matters: the driver BINDS classes/status/filed/expiry/jurisdiction AND the owner name from the FETCHED record ke",
623
- reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson + the record-binding join. Six of the seven keys are stated in the prompt and overwritten by code in the same sentence",
623
+ reparsedBy: "driver/findings-model.mjs:844 parseFindingsJson + the record-binding join. Six of the seven keys are stated in the prompt and overwritten by code in the same sentence",
624
624
  removedByMove: "M1 removes the opaque `uri` (ordinal / record-handle selection against the band); the object skeleton and the five overwritten keys survive",
625
625
  },
626
626
  {
@@ -13,7 +13,7 @@
13
13
  // PASS; ~86% of emitted tokens landed in no artifact, and the attempt that passed is the one that
14
14
  // PATCHED). A cold ladder never patches.
15
15
  //
16
- // THE CURE IS 's, one gate over: the driver writes the row and the model fills in the judgment. The
16
+ // THE CURE IS THE ONE APPLIED ONE GATE OVER: the driver writes the row and the model fills in the judgment. The
17
17
  // axis, the coverage unit, the open crowd blocks with their qids and hit counts, and the deferred slices
18
18
  // with their per-qid receipt reasons are all facts the driver holds BEFORE the stage dispatches. It
19
19
  // writes them. The seat sets `status` and `reason`, and nothing else in the file is the seat's to write.
@@ -856,7 +856,7 @@ export function findCoverageFormViolations(rows, formError = null) {
856
856
  // kind and nothing in it is a string the model had to copy. parseCoverageLedgerFull survives, but only as
857
857
  // the ARCHIVED-RUN reader (loadCoverageLedger's prose fallback): no gate parses this table any more.
858
858
 
859
- const cell = (s) => String(s ?? "").replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
859
+ const cell = (s) => String(s ?? "").replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
860
860
 
861
861
  /** The `## Coverage ledger` section, rendered from the form's rows. "" when nothing is settled. PURE. */
862
862
  export function renderCoverageLedgerSection(rows) {
@@ -97,6 +97,30 @@ export function prepareSample(dir, { repoRoot, tmp } = {}) {
97
97
  }
98
98
 
99
99
 
100
+ /**
101
+ * THE COPIES GO WHEN THEIR PUBLISH IS DONE, AND AT EXIT WHATEVER HAPPENED.
102
+ *
103
+ * Every copy below is made in the system temp directory and nothing removed it: four per `demo --once`,
104
+ * one per demo start, some a full replayed run of about 1,850 files, and 36 GB of them on one machine
105
+ * (measured 2026-09-19). Each root is recorded as it is made. A caller releases them the moment its
106
+ * publish is done, and an exit hook removes whatever is left, on success and on failure. A caller that
107
+ * can be stopped by a signal turns the signal into an exit, so the hook runs then too.
108
+ */
109
+ const copies = new Set();
110
+ let exitHooked = false;
111
+ function tracked(root) {
112
+ copies.add(root);
113
+ if (!exitHooked) { exitHooked = true; process.once("exit", releaseDemoCopies); }
114
+ return root;
115
+ }
116
+ /** Remove every copy made so far. Safe to call more than once. */
117
+ export function releaseDemoCopies() {
118
+ for (const d of [...copies]) {
119
+ try { rmSync(d, { recursive: true, force: true }); } catch { /* going anyway; the exit hook tries again */ }
120
+ copies.delete(d);
121
+ }
122
+ }
123
+
100
124
  /**
101
125
  * The directory a frozen demo should be PUBLISHED from — itself, or a copy when it is part of this tree.
102
126
  *
@@ -117,7 +141,7 @@ export function publishSource(dir, { repoRoot, tmp = tmpdir() } = {}) {
117
141
  const root = resolve(repoRoot ?? "");
118
142
  const here = resolve(dir);
119
143
  if (!root || !(here === root || here.startsWith(root + sep))) return dir;
120
- const copy = join(mkdtempSync(join(tmp, "clearotron-demo-")), "sample");
144
+ const copy = join(tracked(mkdtempSync(join(tmp, "clearotron-demo-"))), "sample");
121
145
  cpSync(here, copy, { recursive: true });
122
146
  return copy;
123
147
  }
@@ -135,7 +159,7 @@ export function publishContainer(root, { repoRoot, tmp = tmpdir() } = {}) {
135
159
  const repo = resolve(repoRoot ?? "");
136
160
  const here = resolve(root);
137
161
  if (!repo || !(here === repo || here.startsWith(repo + sep))) return { dir: root, unusable: demoInventory(root).unusable };
138
- const copy = join(mkdtempSync(join(tmp, "clearotron-demo-")), "sample");
162
+ const copy = join(tracked(mkdtempSync(join(tmp, "clearotron-demo-"))), "sample");
139
163
  mkdirSync(copy, { recursive: true });
140
164
  const { children, unusable } = demoInventory(here);
141
165
  const left = [...unusable];
@@ -288,8 +288,8 @@ export function callsSoFar(dispositionsPath) {
288
288
  export { PARK_AFTER_REFUSALS };
289
289
 
290
290
  /**
291
- * Which rows have been refused past the bound. PURE, and counted PER ROW ACROSS CALLS — the unit 's
292
- * own banner records an earlier investigation dying on, when it joined per-row-final-state instead.
291
+ * Which rows have been refused past the bound. PURE, and counted PER ROW ACROSS CALLS — the unit this
292
+ * module's own banner records an earlier investigation dying on, when it joined per-row-final-state instead.
293
293
  *
294
294
  * A row that is ruled later is never re-parked, because the union only parks rows that are not ruled.
295
295
  * @returns {string[]} row_ids, sorted, so the same ledger always yields the same answer.
@@ -145,7 +145,7 @@ Stages must name **abstract tiers**, not provider aliases. Per-engine maps:
145
145
  | `sweep` | register-unit, case-law, skeptic, report-overview, report-card | `sonnet` | `$CLEAROTRON_OPENAI_MODEL_SWEEP` |
146
146
  | `cheap` | saturation-probe | `haiku` | `$CLEAROTRON_OPENAI_MODEL_CHEAP` |
147
147
 
148
- **AN UNHONOURED OVERRIDE IS AN ERROR, NOT A SUBSTITUTION** ( corruption 3, 2026-08-03). This
148
+ **AN UNHONOURED OVERRIDE IS AN ERROR, NOT A SUBSTITUTION** (2026-08-03). This
149
149
  section used to declare two further tiers — `skeptic` → `google/gemini-3-flash-preview` and
150
150
  `refutation` → `together/deepseek-ai/DeepSeek-V4-Pro` — each substituting an anthropic model on the
151
151
  anthropic engine, "grade-moving, validated only in the paid A/B". The substitution never was validated
@@ -191,7 +191,7 @@ disarms the refusal and never the record.
191
191
  | `max` | `max` | `xhigh` |
192
192
 
193
193
  `off` used to map to `minimal` on codex and `low` on claude — a whole rung apart at the bottom, so a
194
- cross-engine effort comparison at `off` was off by one ( corruption 4a).`low` is the anthropic
194
+ cross-engine effort comparison at `off` was off by one. `low` is the anthropic
195
195
  floor, so codex came up to it and codex's `minimal` is deliberately unreachable from the driver's tier
196
196
  vocabulary. `max` is the one sanctioned divergence: it means "this engine's top rung", and codex has no
197
197
  `max`. `engine.anthropic.test.mjs` pins the two tables together and asserts that single exception.
@@ -200,7 +200,7 @@ vocabulary. `max` is the one sanctioned divergence: it means "this engine's top
200
200
  (`assertEffectiveTier`), on the EFFECTIVE tier rather than the declared one: Haiku 4.5 rejects adaptive
201
201
  thinking and the request bounces to sonnet, so the pairing measures sonnet and records haiku. Both ways
202
202
  it can arise are runtime overrides that never touch the stage table (`CLEAROTRON_STAGE_THINKING`, an
203
- `--experiment --model`), which is why the start-of-run scan alone could not see it ( corruption 4b).
203
+ `--experiment --model`), which is why the start-of-run scan alone could not see it.
204
204
 
205
205
  ## 4. `claude -p` invocation (anthropic-agent)
206
206
 
@@ -5,6 +5,7 @@
5
5
  // exact server/tool wiring. Two inputs, both produced today by the gateway gather block:
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { dirname, join } from "node:path";
8
+ import { tomlString } from "../../../shared/toml-string.mjs";
8
9
 
9
10
  // • mcpConfig — the claude-shaped JSON string `{mcpServers:{name:{command,args,env,connectionTimeoutMs?}}}`
10
11
  // (buildGatherMcpConfig → JSON.stringify)
@@ -36,17 +37,8 @@ export const CRED_ENV_FORWARD = [
36
37
  ];
37
38
 
38
39
  // ── TOML value escaping (basic strings) ──────────────────────────────────────────────────────────────
39
- export function tomlString(s) {
40
- const str = String(s ?? "");
41
- // TOML basic-string escapes: backslash, double-quote, and the C0 controls TOML names (\b \t \n \f \r).
42
- const esc = str
43
- .replace(/\\/g, "\\\\").replace(/"/g, '\\"')
44
- .replace(/\x08/g, "\\b").replace(/\t/g, "\\t").replace(/\n/g, "\\n")
45
- .replace(/\f/g, "\\f").replace(/\r/g, "\\r")
46
- // any remaining control char → \uXXXX (TOML-legal)
47
- .replace(/[\x00-\x1f]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
48
- return `"${esc}"`;
49
- }
40
+ // One encoder for every TOML block the product writes; re-exported so this module's callers keep their import.
41
+ export { tomlString };
50
42
  function tomlStringArray(arr) {
51
43
  return "[" + (arr || []).map(tomlString).join(", ") + "]";
52
44
  }
@@ -212,7 +212,7 @@ const RECORDING = Object.freeze({
212
212
  // SECOND — skeptic, same shape ( is the template). Its OWN key, so blind-frame's seat is never
213
213
  // handed record_skeptic and skeptic is never handed record_blind_frame.
214
214
  //
215
- // skeptic's key carries a SECOND tool: search_run_artifacts, the sanctioned read surface ('s
215
+ // skeptic's key carries a SECOND tool: search_run_artifacts, the sanctioned read surface (the
216
216
  // ratification hold, unlock path 1). A READ over the run's own tree, not retrieval — it dials nothing,
217
217
  // writes nothing, and is bounded to CLEAROTRON_BAND_RUN_DIR by construction (skeptic-search.mjs). It
218
218
  // replaces the artifact half of the Bash reads O3c measured; the skill-doc half was already the seeded
@@ -60,8 +60,8 @@ const codexBin = () => { const r = resolveEngineProgram("openai-agent"); return
60
60
  // 3 stage families, and BYTE-IDENTICALLY on retry. That was true of that code and that CLI.
61
61
  //
62
62
  // What has changed is not the measurement but everything around it: three weeks of stage-contract
63
- // work, and a codex CLI that has moved several minor versions. The ruling above supersedes 's
64
- // conclusion; it does not erase its evidence. A reader who finds first must be able to get here.
63
+ // work, and a codex CLI that has moved several minor versions. The ruling above supersedes the measurement's
64
+ // conclusion; it does not erase its evidence. A reader who finds the measurement first must be able to get here.
65
65
  //
66
66
  // ── HOW A REGRESSION ANNOUNCES ITSELF — no experiment is scheduled, and none is needed ────────────
67
67
  //
@@ -578,6 +578,18 @@ const clipClause = (s, max) => {
578
578
  * count authority and the fallback. Returns null when either axis is missing (legacy sidecars —
579
579
  * callers fall back to today's bare word). PURE.
580
580
  */
581
+ /**
582
+ * The conditions a client is actually told a CONDITIONAL verdict rests on: the client-voice clauses, and
583
+ * the reasons as their fallback. A clause stored as explicit null is a condition ruled to the run record
584
+ * alone, so its reason is not stated either; `undefined` (legacy/short) is not null. PURE.
585
+ */
586
+ export function statedConditions({ reasons, clauses } = {}) {
587
+ const cs = Array.isArray(clauses) ? clauses : [];
588
+ const conds = (Array.isArray(reasons) ? reasons : []).filter((r, i) => cs[i] !== null).map((r) => String(r ?? "").trim()).filter(Boolean);
589
+ const cls = cs.map((c) => String(c ?? "").trim()).filter(Boolean);
590
+ return { conds, cls };
591
+ }
592
+
581
593
  export function riskStatement({ tier, verdict, reasons, basis, clauses } = {}) {
582
594
  const v = String(verdict || "").toUpperCase();
583
595
  const t = String(tier || "").trim();
@@ -592,10 +604,13 @@ export function riskStatement({ tier, verdict, reasons, basis, clauses } = {}) {
592
604
  if (v === "CONDITIONAL") {
593
605
  // A clause stored as explicit null is a condition ruled to the run record alone: it is not the lede,
594
606
  // it is not counted, and its reason is never the fallback text. `undefined` (legacy/short) is not null.
595
- const cs = Array.isArray(clauses) ? clauses : [];
596
- const conds = (Array.isArray(reasons) ? reasons : []).filter((r, i) => cs[i] !== null).map((r) => String(r ?? "").trim()).filter(Boolean);
597
- const cls = cs.map((c) => String(c ?? "").trim()).filter(Boolean);
598
- const lede = cls[0] ?? conds[0] ?? "the open conditions carried in the report";
607
+ const { conds, cls } = statedConditions({ reasons, clauses });
608
+ // NOTHING STATED, NO "CONDITIONAL ON:" LINE (owner, 2026-09-19). When every condition is ruled to
609
+ // the run record, the line had nothing behind it and fell back to "the open conditions carried in the
610
+ // report": a conditional with no condition. The band word stands alone, and `stance` still says
611
+ // conditional to every consumer that keys on it.
612
+ if (!cls.length && !conds.length) return `${t}${basisNote ? `.${basisNote}` : ""}`;
613
+ const lede = cls[0] ?? conds[0];
599
614
  const first = clipClause(sentenceCaseLead(lede), STATEMENT_CLAUSE_MAX);
600
615
  const n = conds.length || cls.length;
601
616
  const more = n > 1 ? ` (and ${n - 1} more)` : "";
@@ -1228,7 +1243,7 @@ export function parseFindingsJsonLenient(raw, opts = {}) {
1228
1243
  // and drops the others, so a run's entire non-Latin field can collapse into one finding whose prose
1229
1244
  // describes one of them.
1230
1245
  //
1231
- // The fix is 's, at a third site: keep the Latin normalisation exactly as it was where it produces
1246
+ // The fix, at a third site: keep the Latin normalisation exactly as it was where it produces
1232
1247
  // anything, and fall back to the RAW value (NFKC-folded, so full-width and compatibility forms of the
1233
1248
  // same characters still key alike) where it does not. Latin keys are byte-identical to before — pinned
1234
1249
  // by test, because a consolidation key that moved would re-merge every archived finding differently.
@@ -1721,7 +1736,7 @@ function validateNet(f, ord, mode) {
1721
1736
  // (publish/index.mjs strict-parses the archived findings.json), and a delivered matter would stop
1722
1737
  // republishing. So it engages at v7 and archived records parse byte-identically, forever.
1723
1738
  //
1724
- // THE LENIENT PATH IS EXEMPT, for validateNetRequired's reason (/) and one of its own. Dropping a
1739
+ // THE LENIENT PATH IS EXEMPT, for validateNetRequired's reason and one of its own. Dropping a
1725
1740
  // finding because its sentence is shaped wrong would delete a real conflict over a punctuation mark —
1726
1741
  // silence arrived at by enforcing a rule about clarity. It also keeps the token out of pipeline.mjs's A3
1727
1742
  // per-finding salvage lane, which has nothing to salvage here (see the token's own note below).
@@ -1146,7 +1146,7 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1146
1146
  // `resolveModel(model)` / `engine.resolveModelId(model)`, computed here without ever looking at the
1147
1147
  // provider's answer. So `--model gemini` logged `google/gemini-3.1-pro-preview` and ran sonnet (the
1148
1148
  // engine's alias table substituted, silently), a haiku stage that bounced to sonnet logged haiku,
1149
- // and every attribution downstream — the A/B arm's manifest, tokens.mjs' per-model rollup, 's
1149
+ // and every attribution downstream — the A/B arm's manifest, tokens.mjs' per-model rollup, the
1150
1150
  // billing classes — was keyed to a model that had not run. Patching the log would not have fixed
1151
1151
  // it; the log was already saying what the code believed.
1152
1152
  //
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.2-beta.12",
5
+ "version": "0.3.2-beta.14",
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": {
@@ -540,7 +540,7 @@ export async function knockoutInner(ctx, job, opts = {}) {
540
540
  //
541
541
  // ONLY when the counter is the real provider. On fixtures or an injected counter the offices this
542
542
  // deployment can reach are not what the lane is talking to, and splitting on them would defer a
543
- // register the fixture holds — a $0 lane reporting a gap it does not have (the shape 's
543
+ // register the fixture holds — a $0 lane reporting a gap it does not have (the shape a
544
544
  // sibling defect had, and the reason `missing` carries the same guard).
545
545
  const unreachable = countExec.source === "provider" ? registerUnavailableOffices(caps) : [];
546
546
  ctx.registerUnreachable = unreachable;
@@ -4818,7 +4818,7 @@ export function terminalReasonFields(reason, { cap = 200, fullCap = 4000 } = {})
4818
4818
  // `_driver/failure.json` carried reason/reasonVerbatim/failClass/terminalKind and no payload at all, so the
4819
4819
  // operator was told one query had dropped and never which one — E2E had to replay
4820
4820
  // findDroppedConnotationQueries against the preserved artifacts to name it. `reasonDetail` already reached
4821
- // status.json (/); `reasonQuantity` reached neither file.
4821
+ // status.json; `reasonQuantity` reached neither file.
4822
4822
  //
4823
4823
  // Both are written UNCONDITIONALLY as string|null and number|null. Never omitted, never 0: "no key" and
4824
4824
  // "this failure carries no count" are different facts, the same distinction `reasonTruncated` is written on
@@ -7929,7 +7929,7 @@ export function buildAskAnswersSection(askAnswers, intakeAsks) { // @internal
7929
7929
  * change to the delivered report nobody asked for. So the divergence is now one-country-only and owned,
7930
7930
  * rather than general and deferred.
7931
7931
  *
7932
- * This section itself is untouched by the ladder: the grouping is not extended and not re-keyed. 's
7932
+ * This section itself is untouched by the ladder: the grouping is not extended and not re-keyed. The
7933
7933
  * graded entries render in a SIBLING section (buildGradedEntriesSection) using the same line grammar.
7934
7934
  */
7935
7935
  export function buildReasonedNegativesSection(findings) { // @internal
@@ -15696,8 +15696,8 @@ async function pipelineInner(job, opts = {}) {
15696
15696
  // — `reasonDetail` rides beside `reason`, never inside it. abbrev, not a bare slice: a cut that
15697
15697
  // reads as a word is worse than one that reads as a cut, and this one is read by an engineer
15698
15698
  // chasing a defect.
15699
- // — one spelling of the payload for BOTH sinks. status.json already carried `reasonDetail`
15700
- // (/); `_driver/failure.json` carried neither it nor the count, and `reasonQuantity` reached
15699
+ // — one spelling of the payload for BOTH sinks. status.json already carried `reasonDetail`;
15700
+ // `_driver/failure.json` carried neither it nor the count, and `reasonQuantity` reached
15701
15701
  // no file at all — so the operator read "dropped 1 dictated connotation query" and had to replay the
15702
15702
  // gate against the preserved artifacts to learn which one. Derived once here and passed to both
15703
15703
  // writers, so the notice and the status file can never disagree about the payload.
@@ -68,7 +68,7 @@ function indexRows(rows, { bySelection = true, uriLeg = true } = {}) {
68
68
  /**
69
69
  * Union the seat's tiers into what the driver already holds.
70
70
  *
71
- * THE RULE, and why it is that way round — identical to 's, for identical reasons:
71
+ * THE RULE, and why it is that way round — identical to coverage-union.mjs and disposition-union.mjs, for identical reasons:
72
72
  * · a SUBMITTED row the gate would accept wins, so the seat can CORRECT itself;
73
73
  * · otherwise a PRIOR row the gate would accept is kept, so a killed or cold attempt cannot destroy
74
74
  * work already done;
@@ -76,7 +76,7 @@ function post(urlStr, { headers = {}, body = "", wantId = null, timeoutMs = 3000
76
76
  data += c;
77
77
  // SSE early-exit: once the frame with our id is complete, we have the answer — stop reading
78
78
  if (wantId != null && String(res.headers["content-type"] ?? "").includes("text/event-stream")
79
- && new RegExp(`"id"\s*:\s*${wantId}`).test(data) && /\n\n/.test(data)) { res.destroy(); done(); }
79
+ && new RegExp(`"id"\\s*:\\s*${wantId}`).test(data) && /\n\n/.test(data)) { res.destroy(); done(); }
80
80
  });
81
81
  res.on("end", done);
82
82
  res.on("close", done);
@@ -850,7 +850,10 @@ export function sectionsOf(html) {
850
850
  if (!nav) return [];
851
851
  const out = [];
852
852
  for (const m of nav[1].matchAll(STRIP_LINK_RE)) {
853
- const label = m[2].replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
853
+ // Stripped until nothing changes, so a tag that a first pass reassembles cannot survive into the label.
854
+ let label = m[2];
855
+ for (let prev = null; prev !== label;) { prev = label; label = label.replace(/<[^>]*>/g, ""); }
856
+ label = label.replace(/\s+/g, " ").trim();
854
857
  if (m[1] && label) out.push({ id: m[1], label });
855
858
  }
856
859
  return out;