clearotron 0.4.1 → 0.5.0-beta.0

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 (90) hide show
  1. package/README.md +1 -0
  2. package/THIRD-PARTY-NOTICES.md +1 -1
  3. package/bin/start.mjs +33 -8
  4. package/build-info.json +2 -2
  5. package/driver/CHANGELOG.md +23 -0
  6. package/driver/ask-ledger.mjs +86 -15
  7. package/driver/band-shape.mjs +61 -11
  8. package/driver/band-size.mjs +10 -0
  9. package/driver/close-verify.mjs +29 -2
  10. package/driver/common-law-coverage-status.mjs +1 -1
  11. package/driver/connotation-search.mjs +7 -9
  12. package/driver/contract-e3-backlog.mjs +34 -34
  13. package/driver/contract-e3-baseline.json +5 -5
  14. package/driver/contract-vocabulary.mjs +12 -12
  15. package/driver/corrections-delivered.mjs +113 -0
  16. package/driver/corrections-feedforward.mjs +16 -6
  17. package/driver/corrective-arm.mjs +11 -8
  18. package/driver/coverage-form.mjs +3 -0
  19. package/driver/coverage-union.mjs +11 -2
  20. package/driver/digest-queue.mjs +1 -1
  21. package/driver/disposition-call.mjs +1 -1
  22. package/driver/document-coverage.mjs +3 -2
  23. package/driver/effective-scope.mjs +2 -2
  24. package/driver/engine/mcp/band-server.mjs +77 -13
  25. package/driver/engine/mcp/dispositions-server.mjs +2 -2
  26. package/driver/engine/mcp/recording-server.mjs +10 -6
  27. package/driver/findings-model.mjs +16 -4
  28. package/driver/floor-duty.mjs +9 -0
  29. package/driver/gate-metrics.mjs +7 -1
  30. package/driver/gateway.mjs +28 -14
  31. package/driver/goods-evidence.mjs +295 -0
  32. package/driver/knockout-assess-record.mjs +1 -1
  33. package/driver/narrative-refutation-record.mjs +40 -2
  34. package/driver/package.json +1 -1
  35. package/driver/pipeline.mjs +1945 -314
  36. package/driver/placement-decisions.mjs +243 -0
  37. package/driver/placement-form.mjs +42 -8
  38. package/driver/placement-union.mjs +14 -6
  39. package/driver/provider-usage.mjs +10 -7
  40. package/driver/publish/audit-from-spine.mjs +5 -2
  41. package/driver/publish/index.mjs +19 -4
  42. package/driver/publish/xlsx.mjs +6 -6
  43. package/driver/recall-reconciliation.mjs +15 -5
  44. package/driver/record-carry.mjs +70 -6
  45. package/driver/record-discard.mjs +7 -1
  46. package/driver/record-fold.mjs +14 -0
  47. package/driver/record-goods.mjs +101 -5
  48. package/driver/register-plan.mjs +89 -0
  49. package/driver/registry-fidelity.mjs +34 -3
  50. package/driver/remedy-accounting.mjs +52 -14
  51. package/driver/repair-composers.mjs +265 -105
  52. package/driver/repair-scope.mjs +327 -0
  53. package/driver/repair-targets.mjs +92 -0
  54. package/driver/result-noun-fields.mjs +3 -3
  55. package/driver/screen-gate.mjs +96 -0
  56. package/driver/skills/clearance-common-law/SKILL.md +8 -0
  57. package/driver/skills/clearance-register/SKILL.md +5 -5
  58. package/driver/skills/clearance-register/digest.md +14 -14
  59. package/driver/skills/clearance-register/unit.md +3 -3
  60. package/driver/skills/clearance-search/delivery-contract.md +2 -58
  61. package/driver/skills/clearance-search/report-prose.md +12 -3
  62. package/driver/skills/clearance-search/risk-framework-triage.md +1 -1
  63. package/driver/skills/clearance-search/synthesis-rules.md +10 -13
  64. package/driver/skills/clearance-variants/SKILL.md +4 -4
  65. package/driver/skills/knockout-assess/SKILL.md +9 -14
  66. package/driver/skills/knockout-frame/SKILL.md +5 -5
  67. package/driver/skills/narrative-refutation/SKILL.md +1 -1
  68. package/driver/skills/placement-inquiry/SKILL.md +1 -1
  69. package/driver/skills/whatif-memo/SKILL.md +1 -1
  70. package/driver/stage-context.mjs +2 -1
  71. package/driver/stages-knockout.mjs +1 -1
  72. package/driver/stages.mjs +95 -60
  73. package/driver/suite-census.json +367 -19
  74. package/driver/synthesis-record.mjs +310 -8
  75. package/driver/terminal-clamp.mjs +53 -2
  76. package/driver/verify-knockout.mjs +22 -4
  77. package/driver/verify.mjs +23 -3
  78. package/mcp-server/CHANGELOG.md +4 -0
  79. package/mcp-server/lib/driver.mjs +5 -0
  80. package/mcp-server/lib/events.mjs +25 -4
  81. package/mcp-server/lib/findings.mjs +4 -2
  82. package/mcp-server/package.json +1 -1
  83. package/package.json +1 -1
  84. package/portal-ui/package.json +1 -1
  85. package/providers/jx/src/core.js +3 -4
  86. package/providers/jx/src/judge.js +2 -2
  87. package/providers/jx/src/nativeread.js +1 -2
  88. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  89. package/providers/oauth-mcp-bridge/package.json +1 -1
  90. package/scripts/e2e-first-time.mjs +29 -12
@@ -42,17 +42,18 @@
42
42
  // prose, so the same check had nothing to run on. They are typed now, so it runs: a `confirmed-clean`
43
43
  // row whose area does not join the plan-execution receipt is refused AT THE CALL, in the turn where
44
44
  // restating it is free, rather than at a gate whose only repair is a forced re-ask of the whole stage.
45
- import { writeFileSync, appendFileSync, mkdirSync, readFileSync, existsSync } from "node:fs";
45
+ import { writeFileSync, appendFileSync, mkdirSync, readFileSync, existsSync, renameSync, rmSync } from "node:fs";
46
46
  import { captureCall, stampVerdict } from "./call-capture.mjs";
47
47
  import { join, dirname } from "node:path";
