clearotron 0.3.2-beta.11 → 0.3.2-beta.13

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 (122) hide show
  1. package/.env.example +1 -1
  2. package/CONTRIBUTING.md +6 -5
  3. package/INSTALL.md +3 -4
  4. package/README.md +2 -1
  5. package/bin/clearotron.mjs +14 -0
  6. package/bin/connect.mjs +68 -3
  7. package/bin/example.mjs +12 -1
  8. package/bin/key.mjs +6 -1
  9. package/bin/onboard.mjs +23 -3
  10. package/bin/passphrase.mjs +4 -2
  11. package/bin/start.mjs +18 -6
  12. package/build-info.json +2 -2
  13. package/docs/CLIENT-MCP.md +6 -6
  14. package/docs/DELIVERY.md +3 -3
  15. package/docs/ONBOARDING.md +1 -1
  16. package/docs/PORTAL.md +3 -3
  17. package/docs/RELEASES.md +1 -1
  18. package/docs/SECURITY.md +1 -1
  19. package/docs/architecture/04-configuration-reference.md +4 -4
  20. package/docs/architecture/05-config-governance.md +10 -10
  21. package/docs/architecture/06-operations-runbook.md +3 -3
  22. package/docs/architecture/07-quality-and-audit.md +1 -1
  23. package/docs/architecture/08-development-guide.md +2 -2
  24. package/docs/architecture/09-security-and-data.md +1 -1
  25. package/docs/decisions/0002-no-dark-functionality.md +1 -1
  26. package/docs/decisions/0006-what-the-public-repository-carries.md +3 -3
  27. package/driver/CHANGELOG.md +29 -0
  28. package/driver/ask-ledger.mjs +1 -1
  29. package/driver/band-shape.mjs +1 -1
  30. package/driver/bundled-demos.mjs +2 -2
  31. package/driver/card-budget.mjs +2 -2
  32. package/driver/case-law-ledger.mjs +2 -2
  33. package/driver/connotation-search.mjs +5 -5
  34. package/driver/contract-e3-backlog.mjs +13 -13
  35. package/driver/contract-vocabulary.mjs +5 -5
  36. package/driver/coverage-form.mjs +2 -2
  37. package/driver/demo-container.mjs +26 -2
  38. package/driver/disposition-tool.mjs +2 -2
  39. package/driver/engine/CONTRACT.md +3 -3
  40. package/driver/engine/mcp/gather-config.mjs +1 -1
  41. package/driver/engine/mcp/recording-server.mjs +1 -1
  42. package/driver/engine/mcp/supplemental.mjs +22 -5
  43. package/driver/engine/openai-agent.mjs +2 -2
  44. package/driver/findings-model.mjs +21 -6
  45. package/driver/gateway.mjs +1 -1
  46. package/driver/knockout-next-step.mjs +72 -0
  47. package/driver/named-band.mjs +1 -1
  48. package/driver/package.json +1 -1
  49. package/driver/pipeline-knockout.mjs +27 -1
  50. package/driver/pipeline.mjs +4 -4
  51. package/driver/placement-union.mjs +1 -1
  52. package/driver/portal-mcp-client.mjs +1 -1
  53. package/driver/portal-report.mjs +25 -3
  54. package/driver/portal-service.mjs +25 -13
  55. package/driver/predelivery-lint.mjs +9 -4
  56. package/driver/progress.mjs +37 -1
  57. package/driver/publish/render-knockout.mjs +14 -9
  58. package/driver/publish/render.mjs +6 -6
  59. package/driver/record-discard.mjs +1 -1
  60. package/driver/register-availability.mjs +1 -1
  61. package/driver/register-count.mjs +1 -1
  62. package/driver/register-digest-record.mjs +1 -1
  63. package/driver/register-plan.mjs +81 -11
  64. package/driver/report-card-record.mjs +2 -2
  65. package/driver/result-noun-fields.mjs +3 -1
  66. package/driver/roster-verdict.mjs +2 -2
  67. package/driver/search-policy.mjs +1 -1
  68. package/driver/skeptic-record.mjs +1 -1
  69. package/driver/stages-knockout.mjs +1 -1
  70. package/driver/stages.mjs +1 -1
  71. package/driver/suite-census.json +72 -24
  72. package/driver/systemd/README.md +1 -1
  73. package/driver/unit-inventory.mjs +2 -2
  74. package/driver/verify-knockout.mjs +0 -27
  75. package/driver/verify.mjs +1 -1
  76. package/mcp-server/CHANGELOG.md +8 -0
  77. package/mcp-server/CONNECT.md +8 -8
  78. package/mcp-server/lib/runs.mjs +1 -1
  79. package/mcp-server/package.json +1 -1
  80. package/mcp-server/serve.mjs +27 -0
  81. package/package.json +1 -1
  82. package/portal-ui/dist/assets/{index-CtvwLCti.css → index-7Lq-dXDV.css} +12 -9
  83. package/portal-ui/dist/assets/{index-DXSRxPV_.js → index-w8GFZftk.js} +110 -69
  84. package/portal-ui/dist/index.html +2 -2
  85. package/portal-ui/package.json +1 -1
  86. package/providers/free-tier/src/capabilities.js +2 -2
  87. package/providers/jx/src/core.js +3 -3
  88. package/providers/jx/src/turn-envelope.mjs +1 -1
  89. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  90. package/providers/oauth-mcp-bridge/package.json +1 -1
  91. package/providers/perplexity/README.md +1 -1
  92. package/providers/signa/src/capabilities.js +2 -2
  93. package/providers/signa/src/core.js +2 -2
  94. package/providers/uspto-local/src/core.js +1 -1
  95. package/providers/uspto-local/src/sync.js +1 -1
  96. package/scripts/README.md +2 -6
  97. package/scripts/ask-ai-render-check.mjs +26 -1
  98. package/scripts/citation-anchor-report.mjs +1 -1
  99. package/scripts/citation-line-check.mjs +3 -3
  100. package/scripts/dead-names.mjs +17 -16
  101. package/scripts/e2e.mjs +1 -1
  102. package/scripts/env-audit.mjs +7 -1
  103. package/scripts/env-classify.mjs +1 -1
  104. package/scripts/pack-publishable.mjs +1 -1
  105. package/scripts/release-artifact-seal.mjs +2 -2
  106. package/scripts/repo-writes.mjs +42 -0
  107. package/scripts/report-sections-render-check.mjs +245 -0
  108. package/scripts/report-theme-render-check.mjs +31 -3
  109. package/scripts/settings-render-check.mjs +15 -8
  110. package/scripts/strip-tracker-citations.mjs +4 -4
  111. package/scripts/test-run.mjs +108 -6
  112. package/shared/browser-temp-root.mjs +10 -3
  113. package/shared/client-door.mjs +38 -6
  114. package/shared/connect-clients.mjs +2 -0
  115. package/shared/identifier-scan.mjs +2 -2
  116. package/shared/invocation.mjs +1 -1
  117. package/shared/parent-watch.mjs +33 -0
  118. package/shared/reference-guard-classes.mjs +5 -3
  119. package/shared/running-start.mjs +14 -3
  120. package/shared/scope.mjs +22 -3
  121. package/shared/stdio-connect.mjs +39 -18
  122. package/shared/writing-standard-classes.mjs +2 -3
