clearotron 0.3.2-beta.11 → 0.3.2-beta.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.env.example +1 -1
  2. package/bin/clearotron.mjs +14 -0
  3. package/bin/connect.mjs +68 -3
  4. package/bin/key.mjs +6 -1
  5. package/bin/onboard.mjs +7 -1
  6. package/bin/passphrase.mjs +4 -2
  7. package/bin/start.mjs +12 -3
  8. package/build-info.json +2 -2
  9. package/driver/CHANGELOG.md +16 -0
  10. package/driver/contract-vocabulary.mjs +5 -5
  11. package/driver/engine/mcp/recording-server.mjs +1 -1
  12. package/driver/engine/mcp/supplemental.mjs +22 -5
  13. package/driver/knockout-next-step.mjs +72 -0
  14. package/driver/named-band.mjs +1 -1
  15. package/driver/package.json +1 -1
  16. package/driver/pipeline-knockout.mjs +26 -0
  17. package/driver/portal-report.mjs +21 -2
  18. package/driver/portal-service.mjs +8 -8
  19. package/driver/publish/render-knockout.mjs +14 -9
  20. package/driver/register-availability.mjs +1 -1
  21. package/driver/register-plan.mjs +81 -11
  22. package/driver/result-noun-fields.mjs +3 -1
  23. package/driver/stages-knockout.mjs +1 -1
  24. package/driver/suite-census.json +47 -17
  25. package/driver/verify-knockout.mjs +0 -27
  26. package/mcp-server/CHANGELOG.md +4 -0
  27. package/mcp-server/package.json +1 -1
  28. package/package.json +1 -1
  29. package/portal-ui/dist/assets/{index-CtvwLCti.css → index-7Lq-dXDV.css} +12 -9
  30. package/portal-ui/dist/assets/{index-DXSRxPV_.js → index-w8GFZftk.js} +110 -69
  31. package/portal-ui/dist/index.html +2 -2
  32. package/portal-ui/package.json +1 -1
  33. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  34. package/providers/oauth-mcp-bridge/package.json +1 -1
  35. package/scripts/ask-ai-render-check.mjs +26 -1
  36. package/scripts/dead-names.mjs +17 -16
  37. package/scripts/env-audit.mjs +6 -0
  38. package/scripts/repo-writes.mjs +42 -0
  39. package/scripts/report-sections-render-check.mjs +245 -0
  40. package/scripts/report-theme-render-check.mjs +31 -3
  41. package/scripts/settings-render-check.mjs +10 -7
  42. package/scripts/test-run.mjs +63 -4
  43. package/shared/client-door.mjs +20 -0
  44. package/shared/parent-watch.mjs +33 -0
  45. package/shared/running-start.mjs +14 -3
  46. package/shared/scope.mjs +22 -3
@@ -3931,7 +3931,9 @@ const escHtml = (t) => String(t).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<"
3931
3931
  // and brand pack §01 fixes dark at #0f0e0c near-black + #f0e8d8 parchment. The pack wins.
3932
3932
  // ── THE PLAIN LINE LEADS; THE ADMINISTRATOR'S TWO LINES STEP BACK INTO A FOLD ────────────────────────
3933
3933
  //
3934
- // A person arriving here needs the field, the button, and one fact: this install signs in one person.
3934
+ // A person arriving here needs the field, the button, and one fact: this install has one user, and which
3935
+ // address that is. The owner's words, 2026-09-19: "Clearotron portal, as <address>." read as "you are
3936
+ // <address>", and a person who found `key issue` first minted a key and could not get in.
3935
3937
  // The reset command and the way to add people are an administrator's business, so they sit in a closed
3936
3938
  // "Administrator help" fold, word for word as they were, with the same link People gives to putting a
3937
3939
  // login system in front. A `<details>` needs no script, which this door must render without.
@@ -3976,9 +3978,7 @@ ${DOOR_THEME_INIT}
3976
3978
  .hint { margin-top:18px; font-size:13px; }
3977
3979
  code { font-family:var(--mono); font-size:12.5px; background:var(--code-bg);
3978
3980
  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; }
3981
+ .fold { margin-top:18px; padding-top:14px; border-top:1px solid var(--line); }
3982
3982
  .fold > summary { display:inline-flex; align-items:center; gap:6px; list-style:none; cursor:pointer;
3983
3983
  font-size:13px; color:var(--link); }
3984
3984
  .fold > summary::-webkit-details-marker { display:none; }