48
48
  import { driverDir } from "../shared/driver-dir.mjs";
49
- import { parseFindingsJson, COVERAGE_AREA_STATES } from "./findings-model.mjs";
49
+ import { parseFindingsJson, COVERAGE_AREA_STATES, topKeysForVersion } from "./findings-model.mjs";
50
50
  import { readFrozenMethod, FROZEN_METHOD_FILE } from "./framework-method.mjs";
51
51
  import { findCoverageRecommendations } from "./verify.mjs";
52
52
  import { declinationCallPaths, readDeclinations } from "./declination-tool.mjs"; // — the seat's own declines, read by the driver never asserted by the seat
53
53
  import { reconcileDeclinationDuty, declinationDutyRefusal } from "./declination-duty.mjs";
54
54
  import { findingUris } from "./record-carry.mjs"; // one derivation of "which records did the findings name", called not copied
55
55
  import { foldAfterSave } from "./record-fold.mjs"; // once the delivery seam is passed, the saved record is folded again
56
+ import { runLog } from "./log.mjs"; // what the transport read a call as goes to the run's record, never to the model's answer
56
57
  import { recordOriginsOr, blankOnRepeat, recordBlanking, FOREIGN_HOST_TOKEN } from "./foreign-record-links.mjs"; // a foreign record link: refused once, then blanked
57
58
  export const NARRATIVE_FILE = "narrative.md";
58
59
  export const FINDINGS_FILE = "findings.json";
@@ -440,6 +441,267 @@ export function lastAcceptedCall(runDir) {
440
441
  } catch { return null; }
441
442
  }
442
443
 