@@ -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
  }
@@ -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
  //
@@ -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.11",
5
+ "version": "0.3.2-beta.13",
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": {
@@ -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
@@ -517,7 +540,7 @@ export async function knockoutInner(ctx, job, opts = {}) {
517
540
  //
518
541
  // ONLY when the counter is the real provider. On fixtures or an injected counter the offices this
519
542
  // deployment can reach are not what the lane is talking to, and splitting on them would defer a
520
- // 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
521
544
  // sibling defect had, and the reason `missing` carries the same guard).
522
545
  const unreachable = countExec.source === "provider" ? registerUnavailableOffices(caps) : [];
523
546
  ctx.registerUnreachable = unreachable;
@@ -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 });
@@ -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);
@@ -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
  }
@@ -831,7 +850,10 @@ export function sectionsOf(html) {
831
850
  if (!nav) return [];
832
851
  const out = [];
833
852
  for (const m of nav[1].matchAll(STRIP_LINK_RE)) {
834
- 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();
835
857
  if (m[1] && label) out.push({ id: m[1], label });
836
858
  }
837
859
  return out;
@@ -179,6 +179,9 @@ import { accessAudience, audienceLabel } from "../shared/access-audience.mjs";
179
179
  import { resolveNumericSetting } from "./numeric-setting.mjs"; // — the same table the engine enforces, without the throw a rendering surface must not take
180
180
  import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-point test, all spellings
181
181
  import { deploymentBox } from "../shared/deployment-box.mjs"; // — the box names itself; one rule
182
+ import { stageNow } from "./progress.mjs"; // the stage a live run is in now, for the card and the row
183
+ // A run in one of these has finished; its step is the one its terminal write set, not the stage it last entered.
184
+ const TERMINAL_STATES = new Set(["delivered", "failed", "cancelled"]);
182
185
 
183
186
  // The offering the portal menus build from — product-rows.mjs's row, unchanged.
184
187
  //
@@ -384,7 +387,7 @@ export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, gener
384
387
  stageLabel: stageLabelOf(meta.searchLevel) ?? meta.stageLabel ?? null,
385
388
  productName: productNameOf(meta.searchLevel),
386
389
  state: "delivered", date: meta.date ?? null,
387
- // WHEN IT FINISHED, to the second (/). `date` is DAY precision and always was — it is
390
+ // WHEN IT FINISHED, to the second. `date` is DAY precision and always was — it is
388
391
  // parsed out of the run directory name. Two reads of one mark delivered on the same day
389
392
  // therefore TIE, Array.prototype.sort is stable, and which one the parent row spoke for was
390
393
  // decided by readdirSync order rather than by recency.
@@ -515,7 +518,11 @@ export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, gener
515
518
  resetsAt: typeof s.resetsAt === "string" ? s.resetsAt : null,
516
519
  // Elapsed, so a card can say how long this has been going without inventing a finish time.
517
520
  startedAt: typeof s.startedAt === "string" ? s.startedAt : null,
518
- step: s.stepLabel ?? null, stepN: s.stepN ?? null, stepTotal: s.stepTotal ?? null,
521
+ // THE STAGE IT IS IN NOW, a step back into an earlier stage included (owner, 2026-09-19): see
522
+ // stageNow. Only while it is live; a run that has finished keeps the step its terminal write set.
523
+ ...(TERMINAL_STATES.has(s.state)
524
+ ? { step: s.stepLabel ?? null, stepN: s.stepN ?? null, stepTotal: s.stepTotal ?? null }
525
+ : stageNow(s)),
519
526
  // — a requested stop is a state the screen shows. Stamped by stop_run,
520
527
  // preserved by writeRunStatus's spread-merge, replaced by the terminal when the honour check
521
528
  // fires. The UI derives "Stopping…" from this beside a non-terminal state.
@@ -867,7 +874,7 @@ export const SERVER_ROUTE_HEADS = Object.freeze(["api", "report", "admin", "heal
867
874
  * `msgId` and `conversationId` sat in this list while `jobFor` hardcoded both to null and the trigger
868
875
  * hop stripped them off again. The totality test beside it could not see that: it asks whether every
869
876
  * declared field is classified, which a declared-and-nulled field satisfies perfectly. So the sentence
870
- * above asserted a behaviour and the test next to it checked a different one — the same shape as 's
877
+ * above asserted a behaviour and the test next to it checked a different one — the same shape as
871
878
  * "an invitation nothing honours", one level up.
872
879
  *
873
880
  * `stamped` exists so the new guard can be DERIVED rather than hand-listed. It splits `carries` into the
@@ -2928,7 +2935,7 @@ async function connectorDoorKind(url) {
2928
2935
  // row, and a retired-endpoint row is a different fact from a not-yours row. If an old open tab is
2929
2936
  // still clicking Flag, that is a thing worth being able to count.
2930
2937
  //
2931
- // WHAT IS DELIBERATELY STILL BELOW THIS LINE: the whole resolver — the two-lane shape, 's
2938
+ // WHAT IS DELIBERATELY STILL BELOW THIS LINE: the whole resolver — the two-lane shape, the
2932
2939
  // disposition read, the server-side-locator rule. The owner ruled "disable services and any code",
2933
2940
  // and the acceptance criterion says re-enabling must be a switch and not a rebuild. Deleting 130
2934
2941
  // lines of hard-won doctrine would have made it a rebuild.
@@ -3759,7 +3766,12 @@ function doctrineStore(now = Date.now) {
3759
3766
  } catch (e) {
3760
3767
  // NOT `{head: null}` alone. A null head with no reason reads as "no store", which is a legal and
3761
3768
  // healthy state (`no-overlay`); this is a different thing and has to say so.
3762
- value = { head: null, situation: "unreadable", outcome: "blocked", detail: String(e?.message ?? e).slice(0, 120) };
3769
+ //
3770
+ // THE ERROR'S OWN WORDS GO TO THE SERVICE LOG, NOT THE WIRE. This answer is served by the health
3771
+ // route, and an error message can carry the store's filesystem path. `unreadable` says what the
3772
+ // caller needs; the operator reads why in the log.
3773
+ console.error(`[portal] the instructions store could not be read: ${String(e?.message ?? e).slice(0, 200)}`);
3774
+ value = { head: null, situation: "unreadable", outcome: "blocked" };
3763
3775
  }
3764
3776
  storeCache = { at: t, value };
3765
3777
  return value;
@@ -3931,7 +3943,9 @@ const escHtml = (t) => String(t).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<"
3931
3943
  // and brand pack §01 fixes dark at #0f0e0c near-black + #f0e8d8 parchment. The pack wins.
3932
3944
  // ── THE PLAIN LINE LEADS; THE ADMINISTRATOR'S TWO LINES STEP BACK INTO A FOLD ────────────────────────
3933
3945
  //
3934
- // A person arriving here needs the field, the button, and one fact: this install signs in one person.
3946
+ // A person arriving here needs the field, the button, and one fact: this install has one user, and which
3947
+ // address that is. The owner's words, 2026-09-19: "Clearotron portal, as <address>." read as "you are
3948
+ // <address>", and a person who found `key issue` first minted a key and could not get in.
3935
3949
  // The reset command and the way to add people are an administrator's business, so they sit in a closed
3936
3950
  // "Administrator help" fold, word for word as they were, with the same link People gives to putting a
3937
3951
  // login system in front. A `<details>` needs no script, which this door must render without.
@@ -3976,9 +3990,7 @@ ${DOOR_THEME_INIT}
3976
3990
  .hint { margin-top:18px; font-size:13px; }
3977
3991
  code { font-family:var(--mono); font-size:12.5px; background:var(--code-bg);
3978
3992
  padding:1px 5px; border-radius:4px; overflow-wrap:anywhere; }
3979
- .lead { margin:18px 0 0; padding-top:14px; border-top:1px solid var(--line); font-size:13.5px; color:var(--ink); }
3980
- .lead b { font-weight:600; }
3981
- .fold { margin-top:10px; }
3993
+ .fold { margin-top:18px; padding-top:14px; border-top:1px solid var(--line); }
3982
3994
  .fold > summary { display:inline-flex; align-items:center; gap:6px; list-style:none; cursor:pointer;
3983
3995
  font-size:13px; color:var(--link); }
3984
3996
  .fold > summary::-webkit-details-marker { display:none; }
@@ -3993,7 +4005,7 @@ ${signedIn
3993
4005
  ? `<p>You are signed in as <span class="who">${escHtml(email)}</span>.</p>
3994
4006
  <div><a href="/portal">Go to the portal</a></div>
3995
4007
  <form method="post" action="/portal/logout"><button type="submit">Sign out</button></form>`
3996
- : `<p>${escHtml(BRAND.name)} portal, as <span class="who">${escHtml(email)}</span>.</p>
4008
+ : `<p>This ${escHtml(BRAND.name)} has one user: <b class="who">${escHtml(email)}</b>. Enter its passphrase.</p>
3997
4009
  ${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>` : ""}
3998
4010
  ${error ? `<p class="err">${escHtml(error)}</p>` : ""}
3999
4011
  <form method="post" action="/portal/login">
@@ -4001,10 +4013,10 @@ ${error ? `<p class="err">${escHtml(error)}</p>` : ""}
4001
4013
  <input id="passphrase" name="passphrase" type="password" autocomplete="new-password" autofocus>
4002
4014
  <button type="submit">Sign in</button>
4003
4015
  </form>
4004
- <p class="lead"><b>This ${escHtml(BRAND.name)} signs in one person: you.</b></p>
4005
4016
  <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>
4006
- <p class="hint">Lost the passphrase? Run <code>${escHtml(reset)}</code> on the machine
4007
- running this portal. It mints a new one and prints it once.</p>
4017
+ <p class="hint">The passphrase was printed once when this ${escHtml(BRAND.name)} first started. Lost it? Run
4018
+ <code>${escHtml(reset)}</code> on the machine running this portal. It prints a new one, once, for the same user.</p>
4019
+ <p class="hint">A key from <code>${escHtml(bareInvocation("key"))} issue</code> is for an AI assistant, not for this page.</p>
4008
4020
  <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>
4009
4021
  </details>`}
4010
4022
  </div></body></html>`;
@@ -30,7 +30,7 @@ import { partyFactSources, partyFactViolations, partyFactMessage, canJudgePartyF
30
30
  import { writeUpViolations, writeUpMessage } from "./narrative-write-ups.mjs"; //
31
31
 
32
32
  import { findRegistryArithmeticIssues, findRegistryViolations, splitBlocks } from "./registry-fidelity.mjs";
33
- import { CLIENT_TIER_BY_COMPOSITE, joinFindingToBlock, parseBlockOrd, worstLiveBand, NO_RATED_CONFLICTS, deriveActionConditions, isUnconditionalProceed, verdictStance, joinAskToAnswer, projectAssessmentField, POSITION_REQUIRED_DISPOSITIONS, OFF_FIELD_GROUNDS, FINDINGS_SCHEMA_VERSION, netChainMarkers, STATEMENT_CLAUSE_MAX } from "./findings-model.mjs";
33
+ import { CLIENT_TIER_BY_COMPOSITE, joinFindingToBlock, parseBlockOrd, worstLiveBand, NO_RATED_CONFLICTS, deriveActionConditions, isUnconditionalProceed, verdictStance, statedConditions, joinAskToAnswer, projectAssessmentField, POSITION_REQUIRED_DISPOSITIONS, OFF_FIELD_GROUNDS, FINDINGS_SCHEMA_VERSION, netChainMarkers, STATEMENT_CLAUSE_MAX } from "./findings-model.mjs";
34
34
  import { normalizeBand } from "./framework.mjs";
35
35
  import { clientConditions, unrenderableConditions, ENGINE_TOKEN_RE } from "./terminal-clamp.mjs"; // the reader's clause per condition, and the token shape it may never carry
36
36
  import { knockoutNoteView, REQUEST_NOTE_WORDS, REQUEST_SUBJECT_WORDS } from "./findings-model.mjs"; // one reader for where a note prints
@@ -87,7 +87,7 @@ const REF_ALLOWLIST = new Set([
87
87
  const TERRITORY_VOCAB = new Set(Object.values(REGION_NAMES).map((n) => norm(n)));
88
88
 
89
89
  function stripHtml(s) {
90
- return String(s ?? "").replace(/<[^>]+>/g, " ").replace(/&amp;/g, "&").replace(/&nbsp;/g, " ");
90
+ return String(s ?? "").replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&");
91
91
  }
92
92
 
93
93
  // "April 2014", "MAY 2026" — dates pass the Title-Case-multi-word shape but are never entities.
@@ -2023,7 +2023,12 @@ export function statementCoherenceChecks({ verdictDoc }) {
2023
2023
  stanceOk ? "" : `verdict.json stance "${verdictDoc.stance}" does not match the verdict ${v || "(missing)"} (expected "${expected ?? "?"}") — the sidecar fields diverged; re-derive it (writeVerdictSidecar composes both from one record)`);
2024
2024
  if (!c3.pass) c3.structural = true;
2025
2025
  out.push(c3);
2026
- const formOk = verdictDoc.stance !== "conditional" || /—\s*conditional on:/.test(st);
2026
+ // The form is owed only where something is stated: a conditional whose every condition was ruled to
2027
+ // the run record carries the band word alone (riskStatement), and a "conditional on:" there would be
2028
+ // the empty line the owner ruled off the page.
2029
+ const stated = statedConditions({ reasons: verdictDoc.reasons, clauses: verdictDoc.clauses });
2030
+ const nothingStated = !stated.cls.length && !stated.conds.length;
2031
+ const formOk = verdictDoc.stance !== "conditional" || nothingStated || /—\s*conditional on:/.test(st);
2027
2032
  const c4 = check("statement-conditional-form", "verdict", "report", formOk,
2028
2033
  formOk ? "" : `a conditional-stance statement must state what conditions reliance ("<Tier> — conditional on: <facts>"), but reads "${st.slice(0, 80)}" — re-derive the sidecar`);
2029
2034
  if (!c4.pass) c4.structural = true;
@@ -2339,7 +2344,7 @@ export function runLint({ depth, commonLawGrid, matterContext, clientPartyName,
2339
2344
  // ── The KNOCKOUT lane's applicable subset (2026-07-31) ──────────────────────────────────────────────
2340
2345
  // The knockout (Stage 0/0.5) lane wrote NO predelivery-lint.json at all, by a decision recorded in
2341
2346
  // docs/DELIVERY.md (2026-07-28). That decision rested on two premises this tranche has since moved:
2342
- // the lint artifact is now the WORKBOOK's QC record (/), so a lane that writes none produces an
2347
+ // the lint artifact is now the WORKBOOK's QC record, so a lane that writes none produces an
2343
2348
  // EMPTY QC record rather than a deliberately-absent one; and the A10 projects recorded defects onto
2344
2349
  // the cover note that reaches the reviewing lawyer, so a knockout defect reached nobody. What has NOT
2345
2350
  // moved is the other half of the memo: this lane's deliverable is store-rendered, and most clearance