@@ -3993,7 +3993,7 @@ ${signedIn
3993
3993
  ? `<p>You are signed in as <span class="who">${escHtml(email)}</span>.</p>
3994
3994
  <div><a href="/portal">Go to the portal</a></div>
3995
3995
  <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>
3996
+ : `<p>This ${escHtml(BRAND.name)} has one user: <b class="who">${escHtml(email)}</b>. Enter its passphrase.</p>
3997
3997
  ${discarded ? `<p class="hint">A session this portal did not start, from another ${escHtml(BRAND.name)} on this address or an expired one, was set aside. Sign in below.</p>` : ""}
3998
3998
  ${error ? `<p class="err">${escHtml(error)}</p>` : ""}
3999
3999
  <form method="post" action="/portal/login">
@@ -4001,10 +4001,10 @@ ${error ? `<p class="err">${escHtml(error)}</p>` : ""}
4001
4001
  <input id="passphrase" name="passphrase" type="password" autocomplete="new-password" autofocus>
4002
4002
  <button type="submit">Sign in</button>
4003
4003
  </form>
4004
- <p class="lead"><b>This ${escHtml(BRAND.name)} signs in one person: you.</b></p>
4005
4004
  <details class="fold"><summary><span>Administrator help</span><svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m9 18 6-6-6-6"/></svg></summary>
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>
4005
+ <p class="hint">The passphrase was printed once when this ${escHtml(BRAND.name)} first started. Lost it? Run
4006
+ <code>${escHtml(reset)}</code> on the machine running this portal. It prints a new one, once, for the same user.</p>
4007
+ <p class="hint">A key from <code>${escHtml(bareInvocation("key"))} issue</code> is for an AI assistant, not for this page.</p>
4008
4008
  <p class="hint">To add people, put it behind a login system such as your company single sign-on. <a href="${escHtml(LOGIN_IN_FRONT_DOC)}" target="_blank" rel="noreferrer">How to set that up</a></p>
4009
4009
  </details>`}
4010
4010
  </div></body></html>`;
@@ -62,6 +62,7 @@ import { EXPORT_TOGGLE, exportPopover, EXPORT_MENU_JS } from './report-topbar.mj
62
62
  // which is the thing providers/_shared/screen.mjs exists to prevent.
63
63
  import { makeClassifyStatus, isAllClass } from '../../providers/_shared/screen.mjs';
64
64
  import { saysSomethingNew } from '../../shared/says-something-new.mjs';
65
+ import { isNextStepHeading } from '../knockout-next-step.mjs';
65
66
 
66
67
  const HERE = dirname(fileURLToPath(import.meta.url));
67
68
 
@@ -1513,21 +1514,25 @@ function koScale(framework, band) {
1513
1514
  // It MOVES, it is not copied: `splitOutcome` returns the assessment without it, and the fold renders
1514
1515
  // that remainder, so the words appear once.
1515
1516
  //
1517
+ // A RECORD WRITTEN SINCE 2026-09-19 NEVER REACHES THIS WITH ONE: the pipeline removes a next-step section
1518
+ // before it writes the record (`knockout-next-step.mjs`), and the heading test below is that module's, so
1519
+ // the two cannot come to disagree about what a next step is. What still draws the section is a record
1520
+ // written before then, re-rendered.
1521
+ //
1516
1522
  // THE HEADING IS THE MODEL'S OWN, so this hoists only the LAST block of the assessment. The assessing
1517
- // skill fixes no heading vocabulary — it asks for sub-headers "where the content divides" and for the
1518
- // read to close on what to do with the name, and the wording is written fresh each run. Matching a list
1519
- // of phrases anywhere in the document would let a mark whose read happens to argue under "Recommendation"
1520
- // in the MIDDLE have that middle lifted to the bottom, handing the client the engine's argument in an
1521
- // order the engine did not write, with nothing going red. A trailing block is the one position where a
1522
- // move cannot reorder what is left. So the phrase list only decides whether the last block is the
1523
- // outcome; anything earlier stays where it was written, and the page renders the assessment whole.
1524
- const OUTCOME_HEADING_RE = /^#{1,6}\s*(?:what to do with it|what happens next|next steps?|recommendations?)\b\s*$/i;
1523
+ // skill fixes no heading vocabulary — it asks for sub-headers "where the content divides", and the
1524
+ // wording is written fresh each run. Matching a list of phrases anywhere in the document would let a
1525
+ // mark whose read happens to argue under "Recommendation" in the MIDDLE have that middle lifted to the
1526
+ // bottom, handing the client the engine's argument in an order the engine did not write, with nothing
1527
+ // going red. A trailing block is the one position where a move cannot reorder what is left. So the
1528
+ // phrase list only decides whether the last block is the outcome; anything earlier stays where it was
1529
+ // written, and the page renders the assessment whole.
1525
1530
  export function splitOutcome(assessment) {
1526
1531
  const whole = String(assessment ?? '').trim();
1527
1532
  const lines = whole.split('\n');
1528
1533
  const heads = lines.map((l, i) => [l.trim(), i]).filter(([l]) => /^#{1,6}\s/.test(l));
1529
1534
  const last = heads[heads.length - 1];
1530
- if (!last || !OUTCOME_HEADING_RE.test(last[0])) return { body: whole, outcome: '' };
1535
+ if (!last || !isNextStepHeading(last[0])) return { body: whole, outcome: '' };
1531
1536
  const outcome = lines.slice(last[1] + 1).join('\n').trim();
1532
1537
  if (!outcome) return { body: whole, outcome: '' };
1533
1538
  return { body: lines.slice(0, last[1]).join('\n').trim(), outcome };
@@ -41,7 +41,7 @@
41
41
  // CORRECTED 2026-08-11 — THIS PARAGRAPH USED TO SAY "one qid per office … which is the shape
42
42
  // joinPlanToBands already has", AND THAT WAS FALSE ABOUT THE COMPILER IT DOCUMENTS. There is no
43
43
  // per-office qid. compileRegisterPlan narrows ONE shared `regions` array and hands it to every entry
44
- // (register-plan.mjs:779 compileRegisterPlan); the unreachable office produces no entry, so no qid, so no band block, so
44
+ // (register-plan.mjs:805 compileRegisterPlan); the unreachable office produces no entry, so no qid, so no band block, so
45
45
  // nothing ever reaches joinPlanToBands' deferred bucket — whose only source is a block stamped
46
46
  // `error:true && deferred:true` (register-plan.mjs:1317 extendRegisterPlan).
47
47
  //
@@ -705,12 +705,7 @@ export function excludeHouseElement(manifest, house) {
705
705
 
706
706
  const lcEl = element.toLowerCase();
707
707
  const lcWords = words.map((w) => w.toLowerCase());
708
- // A word of the remainder, by word boundary rather than by containment: `includes` would count the
709
- // remainder word "on" inside an unrelated mutation and keep a query this exists to drop.
710
- const namesRemainder = (v) => {
711
- const t = String(v ?? "").toLowerCase();
712
- return lcWords.some((w) => new RegExp(`(^|[^\\p{L}\\p{N}])${w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^\\p{L}\\p{N}]|$)`, "u").test(t));
713
- };
708
+ const namesRemainder = (v) => namesAnyWord(v, lcWords);
714
709
 
715
710
  // THE DOMINANT WORD OF THE REMAINDER: longest, ties broken by the LAST of them. The lawyer's own
716
711
  // phrasing was "the last word alone", and on the matter that produced this the two agree; longest is
@@ -738,11 +733,75 @@ export function excludeHouseElement(manifest, house) {
738
733
  // REQUIREMENT 3: the element is still checked ONCE, as a confirmation of the client's own live
739
734
  // registrations rather than as a conflict search — so the exclusion is evidenced on the report by a
740
735
  // query that ran, not by a sentence saying one would have.
736
+ // The remainder rides on it so a writer AFTER the compile can apply the same rule (`houseElementOf`).
741
737
  const confirmation = { axis: "incumbent-class", predicate: "owner", term: element,
742
- expected_kind: "enumerate", provenance: "mark", house_element_confirmation: true };
738
+ expected_kind: "enumerate", provenance: "mark", house_element_confirmation: true,
739
+ house_element_remainder: remainder };
743
740
  return { manifest: next, confirmation, refused: null };
744
741
  }
745
742
 
743
+ /**
744
+ * Does `text` carry any of `words` (lower-case) as a word of its own? By word boundary rather than by
745
+ * containment: `includes` would count the remainder word "on" inside an unrelated mutation.
746
+ */
747
+ function namesAnyWord(text, words) {
748
+ const t = String(text ?? "").toLowerCase();
749
+ return words.some((w) => new RegExp(`(^|[^\\p{L}\\p{N}])${w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^\\p{L}\\p{N}]|$)`, "u").test(t));
750
+ }
751
+
752
+ /**
753
+ * The house element a plan was compiled without, read off the plan itself, or null.
754
+ *
755
+ * WHY THE PLAN CARRIES IT. The compile is not the only writer. After it, the common-law cross-check,
756
+ * the recall lane, the frame reopen and the jurisdiction candidates fold entries in, and the register
757
+ * session proposes its own. On the first live run that proposed an exclusion (2026-09-19) the compile
758
+ * dropped the element and two later writers put it straight back: a contains query from the cross-check
759
+ * and an exact query the register session proposed, both on the bare element. Those writers hold the
760
+ * plan and nothing else, so the plan is where the exclusion has to be readable. Its confirmation row
761
+ * names the element, and the remainder rides beside it because the rule needs both.
762
+ */
763
+ export function houseElementOf(plan) {
764
+ const c = (plan?.entries ?? []).find((e) => e?.house_element_confirmation === true);
765
+ const element = String(c?.term ?? "").trim();
766
+ if (!element) return null;
767
+ const remainder = String(c?.house_element_remainder ?? "").trim().toLowerCase();
768
+ return { element, remainderWords: remainder.split(/\s+/).filter(Boolean) };
769
+ }
770
+
771
+ /**
772
+ * Is this term a search on the excluded house element? The compile's own rule (`excludeHouseElement`
773
+ * keeps a variant only when it names a word of the remainder), applied to a term that arrives after
774
+ * the compile: a term carrying the element and no word of the remainder is a search on the element,
775
+ * whether it is the bare element, the element with a suffix, or the element beside other words. A term
776
+ * carrying a word of the remainder, like the whole mark, is kept, as the compile keeps it.
777
+ *
778
+ * A plan frozen before the remainder was recorded knows the element only, and then only the bare
779
+ * element counts: "carries no word of the remainder" cannot be decided without the remainder.
780
+ */
781
+ export function isHouseElementTerm(term, house) {
782
+ if (!house) return false;
783
+ const t = String(term ?? "").trim().toLowerCase();
784
+ const el = house.element.toLowerCase();
785
+ if (t === el || (norm(el) !== "" && norm(t) === norm(el) && formKey(t) === formKey(el))) return true;
786
+ if (!house.remainderWords.length) return false;
787
+ const carries = t.includes(el) || (norm(el) !== "" && norm(t).includes(norm(el)));
788
+ return carries && !namesAnyWord(t, house.remainderWords);
789
+ }
790
+
791
+ /**
792
+ * The entry without its house-element terms, or null when none is left. An owner sweep is not mark
793
+ * text and comes back untouched: the one owner query on the element is the confirmation itself. PURE.
794
+ */
795
+ export function withoutHouseElementTerms(entry, house) {
796
+ if (!house || !entry || entry.predicate === "owner") return entry;
797
+ if (Array.isArray(entry.terms)) {
798
+ const kept = entry.terms.filter((t) => !isHouseElementTerm(t, house));
799
+ if (!kept.length) return null;
800
+ return kept.length === entry.terms.length ? entry : { ...entry, terms: kept };
801
+ }
802
+ return isHouseElementTerm(entry.term, house) ? null : entry;
803
+ }
804
+
746
805
  export function compileRegisterPlan({ manifest, job, form = null, skillVersion = "", capabilities = null, unavailableOffices = [], houseElement = null }) {
747
806
  // ── AN EXCLUDED ELEMENT'S FORM BAND MUST BE UNREACHABLE, NOT MERELY UNASKED-FOR ─────────────────
748
807
  //
@@ -1495,10 +1554,13 @@ export function entryQuestionKey(entry, plan) {
1495
1554
  * an absence, and an absence reported as success is the failure this whole issue is about. Every
1496
1555
  * caller writes them somewhere a reader will meet them.
1497
1556
  *
1498
- * Returns { plan, added: [qid], refused: [{qid, term, issue}] }.
1557
+ * Returns { plan, added: [qid], refused: [{qid, term, issue}], excluded: [qid] } — `excluded` are rows on
1558
+ * the house element the compile set aside, left out quietly (see houseElementOf).
1499
1559
  */
1500
1560
  export function foldSupplementalEntries(plan, entries) {
1501
1561
  const have = new Set(plan.entries.map((e) => e.qid));
1562
+ const house = houseElementOf(plan);
1563
+ const excluded = [];
1502
1564
  // — first qid wins, so the refusal names the row already IN the plan rather than a later
1503
1565
  // arrival. The compiler's own rows are in here too: a supplemental re-proposal of a compiler slice
1504
1566
  // is the case that actually fired on the round in evidence.
@@ -1530,8 +1592,9 @@ export function foldSupplementalEntries(plan, entries) {
1530
1592
  // one identity, which is the fault. Keyed on the qid alone this refused the harmless case too, and
1531
1593
  // the suite's own arm said so.
1532
1594
  const inBatch = new Map(); // qid -> the term that claimed it
1533
- for (const e of entries ?? []) {
1534
- if (!e || typeof e.qid !== "string" || !e.qid) continue;
1595
+ for (const offered of entries ?? []) {
1596
+ if (!offered || typeof offered.qid !== "string" || !offered.qid) continue;
1597
+ let e = offered;
1535
1598
  const eTerm = String(e.term ?? e.terms?.[0] ?? "");
1536
1599
  if (inBatch.has(e.qid) && inBatch.get(e.qid) === eTerm) continue; // the same row twice — one refusal
1537
1600
  if (inBatch.has(e.qid)) {
@@ -1546,6 +1609,12 @@ export function foldSupplementalEntries(plan, entries) {
1546
1609
  inBatch.set(e.qid, eTerm);
1547
1610
  if (have.has(e.qid)) continue; // already in the PLAN — a re-proposal, expected and quiet
1548
1611
  have.add(e.qid); // a refused qid is claimed too — the same row twice is one refusal
1612
+ // THE CLIENT'S OWN ELEMENT, SET ASIDE BY THE COMPILE, IS NOT FOLDED BACK IN (see houseElementOf).
1613
+ // QUIET, NOT REFUSED: a refused row renders to the client as an OPEN ask, and this is no unanswered
1614
+ // question. The plan's confirmation row answers it and the report states the exclusion.
1615
+ const kept = withoutHouseElementTerms(e, house);
1616
+ if (!kept) { excluded.push(e.qid); continue; }
1617
+ e = kept;
1549
1618
  const issues = entryTermIssues(e);
1550
1619
  if (issues.length) {
1551
1620
  refused.push({ qid: e.qid, term: String(e.term ?? e.terms?.[0] ?? ""),
@@ -1570,11 +1639,12 @@ export function foldSupplementalEntries(plan, entries) {
1570
1639
  if (key && e.unsupported !== true) asked.set(key, e.qid); // dedupes the batch too, on the question
1571
1640
  added.push(e);
1572
1641
  }
1573
- if (!added.length) return { plan, added: [], refused };
1642
+ if (!added.length) return { plan, added: [], refused, excluded };
1574
1643
  return {
1575
1644
  plan: { ...plan, plan_version: (plan.plan_version ?? 1) + 1, entries: [...plan.entries, ...added] },
1576
1645
  added: added.map((e) => e.qid),
1577
1646
  refused,
1647
+ excluded,
1578
1648
  };
1579
1649
  }
1580
1650
 
@@ -95,7 +95,9 @@ export const RESULT_NOUN_FIELDS = Object.freeze([
95
95
  // writing site like the other two — it is a returned CLASSIFICATION of a checkout, not a field named
96
96
  // for a result whose value is what a call returned, so the verdict is unchanged.
97
97
  { file: "driver/engine-build.mjs", noun: "outcome", sites: 3, atWriteSite: 0, verdict: "out-of-scope" },
98
- { file: "driver/engine/mcp/supplemental.mjs", noun: "executed", sites: 2, atWriteSite: 0, verdict: "out-of-scope" },
98
+ // Read at the writing site: the third is the reply when every proposal was the house element the
99
+ // compile set aside — `executed: false` because nothing ran, the same statement as the error reply.
100
+ { file: "driver/engine/mcp/supplemental.mjs", noun: "executed", sites: 3, atWriteSite: 0, verdict: "out-of-scope" },
99
101
  { file: "driver/outbox-backoff.mjs", noun: "outcome", sites: 5, atWriteSite: 0, verdict: "out-of-scope" },
100
102
  { file: "driver/portal-service.mjs", noun: "outcome", sites: 2, atWriteSite: 0, verdict: "out-of-scope" },
101
103
  { file: "driver/remedy-accounting.mjs", noun: "executed", sites: 1, atWriteSite: 0, verdict: "out-of-scope" },
@@ -418,7 +418,7 @@ export const KO_STAGES = {
418
418
  // The depth rule is not stylistic. `# ` opens a SECTION of the delivered report — parseSections
419
419
  // splits on /^# /m and batchSummaryOf terminates on it — so a single hash written here ends the
420
420
  // client's summary at that line and drops everything after it, silently, on the entry point.
421
- `EVERY mark ALSO carries "assessment": the opening read a reader of THIS MARK'S OWN report meets first, before any table. Write it for a client who ordered this one name and nothing else: what the name is, what the landscape around it looks like, what drives the rating, and what a reader should do with that.`,
421
+ `EVERY mark ALSO carries "assessment": the opening read a reader of THIS MARK'S OWN report meets first, before any table. Write it for a client who ordered this one name and nothing else: what the name is, what the landscape around it looks like, what drives the rating.`,
422
422
  `GIVE IT STRUCTURE A READER CAN SCAN — that is what changed. Sub-headers where the content divides, "- " bullets for the load-bearing points, a blank line between blocks. A sub-header is "## " or "### ". NEVER "# ": one hash opens a section of the report itself, and a summary that opens a section ends there and takes the rest of itself off the client's page. A single sub-header over one unbroken block is not structure — the blocks are what make the length readable, and unstructured length is the complaint this replaces.`,
423
423
  `KEEP THE LENGTH. It is still the most useful part of a triage and it is still not rationed. What it now has to be is CONSISTENT: this mark's opening read is the scale of a single-mark report's opening read, and the marks of one batch sit within reach of each other. One assessment several times the size of its siblings is out of family. No word count and none is coming — judge it against the other marks in front of you.`,
424
424
  `SYNTHESISE THE SOURCES, DO NOT SHELVE THEM SEPARATELY. Where filings were handed to you for this mark, weigh them in the SAME passage as the marketplace and common-law reading — what the screened field shows, what those filings do to that reading, and what the two together mean for the band. A reader must not be handed one block per source and left to reconcile them.`,
@@ -279,6 +279,12 @@
279
279
  "skips": 0,
280
280
  "todos": 0
281
281
  },
282
+ "a-demo-stops-when-whatever-started-it-is-gone.test.mjs": {
283
+ "tests": 3,
284
+ "asserts": 11,
285
+ "skips": 0,
286
+ "todos": 0
287
+ },
282
288
  "a-depth-check-that-read-nothing-is-on-the-investigate-list.test.mjs": {
283
289
  "tests": 3,
284
290
  "asserts": 8,
@@ -555,6 +561,12 @@
555
561
  "skips": 1,
556
562
  "todos": 0
557
563
  },
564
+ "a-next-step-the-model-writes-comes-off-at-publish.test.mjs": {
565
+ "tests": 12,
566
+ "asserts": 28,
567
+ "skips": 0,
568
+ "todos": 0
569
+ },
558
570
  "a-note-the-release-refuses-is-refused-on-its-own-pull-request.test.mjs": {
559
571
  "tests": 2,
560
572
  "asserts": 3,
@@ -837,6 +849,12 @@
837
849
  "skips": 0,
838
850
  "todos": 0
839
851
  },
852
+ "a-running-demo-connects-by-its-folder.test.mjs": {
853
+ "tests": 4,
854
+ "asserts": 18,
855
+ "skips": 0,
856
+ "todos": 0
857
+ },
840
858
  "a-save-that-cannot-commit-says-so.test.mjs": {
841
859
  "tests": 4,
842
860
  "asserts": 19,
@@ -1827,6 +1845,12 @@
1827
1845
  "skips": 0,
1828
1846
  "todos": 0
1829
1847
  },
1848
+ "dead-names.test.mjs": {
1849
+ "tests": 5,
1850
+ "asserts": 8,
1851
+ "skips": 2,
1852
+ "todos": 0
1853
+ },
1830
1854
  "declared-park-excuses-proof-not-judgment.test.mjs": {
1831
1855
  "tests": 9,
1832
1856
  "asserts": 34,
@@ -2062,8 +2086,8 @@
2062
2086
  "todos": 0
2063
2087
  },
2064
2088
  "doctor-refuses-what-cannot-run.test.mjs": {
2065
- "tests": 8,
2066
- "asserts": 20,
2089
+ "tests": 9,
2090
+ "asserts": 22,
2067
2091
  "skips": 0,
2068
2092
  "todos": 0
2069
2093
  },
@@ -2812,8 +2836,8 @@
2812
2836
  "todos": 0
2813
2837
  },
2814
2838
  "knockout-structured-read.test.mjs": {
2815
- "tests": 14,
2816
- "asserts": 63,
2839
+ "tests": 12,
2840
+ "asserts": 57,
2817
2841
  "skips": 0,
2818
2842
  "todos": 0
2819
2843
  },
@@ -3004,8 +3028,8 @@
3004
3028
  "todos": 0
3005
3029
  },
3006
3030
  "no-test-writes-inside-the-checkout.test.mjs": {
3007
- "tests": 17,
3008
- "asserts": 38,
3031
+ "tests": 20,
3032
+ "asserts": 52,
3009
3033
  "skips": 0,
3010
3034
  "todos": 0
3011
3035
  },
@@ -3305,7 +3329,7 @@
3305
3329
  },
3306
3330
  "portal-local-login.test.mjs": {
3307
3331
  "tests": 28,
3308
- "asserts": 111,
3332
+ "asserts": 113,
3309
3333
  "skips": 0,
3310
3334
  "todos": 0
3311
3335
  },
@@ -3862,8 +3886,8 @@
3862
3886
  "todos": 0
3863
3887
  },
3864
3888
  "render.test.mjs": {
3865
- "tests": 161,
3866
- "asserts": 670,
3889
+ "tests": 162,
3890
+ "asserts": 675,
3867
3891
  "skips": 0,
3868
3892
  "todos": 0
3869
3893
  },
@@ -4486,8 +4510,8 @@
4486
4510
  "todos": 0
4487
4511
  },
4488
4512
  "status-and-stop-see-a-foreground-start.test.mjs": {
4489
- "tests": 6,
4490
- "asserts": 25,
4513
+ "tests": 7,
4514
+ "asserts": 30,
4491
4515
  "skips": 0,
4492
4516
  "todos": 0
4493
4517
  },
@@ -4959,6 +4983,12 @@
4959
4983
  "skips": 0,
4960
4984
  "todos": 0
4961
4985
  },
4986
+ "the-house-element-stays-out-of-what-folds-in-later.test.mjs": {
4987
+ "tests": 6,
4988
+ "asserts": 22,
4989
+ "skips": 0,
4990
+ "todos": 0
4991
+ },
4962
4992
  "the-identifier-sweep-fires-on-its-sentinels.test.mjs": {
4963
4993
  "tests": 10,
4964
4994
  "asserts": 36,
@@ -5327,7 +5357,7 @@
5327
5357
  },
5328
5358
  "the-sign-in-door-folds-its-administrator-help.test.mjs": {
5329
5359
  "tests": 5,
5330
- "asserts": 21,
5360
+ "asserts": 23,
5331
5361
  "skips": 0,
5332
5362
  "todos": 0
5333
5363
  },
@@ -5344,8 +5374,8 @@
5344
5374
  "todos": 0
5345
5375
  },
5346
5376
  "the-summary-carries-structure.test.mjs": {
5347
- "tests": 13,
5348
- "asserts": 35,
5377
+ "tests": 14,
5378
+ "asserts": 37,
5349
5379
  "skips": 0,
5350
5380
  "todos": 0
5351
5381
  },
@@ -6034,7 +6064,7 @@
6034
6064
  },
6035
6065
  "root-mcp-json-connects.test.mjs": {
6036
6066
  "tests": 3,
6037
- "asserts": 13,
6067
+ "asserts": 14,
6038
6068
  "skips": 0,
6039
6069
  "todos": 0
6040
6070
  },
@@ -6446,8 +6476,8 @@
6446
6476
  "todos": 0
6447
6477
  },
6448
6478
  "reportSections.test.ts": {
6449
- "tests": 9,
6450
- "asserts": 27,
6479
+ "tests": 13,
6480
+ "asserts": 41,
6451
6481
  "skips": 0,
6452
6482
  "todos": 0
6453
6483
  },
@@ -29,25 +29,6 @@ const nameKey = (s) => norm(s).toLowerCase().replace(/\s+/g, " ");
29
29
 
30
30
  // ── Tone guards (the skill's rules, mechanical subset) ───────────────────────────────────────────────
31
31
  export const BANNED_TONE_RE = /\b(extremely difficult|most dangerous|massive|enormous)\b/i;
32
-
33
- /**
34
- * A RECOMMENDATION IS A SHAPE, AND THIS SCREEN MAY NOT WRITE ONE (owner, 2026-09-18).
35
- *
36
- * The screen states findings and a rating; what to do with the name is the reading lawyer's. Two
37
- * instructions that asked for a conclusion were cut first, and a re-run then wrote one anyway, under a
38
- * heading of its own: "Practical next step — <NAME> is not knocked out at this screening depth. Advance
39
- * it to clearance…". Cutting an instruction stops the engine asking; only a refusal stops the offer.
40
- *
41
- * TWO SHAPES, because both reached a reader: a block the writer heads itself, and a closing line that
42
- * moves the name on. WHAT THIS CANNOT SEE: the same conclusion as ordinary prose — "the remaining work
43
- * here is a full search" — and no pattern can. It is a floor under the doctrine, never a substitute.
44
- *
45
- * The false positives were measured rather than assumed: the degraded note this lane REQUIRES reads
46
- * "Manual verification recommended" and matches neither, and a register record "proceeding to
47
- * registration" matches neither — only proceeding *to clearance* does.
48
- */
49
- export const RECOMMENDATION_RE = /^[ \t]*(?:[*_]{0,2}|#{1,6}\s*)(?:practical\s+|immediate\s+|suggested\s+)?(?:next steps?|what to do\b|what happens next|recommendations?|our recommendation)/im;
50
- export const ADVANCE_RE = /\b(?:advance|move|take|send)\s+(?:it|this|the name|[A-Z][A-Z0-9'’-]{2,})\s+(?:on\s+)?(?:to|into|through)\s+(?:a\s+)?(?:full\s+)?clearance\b|\bproceeds?\s+to\s+(?:a\s+)?(?:full\s+)?clearance\b|\bshould\s+(?:proceed|advance|be advanced|move)\b|\bwe\s+recommend\b/i;
51
32
  export const QUANT_CLAIM_RE = /(\b\d[\d,.]*\s*(?:M|million|billion|k|thousand)?\s*(?:streams|downloads|copies sold|units sold)\b)|(\$\s?\d[\d,.]*\s*(?:M|million|billion|k)?\s*(?:in\s+)?(?:annual\s+)?(?:revenue|sales))/i;
52
33
 
53
34
  // ── URL normalization for the receipts gate ──────────────────────────────────────────────────────────
@@ -505,14 +486,6 @@ export const validators = {
505
486
  const claim = [prose, c.chunkSummary].filter(Boolean).join("\n").match(REGISTER_CLAIM_RE);
506
487
  if (claim)
507
488
  return { ok: false, reason: `knockout_register_claim:${m.name}: "${claim[0].trim()}" — this turn cannot see the register lane and must not describe it. The report states register coverage in code, from the run's own count sidecar; a summary that says the registers were not run, above a table of counts that were, is the contradiction this rule closes. Delete the clause` };
508
- // THE PER-NAME READ IS SWEPT TOO, and until now it was not: the checks below read the finding
509
- // fields, while `assessment` was validated for length and structure alone — which is exactly the
510
- // field the conclusion arrived in. Refused HERE so the stage re-asks with the line quoted, rather
511
- // than a reader meeting it.
512
- const read = [m.assessment, prose].filter(Boolean).join("\n");
513
- const recommended = read.match(RECOMMENDATION_RE) ?? read.match(ADVANCE_RE);
514
- if (recommended)
515
- return { ok: false, reason: `mark "${m.name}": "${recommended[0].trim()}" — this screen states findings and a rating; what to do with the name, and whether it advances, is the reading lawyer's. Delete the sentence: the report's own closing caveat already says what the screen is` };
516
489
  const banned = prose.match(BANNED_TONE_RE);
517
490
  if (banned) return { ok: false, reason: `mark "${m.name}": banned tone "${banned[0]}" — measured tone only (the band colour carries urgency)` };
518
491
  const quant = prose.match(QUANT_CLAIM_RE);
@@ -1,5 +1,9 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.3.2-beta.12
4
+
5
+ No changes in this release.
6
+
3
7
  ## 0.3.2-beta.11
4
8
 
5
9
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.3.2-beta.11",
3
+ "version": "0.3.2-beta.12",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clearotron",
3
3
  "type": "module",
4
- "version": "0.3.2-beta.11",
4
+ "version": "0.3.2-beta.12",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",
@@ -254,19 +254,19 @@ html.anon-on [data-anon='mark']:not(.anon-clear) {
254
254
  border-bottom: 1px solid var(--border-hairline);
255
255
  }
256
256
 
257
- /* THE HEADER'S SECOND ROW: which read you are in, then where you are inside it.
257
+ /* THE HEADER'S LOWER ROWS: which read you are in, then where you are inside it.
258
258
  The Reads strip used to sit BELOW this box and the report's own section breadcrumb inside the frame,
259
259
  so neither stayed on screen — the frame is sized to its content, which leaves a `position:sticky` bar
260
260
  in there pinned to nothing. Both are rows of the pinned header now (owner, 2026-09-18).
261
- It scrolls sideways rather than wrapping to a third line: a header that grows taller with the number of
262
- sections is a header that eats the document it is heading. */
263
- .report-nav {
264
- display: flex; align-items: center; gap: 10px; margin-top: 9px;
261
+ THE SECTIONS HAVE A ROW OF THEIR OWN, UNDER THE READS (owner, 2026-09-19). Side by side, sixteen reads
262
+ wrapped into two ragged columns while the strip floated beside them at mid-height. The reads wrap; the
263
+ sections scroll sideways rather than wrapping, so the strip stays one line however narrow the screen. */
264
+ .report-nav { display: flex; flex-direction: column; align-items: stretch; gap: 8px; margin-top: 9px; }
265
+ .report-sections {
266
+ display: flex; align-items: center; gap: 2px;
265
267
  overflow-x: auto; scrollbar-width: none;
266
268
  }
267
- .report-nav::-webkit-scrollbar { display: none; }
268
- .report-nav-sep { flex: none; width: 1px; align-self: stretch; background: var(--border-hairline); }
269
- .report-sections { display: flex; align-items: center; gap: 2px; }
269
+ .report-sections::-webkit-scrollbar { display: none; }
270
270
  .report-section {
271
271
  flex: none; display: inline-flex; align-items: center; gap: 7px; white-space: nowrap;
272
272
  font: inherit; font-size: 12.5px; font-weight: 600; letter-spacing: .01em;
@@ -277,8 +277,11 @@ html.anon-on [data-anon='mark']:not(.anon-clear) {
277
277
  .report-section-dot {
278
278
  width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid currentColor; box-sizing: border-box;
279
279
  }
280
+ /* PROGRESS: every section reached has a filled dot, the rest stay hollow; the one in view also sits on
281
+ the sunken pill, so it can be told from the ones behind it. */
282
+ .report-section[data-reached='true'] { color: var(--text-strong); }
283
+ .report-section[data-reached='true'] .report-section-dot { background: var(--accent); border-color: var(--accent); }
280
284
  .report-section[aria-current='true'] { color: var(--text-strong); background: var(--surface-sunken); }
281
- .report-section[aria-current='true'] .report-section-dot { border-color: var(--accent); border-width: 3px; }
282
285
 
283
286
  /* The breadcrumb above it: one step back as a quiet button, then where the reader is. Compound selectors
284
287
  throughout this block, because the button and row rules they adjust are declared later in the file. */