444
+ // ── THE BASE A LATER PATCH BUILDS ON IS HELD, PUT BACK AND REPAIRED WITH THE RECORD ──────────────────────
445
+ //
446
+ // A corrective pass that saves and is then rolled back used to put back findings.json alone. narrative.md
447
+ // kept the failed pass's text, and the merge base kept the failed pass's record, so the next patch merged onto
448
+ // a record the rollback had discarded and quietly applied it again. The three functions below hold the
449
+ // narrative and the base beside the findings snapshot, put them back, and let the driver write a repair into
450
+ // the base, so a restore the next patch cannot see is not dropped by it.
451
+
452
+ const writeAtomic = (file, text) => {
453
+ mkdirSync(dirname(file), { recursive: true });
454
+ const tmp = `${file}.${process.pid}.tmp`;
455
+ writeFileSync(tmp, text);
456
+ renameSync(tmp, file);
457
+ };
458
+
459
+ /** The merge base and the narrative as they stand: each file's bytes, or null where it is absent or unreadable. Never throws. */
460
+ export function snapshotSynthesisBase(runDir) {
461
+ const dir0 = String(runDir ?? "");
462
+ const bytes = (at) => { try { return readFileSync(at, "utf8"); } catch { return null; } };
463
+ return { accepted: bytes(synthesisCallPaths(dir0).accepted), narrative: bytes(join(dir0, NARRATIVE_FILE)) };
464
+ }
465
+
466
+ /**
467
+ * Put back what `snapshotSynthesisBase` held. Never throws. Returns what happened to each file — "restored",
468
+ * "removed", "unchanged" or "failed" — or null when there is no snapshot, which leaves both files as they are.
469
+ *
470
+ * A snapshot that held NO base removes the one written since, so a later patch is refused for having no base
471
+ * rather than built on the pass that was discarded. A snapshot that held no narrative leaves the file as it
472
+ * stands: a run with no narrative.md reads as one whose synthesis never finished, and the rollback exists to
473
+ * deliver.
474
+ */
475
+ export function restoreSynthesisBase(runDir, snap) {
476
+ if (!snap || typeof snap !== "object") return null;
477
+ const dir0 = String(runDir ?? "");
478
+ const putBack = (at, held, { removeWhenAbsent }) => {
479
+ try {
480
+ let now = null;
481
+ try { now = readFileSync(at, "utf8"); } catch { now = null; }
482
+ if (held == null) {
483
+ if (!removeWhenAbsent || now == null) return "unchanged";
484
+ rmSync(at, { force: true });
485
+ return "removed";
486
+ }
487
+ if (now === held) return "unchanged";
488
+ writeAtomic(at, held);
489
+ return "restored";
490
+ } catch { return "failed"; }
491
+ };
492
+ return {
493
+ accepted: putBack(synthesisCallPaths(dir0).accepted, snap.accepted, { removeWhenAbsent: true }),
494
+ narrative: putBack(join(dir0, NARRATIVE_FILE), snap.narrative, { removeWhenAbsent: false }),
495
+ };
496
+ }
497
+
498
+ /**
499
+ * A DRIVER EDIT TO THE MERGE BASE, judged before it is written. Never throws.
500
+ *
501
+ * `edit` is PURE: it takes the base's findings record, the model's own in its numbering, and returns the record
502
+ * to hold instead. The edited call is judged by `acceptSynthesis` with the run's own reads, as a call is, so the
503
+ * base stays what it has always been: the last record that passed. A refusal, a throw or a failed write leaves
504
+ * the base as it was.
505
+ *
506
+ * Returns `{ok: true, changed}` or `{ok: false, reason}`.
507
+ */
508
+ export function rebaseSynthesis(runDir, edit, opts = {}) {
509
+ const dir0 = String(runDir ?? "");
510
+ const base = lastAcceptedCall(dir0);
511
+ if (!base || typeof base !== "object") return { ok: false, reason: "no_base: this run holds no accepted synthesis call to edit" };
512
+ let findings;
513
+ try { findings = edit(structuredClone(base.findings)); }
514
+ catch (e) { return { ok: false, reason: `edit_failed: ${String(e?.message ?? e).slice(0, 160)}` }; }
515
+ if (JSON.stringify(findings) === JSON.stringify(base.findings)) return { ok: true, changed: false };
516
+ const auto = driverReadsFor(dir0);
517
+ const { asks = auto.asks, ledger = auto.ledger, manifest = auto.manifest,
518
+ method = auto.method, methodInvalid = auto.methodInvalid ?? null,
519
+ owed = auto.owed, declined = auto.declined, recordOrigins = undefined,
520
+ now = () => new Date().toISOString() } = opts;
521
+ const call = { ...base, findings };
522
+ const v = acceptSynthesis(call, { asks, ledger, manifest, method, methodInvalid, owed, declined, recordOrigins: recordOriginsOr(recordOrigins) });
523
+ if (!v.ok) return { ok: false, reason: v.reason };
524
+ try {
525
+ writeAtomic(synthesisCallPaths(dir0).accepted, JSON.stringify({
526
+ _provenance: "the last ACCEPTED call, with a driver edit that passed the same acceptance — the base a later repair patches onto",
527
+ acceptedAt: now(), editedBy: "driver", params: call,
528
+ }, null, 2) + "\n");
529
+ } catch (e) { return { ok: false, reason: `write_failed: ${String(e?.message ?? e).slice(0, 160)}` }; }
530
+ return { ok: true, changed: true };
531
+ }
532
+
533
+ // ── A FINDINGS OBJECT WITH NO LIST IS A PATCH ON THE REGISTERS IT CARRIES ───────────────────────────────
534
+ //
535
+ // A correction has been seen to send the registers it changed inside a `findings` object that carries no
536
+ // findings list, beside its rows. Read as a document, that object has no findings at all, and the call was
537
+ // refused as an unparseable record, or as a record accounting for none of the run's owed records, though the
538
+ // stored record held every one. A findings list makes the document whole; without one, the call patches what
539
+ // it carries, and the merged record is judged whole as any call is.
540
+ //
541
+ // MAIN'S READING STANDS FOR EVERYTHING ELSE. A list-less object reads as registers only when there is a stored
542
+ // record to patch, a narrative sent beside it is an object of the sections the tool describes, every key it carries
543
+ // is a register of that record's own ladder (the one its schema_version gives) or its rows, every key the call
544
+ // carries beside its narrative and that object is one too, its envelope is absent or equal to the stored one wherever
545
+ // it is sent, a corrections marker sent inside it or beside it is one the decode reads, a register sent inside it and
546
+ // again beside it carries the same value in both places, the call's own rows each name a finding the stored record
547
+ // holds, and no ordinal carries two different rows, among the call's own rows or its nested ones. Any other shape is
548
+ // read exactly as it always was, so the model meets the refusal it always met, byte for byte.
549
+
550
+ const isPlainObject = (v) => v != null && typeof v === "object" && !Array.isArray(v);
551
+ // Key-sorted JSON: "the same content" must not turn on the order the model wrote its keys in.
552
+ const canon = (v) => (v === null || typeof v !== "object") ? JSON.stringify(v)
553
+ : Array.isArray(v) ? `[${v.map(canon).join(",")}]`
554
+ : `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canon(v[k])}`).join(",")}}`;
555
+ const sameValue = (a, b) => canon(a) === canon(b);
556
+
557
+ // A finding row names the finding it replaces by its ordinal, a positive whole number.
558
+ const isRow = (r) => isPlainObject(r) && Number.isInteger(r.ordinal) && r.ordinal > 0;
559
+ // A corrections marker sent as a JSON string, decoded: the object it encodes, or undefined for anything else.
560
+ const decodedMarker = (v) => { try { const x = JSON.parse(v); return isPlainObject(x) ? x : undefined; } catch { return undefined; } };
561
+ // A marker the decode reads: none (null, which the parser reads as no marker), an object, or a string encoding one.
562
+ const isMarker = (v) => v == null || isPlainObject(v) || (typeof v === "string" && decodedMarker(v) !== undefined);
563
+ // A register's value as the transport reads it: a marker string as the object it encodes, anything else as sent.
564
+ const asRead = (k, v) => (k === "corrections" && typeof v === "string" ? (decodedMarker(v) ?? v) : v);
565
+ // A narrative section as the tool describes it: the coverage prose inside `coverage`, the calibration challenges as
566
+ // {challenge, answer} pairs, and every other section as text. `ask_answers` belongs to the record, not here.
567
+ const isSection = ([k, v]) => k === "coverage" ? isPlainObject(v) && Object.keys(v).every((x) => x === "read") && isStr(v.read)
568
+ : k === "calibration" ? Array.isArray(v) && v.every((c) => isPlainObject(c) && isStr(c.challenge) && isStr(c.answer))
569
+ : k !== "ask_answers" && isStr(v);
570
+ // Two different rows for one ordinal among `rows`. A row sent again with the same content is one row.
571
+ const twoRowsForOneOrdinal = (rows) => {
572
+ const one = new Map();
573
+ return rows.some((r) => (one.has(r.ordinal) ? !sameValue(one.get(r.ordinal), r) : (one.set(r.ordinal, r), false)));
574
+ };
575
+
576
+ // The record's envelope. A list-less object that would change either, inside the object or at the call's top
577
+ // level, is not a register edit: re-versioning is a whole re-send (the schema-downlevel rung), and read as
578
+ // registers it could move the stored list to another version's rules without the list ever being sent.
579
+ const ENVELOPE_KEYS = ["schema_version", "rated_under_framework"];
580
+
581
+ /**
582
+ * Rows sent inside a list-less `findings`, beside the call's own. PURE. Null when that is ambiguous: rows that are
583
+ * not an array, a nested row that is not an object carrying a positive whole-number ordinal (the way a finding row
584
+ * names the finding it replaces), or one ordinal carrying two different rows, whether both are nested or one is at the
585
+ * top level. The call's own rows reach here already checked to carry one row per ordinal (`registerPatchOf`), so a
586
+ * nested row is compared with the one row its ordinal has there. A nested row that repeats one already sent is
587
+ * applied once, and `lifted` counts only the rows actually added.
588
+ */
589
+ function liftNestedRows(top, nested) {
590
+ if (nested === undefined) return { rows: top, lifted: 0 };
591
+ if (!Array.isArray(nested) || (top != null && !Array.isArray(top))) return null;
592
+ if (!nested.every(isRow)) return null;
593
+ const sent = new Map((top ?? []).map((r) => [r?.ordinal, r]));
594
+ const added = [];
595
+ for (const r of nested) {
596
+ if (sent.has(r?.ordinal)) {
597
+ if (!sameValue(sent.get(r?.ordinal), r)) return null;
598
+ continue;
599
+ }
600
+ sent.set(r?.ordinal, r);
601
+ added.push(r);
602
+ }
603
+ return { rows: added.length ? [...(top ?? []), ...added] : top, lifted: added.length };
604
+ }
605
+
606
+ /**
607
+ * A call whose `findings` carries no list, read as a patch on the registers it carries onto `stored`. PURE.
608
+ *
609
+ * Returns `{registers, rows, lifted}`, or null when the call does not read that way (see above), and null leaves
610
+ * the call to main's reading of it. `rows` are the call's own `findings_patch` with the nested rows after them.
611
+ */
612
+ export function registerPatchOf(stored, call) {
613
+ const doc = call?.findings;
614
+ if (!isPlainObject(stored?.findings) || !isPlainObject(doc) || Object.hasOwn(doc, "findings")) return null;
615
+ // A narrative sent beside it is an object of sections. Text, a list or null is no section patch: merged, its
616
+ // characters would become numbered keys in the base while its prose reached no file. And each section it sends is
617
+ // one the tool describes: a coverage that is not its prose, or calibration that is not its pairs, would replace
618
+ // the held section with something no file renders, and a spine or verdict that is not text is no section at all.
619
+ if (call.narrative !== undefined && !(isPlainObject(call.narrative) && Object.entries(call.narrative).every(isSection))) return null;
620
+ // What it may carry: a register of the stored record's own ladder, or rows.
621
+ const allowed = new Set([...topKeysForVersion(stored.findings.schema_version).filter((k) => k !== "findings"), "findings_patch"]);
622
+ if (Object.keys(doc).some((k) => !allowed.has(k))) return null;
623
+ // And what the call may carry beside it. The merge copies a patch's own top-level keys into the record, so a key
624
+ // there that the ladder does not allow would be judged against the stored record, where the object read as a
625
+ // document is judged as sent.
626
+ if (Object.keys(call).some((k) => k !== "narrative" && k !== "findings" && !allowed.has(k))) return null;
627
+ // Its envelope, inside the object or beside it, is absent or the stored one. The merge copies the call's own
628
+ // top-level keys last, so a different envelope there would re-version the record as surely as one inside.
629
+ if (ENVELOPE_KEYS.some((k) => [doc, call].some((o) => Object.hasOwn(o, k) && !sameValue(o[k], stored.findings[k])))) return null;
630
+ // A corrections marker, inside the object or beside it, is one the decode reads. Any other marker, a string that
631
+ // does not decode to an object among them, is refused as it always was: on this shape, as main reads the object.
632
+ if (![doc, call].every((o) => isMarker(o.corrections))) return null;
633
+ // A register sent inside the object and again beside it carries one value in both places. Read as registers, the
634
+ // copy beside the object would be kept and the other dropped without a word, so the model would never learn that
635
+ // part of its correction was ignored; read as main reads the call, it meets main's refusal. Compared as read, so the
636
+ // answer is the same on the call as it arrives and on the call once its markers are decoded.
637
+ if (Object.keys(doc).some((k) => k !== "findings_patch" && Object.hasOwn(call, k) && !sameValue(asRead(k, doc[k]), asRead(k, call[k])))) return null;
638
+ // The call's own rows each replace a finding the stored record holds, named by a positive whole-number ordinal.
639
+ // A row list that is not one, or a row aimed elsewhere, is read as main reads the call.
640
+ const held = new Set((Array.isArray(stored.findings.findings) ? stored.findings.findings : []).map((f) => f?.ordinal));
641
+ if (call.findings_patch !== undefined && !(Array.isArray(call.findings_patch) && call.findings_patch.every((r) => isRow(r) && held.has(r.ordinal)))) return null;
642
+ // And no ordinal carries two different rows among them. Read as registers, the last would be applied and the other
643
+ // dropped without a word, the choice turning on the order they were sent in; read as main reads the call, it meets
644
+ // main's refusal. A row sent again with the same content is one row.
645
+ if (call.findings_patch !== undefined && twoRowsForOneOrdinal(call.findings_patch)) return null;
646
+ const { findings_patch: nested, ...registers } = doc;
647
+ const lift = liftNestedRows(call.findings_patch, nested);
648
+ return lift ? { registers, rows: lift.rows, lifted: lift.lifted } : null;
649
+ }
650
+
651
+ /** Would `call` read as a register patch on `stored` but for a corrections marker the decode cannot read? PURE. */
652
+ function markerAloneBlocks(stored, call) {
653
+ const doc = call?.findings;
654
+ if (!isPlainObject(doc) || Object.hasOwn(doc, "findings")) return false;
655
+ if ([doc, call].every((o) => isMarker(o.corrections))) return false;
656
+ const { corrections: _inner, ...docRest } = doc;
657
+ const { corrections: _outer, ...callRest } = call;
658
+ return registerPatchOf(stored, { ...callRest, findings: docRest }) != null;
659
+ }
660
+
661
+ /**
662
+ * Read a call in the shapes the model has been seen to send it. PURE. Returns the call to decide on — the
663
+ * received object itself unless something below applies — and what was read, for the run's own record.
664
+ *
665
+ * A `corrections` marker sent as a JSON STRING is decoded where the call carries no findings list: at the top
666
+ * level of a patch, and inside a findings object read as registers (`registerPatchOf`). Only a string that
667
+ * decodes to an object is replaced, and the object is then validated as any marker is; anything else stays as it
668
+ * came, and the parser refuses it as it always did. A call carrying a list — a whole call, or a record re-sent
669
+ * with no narrative — is read exactly as main reads it.
670
+ *
671
+ * WHAT IS COUNTED, for the run log only. On a WHOLE call every top-level key beside its narrative and its findings
672
+ * is ignored, as it always was, and counted: `keysIgnored` for keys, which are never named, since a key off the
673
+ * record's ladder is the model's own word; `rowsIgnored` for a top-level `findings_patch`, and `rowsDiffering` for
674
+ * those of its rows the record's own list does not hold as sent. Nothing a register patch carries is dropped: a
675
+ * register sent in both places with two values makes the call one main reads as it always did (`registerPatchOf`).
676
+ */
677
+ export function readSynthesisCall(received, stored) {
678
+ const notes = { decoded: [], keysIgnored: 0, rowsIgnored: 0, rowsDiffering: 0 };
679
+ if (!isPlainObject(received)) return { call: received, notes };
680
+ const asRegisters = registerPatchOf(stored, received) !== null;
681
+ if (received.findings !== undefined && !asRegisters) {
682
+ if (received.narrative !== undefined) { // a whole call
683
+ const list = new Map((Array.isArray(received.findings?.findings) ? received.findings.findings : []).map((f) => [f?.ordinal, f]));
684
+ for (const [k, v] of Object.entries(received)) {
685
+ if (k === "narrative" || k === "findings") continue;
686
+ if (k !== "findings_patch" || !Array.isArray(v)) { notes.keysIgnored++; continue; }
687
+ notes.rowsIgnored += v.length;
688
+ notes.rowsDiffering += v.filter((r) => !list.has(r?.ordinal) || !sameValue(list.get(r?.ordinal), r)).length;
689
+ }
690
+ }
691
+ return { call: received, notes };
692
+ }
693
+ let call = received;
694
+ if (typeof call.corrections === "string") {
695
+ const x = decodedMarker(call.corrections);
696
+ if (x !== undefined) { call = { ...call, corrections: x }; notes.decoded.push("corrections"); }
697
+ }
698
+ if (asRegisters && typeof call.findings.corrections === "string") {
699
+ const x = decodedMarker(call.findings.corrections);
700
+ if (x !== undefined) { call = { ...call, findings: { ...call.findings, corrections: x } }; notes.decoded.push("findings.corrections"); }
701
+ }
702
+ return { call, notes };
703
+ }
704
+
443
705
  /**
444
706
  * Merge a patch call onto the stored one. PURE.
445
707
  *
@@ -451,6 +713,9 @@ export function lastAcceptedCall(runDir) {
451
713
  * A patch naming an ordinal the stored call does not hold is a DEFECT, not an insert: the repair
452
714
  * ladder only ever patches findings the parse already quarantined, so an unknown ordinal means the seat
453
715
  * is repairing something this run does not have.
716
+ *
717
+ * Returns `{ok: true, merged, lifted}`, where `lifted` counts the rows taken from inside a list-less
718
+ * `findings`, or `{ok: false, reason}`.
454
719
  */
455
720
  export function mergeSynthesisPatch(stored, patch) {
456
721
  if (!stored) return { ok: false, reason: "synthesis_patch_without_base: this run has no accepted synthesis call to patch — send the complete `narrative` and `findings` instead. A patch is a repair of values the driver is holding, and it is holding none" };
@@ -460,9 +725,22 @@ export function mergeSynthesisPatch(stored, patch) {
460
725
  // still arrives here rather than as a first call, because it carries no narrative — and the narrative
461
726
  // it is not being asked to redo is the stored one. Without this the rung could not succeed: the call
462
727
  // was refused for `synthesis_narrative_missing`, naming sections nobody had asked the seat to resend.
463
- if (patch?.findings !== undefined) out.findings = patch.findings;
728
+ //
729
+ // A `findings` OBJECT WITH NO LIST is the other shape (`registerPatchOf`): each register it carries replaces
730
+ // the stored one and the rest are held, and rows sent inside it are applied as rows. Precedence: the stored
731
+ // record, then those registers, then the call's top-level registers (copied last, below); a register sent in both
732
+ // places carries one value there, so the order chooses nothing. Rows replace whole findings by ordinal; narrative
733
+ // sections replace sections.
734
+ const asRegisters = registerPatchOf(stored, patch);
735
+ // A list-less `findings` object that reads as registers in every way but its corrections marker: the marker is what
736
+ // is wrong. Read as main reads the call it would replace the record, and it would then be refused for a defect it
737
+ // does not have (rows naming findings the run holds, or a record with no list); it gets the refusal the same marker
738
+ // meets at the top level instead.
739
+ if (!asRegisters && markerAloneBlocks(stored, patch)) return { ok: false, reason: "synthesis_findings_invalid: findings_corrections_invalid (corrections must be an object { applied, note })" };
740
+ if (asRegisters) Object.assign(out.findings, asRegisters.registers);
741
+ else if (patch?.findings !== undefined) out.findings = patch.findings;
464
742
  for (const [k, v] of Object.entries(patch?.narrative ?? {})) out.narrative[k] = v;
465
- const rows = patch?.findings_patch;
743
+ const rows = asRegisters ? asRegisters.rows : patch?.findings_patch;
466
744
  if (rows != null) {
467
745
  if (!Array.isArray(rows)) return { ok: false, reason: "synthesis_patch_shape: `findings_patch` is an array of complete finding objects, each carrying the `ordinal` of the finding it replaces" };
468
746
  const byOrdinal = new Map((out.findings.findings ?? []).map((f, i) => [f?.ordinal, i]));
@@ -478,7 +756,7 @@ export function mergeSynthesisPatch(stored, patch) {
478
756
  for (const [k, v] of Object.entries(patch ?? {})) {
479
757
  if (k !== "narrative" && k !== "findings_patch" && k !== "findings") out.findings[k] = v;
480
758
  }
481
- return { ok: true, merged: out };
759
+ return { ok: true, merged: out, lifted: asRegisters?.lifted ?? 0 };
482
760
  }
483
761
 
484
762
  /**
@@ -626,8 +904,28 @@ export function recordSynthesis(runDir, received, opts = {}) {
626
904
  // a second way to say the same thing and the two can disagree. Either half may be the missing one —
627
905
  // a corrective sends findings changes with no narrative rewrite, a schema migration re-emits the
628
906
  // whole record and touches no section — and in both cases the half not sent is the stored one.
629
- const isPatch = Boolean(received) && (received.findings === undefined || received.narrative === undefined);
630
- let call = received;
907
+ // A call whose `findings` carries NO LIST is a patch too, on the registers it carries, where there is a stored
908
+ // record to patch and the call reads that way (`registerPatchOf`); any other shape is read as it always was.
909
+ // The call is decided on AS READ (`readSynthesisCall`): a marker sent as a JSON string is decoded first. The
910
+ // capture above keeps what arrived; the base written below keeps the call as read.
911
+ const stored = lastAcceptedCall(dir0);
912
+ const read = readSynthesisCall(received, stored);
913
+ let call = read.call;
914
+ const asRegisters = registerPatchOf(stored, call);
915
+ const isPatch = Boolean(call) && (call.findings === undefined || call.narrative === undefined || asRegisters !== null);
916
+ // WHAT THE TRANSPORT READ THE CALL AS: one run-log row at whichever exit the call takes, beside the decision,
917
+ // when the reading decoded, lifted or ignored anything. Never in the answer below, which the tool
918
+ // returns whole into the model's turn. Best-effort, as the foreign-link record is. A lift is counted as READ,
919
+ // so one the merge then refuses is on the record as one the acceptance refuses is.
920
+ const reading = { ...read.notes, lifted: asRegisters?.lifted ?? 0 };
921
+ const recordReading = (accepted) => {
922
+ if (!reading.decoded.length && !reading.lifted && !reading.keysIgnored && !reading.rowsIgnored) return;
923
+ try {
924
+ runLog(dir0, { event: "synthesis-call-normalised", stage: "synthesis", seq: cap.seq ?? null, accepted,
925
+ decoded: reading.decoded, lifted: reading.lifted,
926
+ keysIgnored: reading.keysIgnored, rowsIgnored: reading.rowsIgnored, rowsDiffering: reading.rowsDiffering });
927
+ } catch { /* a log that cannot be written must not stop the record it describes */ }
928
+ };
631
929
  // Appended, never overwritten: a turn can be refused more than once and each one is a fact about the
632
930
  // run. Best-effort — bookkeeping that can kill a run is worse than bookkeeping that is absent.
633
931
  const noteRefusal = (reason) => {
@@ -635,9 +933,10 @@ export function recordSynthesis(runDir, received, opts = {}) {
635
933
  };
636
934
 
637
935
  if (isPatch) {
638
- const merged = mergeSynthesisPatch(lastAcceptedCall(dir0), received);
936
+ const merged = mergeSynthesisPatch(stored, call); // the call as READ: a decoded marker merges as an object
639
937
  if (!merged.ok) {
640
938
  noteRefusal(merged.reason);
939
+ recordReading(false);
641
940
  return { written: null, refused: merged.reason,
642
941
  captured: closeCapture({ ok: false, refused: merged.reason }), capture_failed: captureFailed };
643
942
  }
@@ -656,6 +955,7 @@ export function recordSynthesis(runDir, received, opts = {}) {
656
955
  const v = acceptSynthesis(call, { asks, ledger, manifest, method, methodInvalid, owed, declined, recordOrigins: origins });
657
956
  if (!v.ok) {
658
957
  noteRefusal(v.reason);
958
+ recordReading(false);
659
959
  return { written: null, refused: v.reason,
660
960
  captured: closeCapture({ ok: false, refused: v.reason }), capture_failed: captureFailed };
661
961
  }
@@ -674,6 +974,7 @@ export function recordSynthesis(runDir, received, opts = {}) {
674
974
  } catch (e) {
675
975
  // The call was VALID and we could not store it. That is infrastructure, and it must not read as a
676
976
  // rejected call — the two have opposite repairs.
977
+ recordReading(false);
677
978
  return {
678
979
  written: null, refused: null,
679
980
  write_failed: String(e?.message ?? e).slice(0, 200),
@@ -684,6 +985,7 @@ export function recordSynthesis(runDir, received, opts = {}) {
684
985
  // call records above stay the model's own, the base for its next patch; findings.json becomes the folded
685
986
  // record the reviewer, the cards and the delivery read. Before the seam this leaves the record as written.
686
987
  foldAfterSave(dir0, findingsAt);
988
+ recordReading(true);
687
989
 
688
990
  return {
689
991
  written: [findingsAt, narrativeAt], refused: null,
@@ -27,6 +27,46 @@
27
27
  /** The verdicts, ordered by caution. A clamp may move RIGHT along this list and never left. */
28
28
  const CAUTION = Object.freeze(["CLEAR", "CONDITIONAL", "BLOCKING"]);
29
29
 
30
+ /**
31
+ * A GUARD'S GAP POSITIVELY CLOSED when it is judged again: its account was read, could be computed, and finds no gap.
32
+ * An account that is absent, unreadable or not computable is no proof that the gap closed, and the clause stands.
33
+ * `token` names the guard. The floor's account is judged in its own module (`floorDutyClosed`) and arrives as
34
+ * `floorClosed`; the writer's duty arrives as its reconciliation, and the recall gate as its reconciliation with the
35
+ * records a refused merge left out. PURE.
36
+ */
37
+ export function gapPositivelyClosed(token, { floorClosed = false, duty = null, recall = null, leftOut = [] } = {}) {
38
+ if (token === "floor_duty_undischarged") return floorClosed === true;
39
+ if (token === "synthesis_unaccounted_delivered") return duty?.computable === true && Array.isArray(duty.unaccounted) && duty.unaccounted.length === 0;
40
+ if (token === "recall_reconciliation_unended") return recall?.computable === true && !((recall.totals?.unended ?? 0) > 0) && !leftOut.length;
41
+ return false;
42
+ }
43
+
44
+ /**
45
+ * THE RESULT ONCE A CLOSED GAP'S CLAUSE IS WITHDRAWN, recomputed from the accepted assessment and the conditions that
46
+ * still stand. A CONDITIONAL goes back to CLEAR only when the assessment was CLEAR and no condition is left; any other
47
+ * result stands, so a condition that still applies, a review that said CONDITIONAL or BLOCKING, or a result the guard
48
+ * never moved is never lifted. PURE.
49
+ */
50
+ export function verdictWithoutTheClosedGap({ verdict, assessed, reasons = [] } = {}) {
51
+ return verdict === "CONDITIONAL" && assessed === "CLEAR" && !reasons.length ? "CLEAR" : verdict;
52
+ }
53
+
54
+ /**
55
+ * ONE SENTENCE WHEN THE WRITER'S DUTY AND THE RECALL GATE BOTH FIND RECORDS UNADDRESSED. Both state the approved
56
+ * sentence for records neither addressed nor set aside, each out of its own population: the records the writer was
57
+ * owed, and the records the recall gate weighed. Stated twice, the same words would give two totals for "the register
58
+ * records this search surfaced". Stated once, N is the records either names and M the records either required to be
59
+ * assessed, each counted once: never a sum of overlapping records, and never a record neither required. Null when
60
+ * either side names no record or either population is unknown; each then states its own count. PURE.
61
+ */
62
+ export function jointUnaccounted({ dutyIds = [], owedIds = null, recallIds = [], weighedIds = null } = {}) {
63
+ if (!Array.isArray(owedIds) || !Array.isArray(weighedIds) || !dutyIds.length || !recallIds.length) return null;
64
+ const ids = (...lists) => new Set(lists.flat().map((x) => String(x ?? "").trim().toLowerCase()).filter(Boolean));
65
+ const n = ids(dutyIds, recallIds).size;
66
+ const m = ids(owedIds, weighedIds, dutyIds, recallIds).size;
67
+ return { n, m };
68
+ }
69
+
30
70
  /**
31
71
  * The shape of an engine identifier: `word_word:N` tokens (`floor_duty_undischarged:22`) and the
32
72
  * `snake_case` defect vocabulary generally. The CLIENT'S clause may never carry one —:
@@ -173,9 +213,20 @@ export function orderClausesForLede(clauses, reasons, guardSet) {
173
213
  * after 5.55 hours and the client received nothing instead of a report naming one gap. The caller
174
214
  * decides what the absence means.
175
215
  */
216
+ // The approved sentence for surfaced records the report neither addresses nor sets aside. The recall gate finds the
217
+ // same state, a retrieved record ending nowhere a reader can see, and since the owner's ruling of 2026-10-19 (never hold
218
+ // a run) it ships that state instead of stopping: it is said in this same sentence, not a new one.
219
+ const unaccountedRecords = (n, m) =>
220
+ `${n} of the ${m} register records this search surfaced are neither addressed as findings nor expressly set aside in this report — they remain open points a reader must weigh`;
176
221
  const CLAUSE_AUTHORITY = Object.freeze({
177
- synthesis_unaccounted_delivered: (n, m) =>
178
- `${n} of the ${m} register records this search surfaced are neither addressed as findings nor expressly set aside in this report — they remain open points a reader must weigh`,
222
+ synthesis_unaccounted_delivered: unaccountedRecords,
223
+ recall_reconciliation_unended: unaccountedRecords,
224
+ // The owner's words of 2026-10-19, for records a repeated placement pass re-ranked after the writer last wrote, when
225
+ // the writer's re-run for the change did not complete: his singular for one record, his plural for more. The second
226
+ // count is the population, kept for the run record.
227
+ ranking_changed_unread: (n) => (n === 1
228
+ ? "1 register record was re-ranked after this report was written; the audit trail gives its final ranking"
229
+ : `${n} register records were re-ranked after this report was written; the audit trail gives their final ranking`),
179
230
  floor_duty_undischarged: (n, m) =>
180
231
  `${n} of the ${m} live registrations identical or near-identical to the mark are not individually addressed in this report — each remains an open point a reader must weigh`,
181
232
  });
@@ -3,8 +3,8 @@
3
3
  // verify-knockout.mjs — validators + gates for the KNOCKOUT (Depth 1) lane.
4
4
  // Same contract as verify.mjs validators: (file, text) -> {ok, reason?} for per-stage outputs (they ride
5
5
  // runStage's corrective ladder), plus code-side gates for the merged findings and the pre-publish lint.
6
- // The receipts discipline: every cited URL must appear in THAT mark's raw research payload — the knockout
7
- // analogue of the band-truth gate (a finding the driver cannot trace to held evidence is treated as
6
+ // The receipts discipline: every cited URL must appear in THAT mark's raw research payload or in the register
7
+ // records supplied for that mark — the knockout analogue of the band-truth gate (a finding the driver cannot trace to held evidence is treated as
8
8
  // fabricated). It runs at three doors and has NO off switch: CLEAROTRON_KNOCKOUT_URL_GATE is deleted.
9
9
  // An env var that disarms a gate makes the gate's absence look exactly like its pass, which is the one
10
10
  // failure mode this lane's gates exist to refuse.
@@ -94,12 +94,27 @@ export function knockoutCitedUrls(f) {
94
94
  *
95
95
  * @returns {{ok: boolean, failures: string[], checked: {marks: number, citing: number, findings: number, urls: number}}}
96
96
  */
97
+ /**
98
+ * THE REGISTER FILINGS THE RATING STEP WAS HANDED, per mark, as text (register-records.json, which the rating
99
+ * message names when it is on disk). The message lets a citation come from the mark's own payload or from the
100
+ * filings supplied for that mark, so the receipts check reads both; another mark's filings license nothing.
101
+ */
102
+ function suppliedRecordsByMark(runDir) {
103
+ try {
104
+ const doc = JSON.parse(readFileSync(driverDir(runDir, "register-records.json"), "utf8"));
105
+ return new Map((Array.isArray(doc?.marks) ? doc.marks : []).filter((x) => x?.name)
106
+ .map((x) => [String(x.name).trim().toLowerCase(), JSON.stringify(Array.isArray(x.records) ? x.records : [])]));
107
+ } catch { return new Map(); }
108
+ }
109
+
97
110
  export function knockoutReceipts(runDir, marks) {
98
111
  const failures = [];
99
112
  const checked = { marks: (marks ?? []).length, citing: 0, findings: 0, urls: 0 };
113
+ const supplied = suppliedRecordsByMark(runDir);
100
114
  for (const m of marks ?? []) {
101
115
  const list = Array.isArray(m?.findings) ? m.findings : [];
102
116
  checked.findings += list.length;
117
+ const records = supplied.get(String(m?.name ?? "").trim().toLowerCase()) ?? null;
103
118
  let payload = null, opened = false;
104
119
  for (const f of list) {
105
120
  const cited = knockoutCitedUrls(f);
@@ -111,8 +126,11 @@ export function knockoutReceipts(runDir, marks) {
111
126
  }
112
127
  for (const url of cited) {
113
128
  checked.urls += 1;
114
- if (payload == null) { failures.push(`knockout_url_unreceipted:${m.name}: cites "${url}" but the mark has no research payload — a degraded mark cannot cite URLs`); continue; }
115
- if (!urlInPayload(url, payload)) failures.push(`knockout_url_unreceipted:${m.name}: "${url}" does not appear in the mark's research payload — cite only held evidence`);
129
+ if (records != null && urlInPayload(url, records)) continue; // a filing supplied for this mark
130
+ // One refusal for both branches (design's wording, 2026-10-19): the register records supplied for the mark are
131
+ // held evidence too, so a refusal never tells the model to drop one of them.
132
+ if (payload == null || !urlInPayload(url, payload))
133
+ failures.push(`knockout_url_unreceipted:${m.name}: "${url}" does not appear in the mark's own research payload or in the register records supplied for that mark — cite only held evidence`);
116
134
  }
117
135
  }
118
136
  }
package/driver/verify.mjs CHANGED
@@ -12,7 +12,7 @@ import { dirname, join, basename } from "node:path";
12
12
  import { driverDir } from "../shared/driver-dir.mjs"; //
13
13
  import { findReceiptViolations, findGridLedgerViolations, findUnranDictatedCells, findPlatformIdentityViolations, parsePrRiskQueries, MEANING_SEAT, erroredConnotationQueriesAmong } from "./common-law-receipts.mjs";
14
14
  // Conversion 2 — the discriminator the two rulings above key on. PURE-ish: one existsSync-shaped read.
15
- import { matterFrameWasRecorded, frameRatifiedForms } from "./matter-frame-record.mjs";
15
+ import { matterFrameWasRecorded, frameRatifiedForms } from "./matter-frame-record.mjs"; import { lastAcceptedOverview, acceptReportOverview, readReportIdentity } from "./report-overview-record.mjs";
16
16
  import { findConnotationViolations, parsePrRiskResults, prRiskPopulation,
17
17
  CONNOTATION_UNMATCHED_MARK, CONNOTATION_NO_RESEMBLANCE_MARK, MEANING_ANGLES_RE,
18
18
  parseDispositionForm, CONNOTATION_UNRULED_REASONS, queryKey } from "./connotation-search.mjs";
@@ -237,6 +237,7 @@ function all(...checks) {
237
237
  return ok();
238
238
  }
239
239
  const ok = (reason = "") => ({ ok: true, reason });
240
+
240
241
  const fail = (reason) => ({ ok: false, reason });
241
242
 
242
243
  // ── A1 SPLIT: the structural core shared by the commonLaw and commonLawHalf validators ─────────────
@@ -2505,8 +2506,8 @@ export const validators = {
2505
2506
  // delivery contract files. report.md is now ASSEMBLED by the driver (assembleReportMd) from the two LLM
2506
2507
  // halves below; its validator stays (front-matter + Marks) as the post-assembly structural gate.
2507
2508
  report: (_p, c) => all(nonEmpty(c, 300), needs(c, [/^---/m, /^#\s*Marks/im], "front-matter+marks", ["front-matter", "marks-section"])),
2508
- // B1 — report-overview: the cross-finding shell (front-matter + Actions/Coverage/Methodology, NO cards).
2509
- reportOverview: (_p, c) => all(nonEmpty(c, 120), needs(c, [/^---/m, /^#\s*(Actions|Coverage|Methodology)/im], "front-matter+shell", ["front-matter", "shell-section"])),
2509
+ // B1 — report-overview: the cross-finding shell (front-matter + Actions/Coverage/Methodology, NO cards). The driver's own render of an accepted call passes on the acceptance's terms (see isAcceptedOverviewRender, at the end of this file).
2510
+ reportOverview: (p, c) => (isAcceptedOverviewRender(dirname(String(p ?? "")), c) ? ok("rendered from an accepted call") : all(nonEmpty(c, 120), needs(c, [/^---/m, /^#\s*(Actions|Coverage|Methodology)/im], "front-matter+shell", ["front-matter", "shell-section"]))),
2510
2511
  // B1 — report-card: ONE isolated finding card (heading + its depth section). Lenient-but-structural
2511
2512
  // like the rest.: the required section is "### Full detail", NOT "### The read" — the read was a
2512
2513
  // third condensation of a finding already summarised by the typed `net`, and it is retired. This gate
@@ -2777,3 +2778,22 @@ export function lateReviewAgainst(carried, reviewMd) {
2777
2778
  const softened = !hardened && Number.isInteger(a) && Number.isInteger(b) && b < a ? now : null;
2778
2779
  return { hardened, softened };
2779
2780
  }
2781
+
2782
+ /**
2783
+ * IS THIS THE DRIVER'S OWN RENDER OF THE RUN'S LAST ACCEPTED OVERVIEW CALL, byte for byte? Never throws.
2784
+ *
2785
+ * The record_report_overview transport (report-overview-record.mjs) measures the call and renders the shell itself. A
2786
+ * caption with no actions and no methodology is a call it accepts, and it renders with no section heading; the
2787
+ * delivered report places its code-built sections whether or not `# Actions` is there. Holding that render to a
2788
+ * section heading asked the model to repair structure the code writes, and a run went into recovery on it. So
2789
+ * `validators.reportOverview` passes a file that is exactly this render, on the acceptance's terms. Any other file (an
2790
+ * archived or replayed shell written by hand, or a render changed after the save) is held to the structural check.
2791
+ */
2792
+ function isAcceptedOverviewRender(runDir, content) {
2793
+ try {
2794
+ const call = lastAcceptedOverview(runDir);
2795
+ if (!call) return false;
2796
+ const v = acceptReportOverview(call, { identity: readReportIdentity(runDir) });
2797
+ return v.ok === true && v.content === content;
2798
+ } catch { return false; }
2799
+ }
@@ -1,5 +1,9 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.5.0-beta.0
4
+
5
+ No changes in this release.
6
+
3
7
  ## 0.4.1
4
8
 
5
9
  No changes in this release.
@@ -20,6 +20,11 @@ export {
20
20
  // exists: a second copy of a path table drifts, and the drift shows up as an artifact reported missing.
21
21
  export { koPaths } from "../../driver/stages-knockout.mjs";
22
22
 
23
+ // PLACEMENT'S STANDING DECISIONS, as the driver's audit builder delivers them in the Audit Trail. The rebuild of a
24
+ // missing audit (lib/findings.mjs) carries the same rows, so the decisions recall reconciliation accepted are not lost
25
+ // on the recovery path. A dependency-free driver module, like the rest of this file.
26
+ export { placementAuditRows } from "../../driver/placement-decisions.mjs";
27
+
23
28
  // HOW PUBLISH ADDRESSES A KNOCKOUT'S LISTED FILINGS. On a register with no record pages of its own, each
24
29
  // filing with an office number is given the office's own page, or the office and the number and why that is
25
30
  // not a link, and the report, the workbook and report-data.json state it. The evidence view states it from