clearotron 0.5.0-beta.2 → 0.5.0-beta.4

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 (73) hide show
  1. package/.env.example +7 -0
  2. package/build-info.json +2 -2
  3. package/docs/architecture/03-run-lifecycle.md +6 -0
  4. package/docs/architecture/04-configuration-reference.md +1 -0
  5. package/docs/architecture/05-config-governance.md +1 -1
  6. package/driver/CHANGELOG.md +21 -0
  7. package/driver/band-shape.mjs +19 -11
  8. package/driver/contract-vocabulary.mjs +3 -3
  9. package/driver/coverage-form-io.mjs +3 -1
  10. package/driver/coverage-form.mjs +21 -2
  11. package/driver/coverage-ledger.mjs +7 -1
  12. package/driver/declination-call.mjs +33 -16
  13. package/driver/degraded-parts.mjs +6 -0
  14. package/driver/engine/mcp/band-server.mjs +12 -12
  15. package/driver/engine/mcp/clarivate-server.mjs +2 -0
  16. package/driver/engine/mcp/codex-config.mjs +12 -7
  17. package/driver/engine/mcp/corsearch-server.mjs +2 -0
  18. package/driver/engine/mcp/declination-server.mjs +4 -5
  19. package/driver/engine/mcp/dispositions-server.mjs +7 -0
  20. package/driver/engine/mcp/euipo-server.mjs +2 -0
  21. package/driver/engine/mcp/free-tier-server.mjs +2 -0
  22. package/driver/engine/mcp/gather-config.mjs +15 -0
  23. package/driver/engine/mcp/held-answers.mjs +11 -3
  24. package/driver/engine/mcp/perplexity-server.mjs +29 -1
  25. package/driver/engine/mcp/recording-server.mjs +16 -1
  26. package/driver/engine/mcp/signa-server.mjs +2 -0
  27. package/driver/engine/mcp/supplemental.mjs +11 -3
  28. package/driver/engine/mcp/uspto-local-server.mjs +2 -0
  29. package/driver/engine/openai-agent.mjs +48 -9
  30. package/driver/envelope-settle.mjs +13 -3
  31. package/driver/findings-model.mjs +17 -0
  32. package/driver/gateway.mjs +31 -10
  33. package/driver/package.json +1 -1
  34. package/driver/parts-not-completed.mjs +52 -11
  35. package/driver/pipeline-knockout.mjs +5 -4
  36. package/driver/pipeline.mjs +474 -81
  37. package/driver/placement-decisions.mjs +24 -9
  38. package/driver/placement-form-io.mjs +21 -1
  39. package/driver/placement-form.mjs +25 -15
  40. package/driver/placement-union.mjs +53 -8
  41. package/driver/predelivery-lint.mjs +8 -1
  42. package/driver/publish/index.mjs +7 -3
  43. package/driver/register-digest-record.mjs +6 -3
  44. package/driver/research-answers.mjs +54 -7
  45. package/driver/runner.mjs +4 -4
  46. package/driver/saved-findings-block.mjs +36 -0
  47. package/driver/skills/clearance-register/digest.md +1 -1
  48. package/driver/skills/clearance-search/phase2-execution.md +7 -5
  49. package/driver/skills/clearance-search/synthesis-rules.md +3 -3
  50. package/driver/skills/placement-inquiry/SKILL.md +3 -3
  51. package/driver/stage-context.mjs +18 -0
  52. package/driver/stages.mjs +41 -13
  53. package/driver/suite-census.json +140 -14
  54. package/driver/synthesis-record.mjs +167 -1
  55. package/driver/verify.mjs +6 -6
  56. package/mcp-server/CHANGELOG.md +8 -0
  57. package/mcp-server/package.json +1 -1
  58. package/package.json +1 -1
  59. package/portal-ui/package.json +1 -1
  60. package/providers/_shared/README.md +1 -0
  61. package/providers/_shared/enumerate.mjs +4 -3
  62. package/providers/_shared/execute-plan.mjs +33 -7
  63. package/providers/_shared/send-until.mjs +25 -0
  64. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  65. package/providers/oauth-mcp-bridge/package.json +1 -1
  66. package/scripts/band-shape-probe.mjs +1 -1
  67. package/scripts/e2e-first-time.mjs +2 -1
  68. package/scripts/release-notes-lint.mjs +1 -1
  69. package/shared/inside-run.mjs +54 -0
  70. package/shared/names-in-force.mjs +1 -0
  71. package/shared/slices-taken-out.mjs +65 -0
  72. package/skills/clearotron-account/SKILL.md +1 -1
  73. package/skills/clearotron-ops/SKILL.md +1 -1
@@ -46,13 +46,15 @@ import { writeFileSync, appendFileSync, mkdirSync, readFileSync, existsSync, ren
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, topKeysForVersion } from "./findings-model.mjs";
49
+ import { parseFindingsJson, COVERAGE_AREA_STATES, topKeysForVersion, validateFindingForSave } from "./findings-model.mjs";
50
+ import { normalizeRecordUri } from "./registry-fidelity.mjs"; // the duty's own reading of a record uri
50
51
  import { readFrozenMethod, FROZEN_METHOD_FILE } from "./framework-method.mjs";
51
52
  import { findCoverageRecommendations } from "./verify.mjs";
52
53
  import { declinationCallPaths, readDeclinations } from "./declination-tool.mjs"; // — the seat's own declines, read by the driver never asserted by the seat
53
54
  import { reconcileDeclinationDuty, declinationDutyRefusal } from "./declination-duty.mjs";
54
55
  import { findingUris } from "./record-carry.mjs"; // one derivation of "which records did the findings name", called not copied
55
56
  import { foldAfterSave } from "./record-fold.mjs"; // once the delivery seam is passed, the saved record is folded again
57
+ import { SAVED_FINDINGS_FILE } from "./saved-findings-block.mjs"; // the file a later attempt is handed the saved findings in
56
58
  import { runLog } from "./log.mjs"; // what the transport read a call as goes to the run's record, never to the model's answer
57
59
  import { recordOriginsOr, blankOnRepeat, recordBlanking, FOREIGN_HOST_TOKEN } from "./foreign-record-links.mjs"; // a foreign record link: refused once, then blanked
58
60
  export const NARRATIVE_FILE = "narrative.md";
@@ -908,6 +910,19 @@ export function recordSynthesis(runDir, received, opts = {}) {
908
910
  // record to patch and the call reads that way (`registerPatchOf`); any other shape is read as it always was.
909
911
  // The call is decided on AS READ (`readSynthesisCall`): a marker sent as a JSON string is decoded first. The
910
912
  // capture above keeps what arrived; the base written below keeps the call as read.
913
+ // A SAVE OF COMPLETED FINDINGS is its own call, and writes no report file (see `saveFindings`).
914
+ if (received && typeof received === "object" && received.save_findings !== undefined) {
915
+ const others = Object.keys(received).filter((k) => k !== "save_findings");
916
+ if (others.length) {
917
+ const reason = `synthesis_save_mixed:${others.join(",").slice(0, 80)} — a save carries \`save_findings\` alone; the whole record arrives on its own call, which takes the saved findings`;
918
+ noteRefusalAt(dir0, reason, now);
919
+ return { written: null, refused: reason, captured: closeCapture({ ok: false, refused: reason }), capture_failed: captureFailed };
920
+ }
921
+ const r = saveFindings(dir0, received.save_findings, { manifest, method, owed, declined, recordOrigins: recordOriginsOr(recordOrigins), now });
922
+ for (const x of r.refused) noteRefusalAt(dir0, x.reason, now);
923
+ closeCapture({ ok: r.saved.length > 0, saved: r.saved.length, refused: r.refused.length });
924
+ return { written: null, refused: null, saved: r.saved, save_refused: r.refused, held: r.held, ...(r.write_failed ? { write_failed: r.write_failed } : {}), capture_failed: captureFailed };
925
+ }
911
926
  const stored = lastAcceptedCall(dir0);
912
927
  const read = readSynthesisCall(received, stored);
913
928
  let call = read.call;
@@ -952,6 +967,10 @@ export function recordSynthesis(runDir, received, opts = {}) {
952
967
  call = repeat.call;
953
968
  recordBlanking(dir0, { stage: "synthesis", layer: "call", blanked: repeat.blanked, kept: repeat.kept });
954
969
  }
970
+ // THE COMPLETING CALL TAKES THE SAVED FINDINGS for every position it does not carry itself, and the record-wide checks
971
+ // below judge the record that results. A patch repairs an accepted record and takes none.
972
+ const savedBefore = isPatch ? [] : readSavedFindings(dir0);
973
+ if (savedBefore.length) call = { ...call, findings: withSavedFindings(call.findings, savedBefore, owed).doc };
955
974
  const v = acceptSynthesis(call, { asks, ledger, manifest, method, methodInvalid, owed, declined, recordOrigins: origins });
956
975
  if (!v.ok) {
957
976
  noteRefusal(v.reason);
@@ -986,6 +1005,11 @@ export function recordSynthesis(runDir, received, opts = {}) {
986
1005
  // record the reviewer, the cards and the delivery read. Before the seam this leaves the record as written.
987
1006
  foldAfterSave(dir0, findingsAt);
988
1007
  recordReading(true);
1008
+ // The parts this record took are consumed; a part saved after it waits for the next completing call.
1009
+ if (savedBefore.length) {
1010
+ try { appendFileSync(savedFindingsPath(dir0), JSON.stringify({ at: now(), consumed: true, findings: savedBefore.length }) + "\n"); }
1011
+ catch { /* best-effort: the accepted record already holds them */ }
1012
+ }
989
1013
 
990
1014
  return {
991
1015
  written: [findingsAt, narrativeAt], refused: null,
@@ -995,6 +1019,148 @@ export function recordSynthesis(runDir, received, opts = {}) {
995
1019
  };
996
1020
  }
997
1021
 
1022
+ // ── A COMPLETED FINDING IS KEPT AS IT PASSES, BEFORE THE WHOLE RECORD EXISTS ─────────────────────────────────────────
1023
+ //
1024
+ // The writer recorded the report in one whole call, and nothing was kept until that call passed: on a test run of a
1025
+ // crowded matter, 2026-10-27, two writer attempts were stopped after hundreds of tool calls each and left nothing, so
1026
+ // each attempt started again from nothing. A completed finding is now saved as the writer completes it
1027
+ // (`save_findings`), checked as the whole record checks a finding, and kept in the run's own record, where a fresh
1028
+ // attempt, a fresh recovery ladder or a lost working folder cannot take it. The whole record still arrives on one call,
1029
+ // the completing call, which takes every saved finding for a position it does not carry itself; the record-wide checks
1030
+ // run there, as they always have. Intermediate saves write no report file, so nothing downstream reads a part as done.
1031
+ //
1032
+ // A SAVED FINDING IS KEYED BY THE POSITION IT ENDS, never by its ordinal: the ordinal is the finding's rank, which the
1033
+ // next finding found can move. The position is the declination spec's entry whose records the finding names, as the
1034
+ // duty reads it; a finding naming none of them is keyed by the records it names, or by its mark and owner.
1035
+ //
1036
+ // APPEND-ONLY, FOLDED ON READ: a kill in the middle of a save cannot cut an earlier one short, and two saves in one turn
1037
+ // cannot lose either. The latest save for a position stands; an identical re-save changes nothing; a different one is a
1038
+ // revision, and the record says how many there were. A saved finding naming a record the writer has declined is
1039
+ // refused by name: an accepted exclusion is never reversed by a save. Once a whole record is accepted the parts before
1040
+ // it are consumed, and a part saved after it waits for the next completing call.
1041
+
1042
+ /** Where the saved findings live: beside the writer's call records, under the run's `_driver/`. */
1043
+ export const savedFindingsPath = (runDir) => join(synthesisCallPaths(runDir).dir, "saved-findings.jsonl");
1044
+
1045
+ /** The position a finding ends, as the declination duty reads positions. PURE. */
1046
+ export function positionKeyOf(finding, owed = null) {
1047
+ const named = [...findingUris([finding]).keys()].map((u) => normalizeRecordUri(u)).filter(Boolean);
1048
+ for (const row of Array.isArray(owed) ? owed : []) {
1049
+ const head = normalizeRecordUri(row?.uri ?? row?.record_id);
1050
+ if (!head) continue;
1051
+ const members = new Set([head, ...(Array.isArray(row?.uris) ? row.uris : []).map((u) => normalizeRecordUri(u)).filter(Boolean)]);
1052
+ if (named.some((u) => members.has(u))) return `position:${head}`;
1053
+ }
1054
+ if (named.length) return `records:${[...new Set(named)].sort().join(",")}`;
1055
+ return `mark:${String(finding?.mark ?? "").trim().toLowerCase()}|${String(finding?.owner ?? "").trim().toLowerCase()}`;
1056
+ }
1057
+
1058
+ /**
1059
+ * The findings saved since the last accepted whole record, the latest per position, in the order first saved. A torn
1060
+ * line is skipped. IMPURE (reads the record). Returns `[{ key, finding, savedAt, revisions }]`.
1061
+ */
1062
+ export function readSavedFindings(runDir) {
1063
+ let raw = "";
1064
+ try { raw = readFileSync(savedFindingsPath(String(runDir ?? "")), "utf8"); } catch { return []; }
1065
+ let byKey = new Map();
1066
+ for (const line of raw.split("\n")) {
1067
+ if (!line.trim()) continue;
1068
+ let row;
1069
+ try { row = JSON.parse(line); } catch { continue; }
1070
+ if (row?.consumed) { byKey = new Map(); continue; }
1071
+ if (typeof row?.key !== "string" || !row.finding || typeof row.finding !== "object") continue;
1072
+ const had = byKey.get(row.key);
1073
+ byKey.set(row.key, { key: row.key, finding: row.finding, savedAt: String(row.at ?? ""), revisions: had ? had.revisions + (row.revised ? 1 : 0) : 0 });
1074
+ }
1075
+ return [...byKey.values()];
1076
+ }
1077
+
1078
+ const sameFinding = (a, b) => JSON.stringify(a) === JSON.stringify(b);
1079
+
1080
+ /**
1081
+ * Save completed findings, one by one: each passes or is refused on its own, and a refusal never touches a finding
1082
+ * already saved. IMPURE (appends to the record). Returns the answer the tool hands back.
1083
+ */
1084
+ export function saveFindings(runDir, list, { manifest = null, method = undefined, owed = null, declined = null, recordOrigins = null, now = () => new Date().toISOString() } = {}) {
1085
+ const dir0 = String(runDir ?? "");
1086
+ if (!Array.isArray(list) || !list.length)
1087
+ return { saved: [], refused: [{ index: null, reason: "synthesis_save_empty: `save_findings` is an array of one or more completed finding objects, each as the findings record carries it" }], held: readSavedFindings(dir0).length };
1088
+ const held = new Map(readSavedFindings(dir0).map((s) => [s.key, s]));
1089
+ const declinedSet = new Set((Array.isArray(declined) ? declined : []).map((u) => normalizeRecordUri(u)).filter(Boolean));
1090
+ const saved = [], refused = [], rows = [];
1091
+ list.forEach((f, index) => {
1092
+ let checked;
1093
+ try { checked = validateFindingForSave(f, { manifest, ...(method !== undefined ? { method } : {}), ...(Array.isArray(recordOrigins) ? { recordOrigins } : {}) }); }
1094
+ catch (e) { refused.push({ index, reason: `synthesis_save_invalid:${index}: ${String(e?.message ?? e).slice(0, 240)}` }); return; }
1095
+ const named = [...findingUris([f]).keys()].map((u) => normalizeRecordUri(u)).filter((u) => declinedSet.has(u));
1096
+ if (named.length) {
1097
+ refused.push({ index, reason: `synthesis_save_declined:${index}: you declined ${named.slice(0, 3).join(", ")} through record_declination, and a save does not reverse an exclusion. Leave that record declined, or keep it out of this finding` });
1098
+ return;
1099
+ }
1100
+ const key = positionKeyOf(f, owed);
1101
+ const before = held.get(key);
1102
+ if (before && sameFinding(before.finding, f)) { saved.push({ index, key, unchanged: true }); return; }
1103
+ rows.push({ at: now(), key, ...(before ? { revised: true } : {}), finding: f });
1104
+ held.set(key, { key, finding: f });
1105
+ saved.push({ index, key, ...(before ? { revised: true } : {}) });
1106
+ void checked;
1107
+ });
1108
+ if (rows.length) {
1109
+ try {
1110
+ mkdirSync(dirname(savedFindingsPath(dir0)), { recursive: true });
1111
+ appendFileSync(savedFindingsPath(dir0), rows.map((r) => JSON.stringify(r)).join("\n") + "\n");
1112
+ } catch (e) {
1113
+ // VALID AND NOT KEPT is infrastructure, never a refusal: the two have opposite repairs.
1114
+ return { saved: [], refused, held: readSavedFindings(dir0).length, write_failed: String(e?.message ?? e).slice(0, 200) };
1115
+ }
1116
+ }
1117
+ return { saved, refused, held: readSavedFindings(dir0).length };
1118
+ }
1119
+
1120
+ /**
1121
+ * The completing call's findings with every saved finding for a position the call does not carry, appended after the
1122
+ * call's own in the order they were saved, each numbered after the call's highest ordinal: the record's order is the
1123
+ * call's, and the fold renumbers the record contiguously as it always has. PURE.
1124
+ */
1125
+ export function withSavedFindings(doc, saved, owed = null) {
1126
+ if (!saved?.length || !doc || typeof doc !== "object" || !Array.isArray(doc.findings)) return { doc, taken: 0 };
1127
+ const carried = new Set(doc.findings.map((f) => positionKeyOf(f, owed)));
1128
+ let next = doc.findings.reduce((m, f) => (Number.isInteger(f?.ordinal) && f.ordinal > m ? f.ordinal : m), 0);
1129
+ const taken = saved.filter((s) => !carried.has(s.key)).map((s) => ({ ...s.finding, ordinal: ++next }));
1130
+ return { doc: { ...doc, findings: [...doc.findings, ...taken] }, taken: taken.length };
1131
+ }
1132
+
1133
+
1134
+ /**
1135
+ * HAND A LATER ATTEMPT WHAT THIS RUN ALREADY SAVED: write the saved findings to `synthesis-saved.json` in the run folder,
1136
+ * each as it was accepted, with the evidence it carries, and return what the attempt's message says about them: how
1137
+ * many were saved, how many records were declined, and the positions still owed. null when nothing is saved, so a first
1138
+ * attempt's message is unchanged; null too when the file could not be written, since a message must never name a file
1139
+ * that is not there. `outstanding` is null when the duty cannot be computed (no declination spec, an unreadable
1140
+ * ledger), never an empty list. IMPURE.
1141
+ */
1142
+ export function handSavedFindings(runDir) {
1143
+ const dir0 = String(runDir ?? "");
1144
+ const saved = readSavedFindings(dir0);
1145
+ if (!saved.length) return null;
1146
+ const { owed, declined } = driverReadsFor(dir0);
1147
+ const duty = reconcileDeclinationDuty({ owed, deliveredUris: [...findingUris(saved.map((s) => s.finding)).keys()], declinedUris: declined });
1148
+ try {
1149
+ const p = join(dir0, SAVED_FINDINGS_FILE);
1150
+ writeFileSync(`${p}.tmp`, JSON.stringify({ saved_findings: saved.map((s) => s.finding) }, null, 2) + "\n");
1151
+ renameSync(`${p}.tmp`, p);
1152
+ } catch { return null; }
1153
+ return {
1154
+ findings: saved.length,
1155
+ declined: Array.isArray(declined) ? declined.length : 0,
1156
+ outstanding: duty.computable ? duty.unaccounted.map((r) => `${r.mark ? `${r.mark}${r.owner ? ` — ${r.owner}` : ""} ` : ""}(${r.uri})`) : null,
1157
+ };
1158
+ }
1159
+
1160
+ const noteRefusalAt = (runDir, reason, now) => {
1161
+ try { appendFileSync(synthesisCallPaths(runDir).refusals, JSON.stringify({ at: now(), reason }) + "\n"); } catch { /* best-effort */ }
1162
+ };
1163
+
998
1164
  /** Every call this run turned away, in order — the run's own record that a defect was met and corrected. */
999
1165
  export function refusalsFor(runDir) {
1000
1166
  try {
package/driver/verify.mjs CHANGED
@@ -2689,12 +2689,12 @@ function placementAccountVerdict(dir) {
2689
2689
  const rid = rowOf.get(id), cause = rid ? causeOf.get(rid) : null;
2690
2690
  return `${id}${group ? ` in (${group})` : ""}${cause ? `, its row ${rid} (${cause})` : ""}`;
2691
2691
  });
2692
- const finishRow = floors.some((f) => f.includes(", its row ")) ? " Where a floor's row is named, finish that row: a floor's row owes a tier, "
2693
- + "sheet-2 at the least, and a reason stating the ground." : "";
2692
+ const finishRow = floors.some((f) => f.includes(", its row ")) ? " Where a floor's row is named, finish that row: give it "
2693
+ + "headline-candidate or sheet-2, and a reason stating the ground." : "";
2694
2694
  if (unjudged.length) {
2695
2695
  const named = unjudged.slice(0, 8).map((u) => `${u.row_id ?? u.select ?? "?"} (${u.cause})`).join(", ");
2696
- const floorsToo = floors.length ? ` The form gives these floors no fate either: ${capped(floors)}. Place each by `
2697
- + `selecting that record, sheet-2 at the least, or set its line aside by name on its status or its territory.${finishRow}` : "";
2696
+ const floorsToo = floors.length ? ` The form gives these floors no fate either: ${capped(floors)}. Select each at `
2697
+ + `headline-candidate or sheet-2, or set its line aside by name.${finishRow}` : "";
2698
2698
  return fail(`placement_unjudged=${unjudged.length}: ${named}${unjudged.length > 8 ? ", …" : ""} — each of these rows is `
2699
2699
  + "in placement-form.json and its own judgement will not render: a selected row owes a `tier` from the closed set and a `reason` "
2700
2700
  + "stating the ground; a row you wrote in full owes mark, owner and jurisdiction too. Finish these rows in the form; "
@@ -2755,8 +2755,8 @@ function placementSaidBackVerdict({ refused, floors, also, finishRow = "" }) {
2755
2755
  if (floors.length)
2756
2756
  return fail(`placement_floors_without_fate=${floors.length}: ${capped(floors)} — each is a floor, a live identical or `
2757
2757
  + "near-identical mark in an ordered class, and the form gives it no fate. "
2758
- + "Place it by selecting that record, sheet-2 at the least, or set its line aside by name on its status or its "
2759
- + `territory.${finishRow} Only these are owed; every other row stands.${also}`);
2758
+ + "Select it at headline-candidate or sheet-2, or set its line aside by name."
2759
+ + `${finishRow} Only these are owed; every other row stands.${also}`);
2760
2760
  if (refused.length)
2761
2761
  return fail(`placement_refused=${refused.length}: ${capped(refused)} — the form refused each of these for the reason given `
2762
2762
  + "with it, and nothing else on the form moved. Send each again corrected, or leave it out; every other row stands.");
@@ -1,5 +1,13 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.5.0-beta.4
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.5.0-beta.3
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.5.0-beta.2
4
12
 
5
13
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.5.0-beta.2",
3
+ "version": "0.5.0-beta.4",
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
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron",
3
3
  "mcpName": "io.github.CordilleraSarl/clearotron",
4
4
  "type": "module",
5
- "version": "0.5.0-beta.2",
5
+ "version": "0.5.0-beta.4",
6
6
  "license": "AGPL-3.0-only",
7
7
  "repository": {
8
8
  "type": "git",
@@ -2,7 +2,7 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.5.0-beta.2",
5
+ "version": "0.5.0-beta.4",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
8
8
  "engines": {
@@ -24,6 +24,7 @@ documents both: `countProbe` (`"endpoint"` / `"cheap"` / `"none"`) says where a
24
24
  | `term-shape.mjs` | Whether a string is a searchable mark term and agrees with its predicate (`{ predicate: "exact", term: "WAVO*" }` is a silent clean). |
25
25
  | `territory-codes.mjs` | Display name → ISO 3166 / WIPO ST.3 code. `""` means worldwide, `null` means unknown — and never goes to the wire. |
26
26
  | `provider-text.mjs` | Clips a vendor message from the MIDDLE, because the word that classifies a refusal sits at the end of the sentence. |
27
+ | `send-until.mjs` | The time after which a plan call sends no further question, handed to each turn's tool servers ahead of the turn's deadline, so a call cut short returns what it holds. |
27
28
  | `test/` | Twelve files, provider-agnostic by design — several drive every adapter's real code rather than fixtures of what we believe it returns. |
28
29
 
29
30
  ## What reads it
@@ -57,6 +57,7 @@ import {
57
57
  import { makeCountProbe, parseToolText, isToolError } from "./count.mjs"; import { floorOf, moreThan } from "../../shared/register-floor.mjs";
58
58
  import { guardCountCall, guardToolCall } from "./transport-guard.mjs";
59
59
  import { clipProviderText } from "./provider-text.mjs"; // — keep the discriminator
60
+ import { pastSendUntil } from "./send-until.mjs"; // past a call's time to stop sending, a timed-out question is not split again
60
61
 
61
62
  // ── — HOW MUCH OF A PROVIDER ERROR SURVIVES INTO THE BAND BLOCK: ALL OF IT ───────────────────────────
62
63
  //
@@ -448,7 +449,7 @@ export function makeEnumerate(deps) {
448
449
  }
449
450
  // A half that timed out while its sibling answered is the one place fewer regions may help: it is
450
451
  // halved in turn, under the same rule.
451
- const r = timedOut({ r: first })
452
+ const r = timedOut({ r: first }) && !pastSendUntil(tctx)
452
453
  ? await regionHalves(auth, { ...params, regions: half }, tctx, { ceiling, incomplete, cause: parseToolText(first)?.reason ?? cause, hear })
453
454
  : first;
454
455
  const parsed = isToolError(r) ? null : parseToolText(r);
@@ -637,7 +638,7 @@ export function makeEnumerate(deps) {
637
638
  + `Provider's own words: ${clipProviderText(c?.reason ?? "", COUNT_PROBE_BUDGET)}`,
638
639
  { crowd_basis: "provider-refused-count", count_unavailable: true });
639
640
  }
640
- if (splittable && isGatewayTimeout(c?.reason)) return regionHalves(auth, params, tctx, { ceiling, incomplete, cause: String(c.reason), hear });
641
+ if (splittable && isGatewayTimeout(c?.reason) && !pastSendUntil(tctx)) return regionHalves(auth, params, tctx, { ceiling, incomplete, cause: String(c.reason), hear });
641
642
  return incomplete(0, 0, [],
642
643
  `provider error on the count probe before enumeration: ${String(c?.reason ?? "count unavailable")}`, timeoutMark(c?.reason));
643
644
  }
@@ -670,7 +671,7 @@ export function makeEnumerate(deps) {
670
671
  if (page === 0 && allNames && allNames.length > 1 && listRefusal?.test(String(r.text ?? ""))) {
671
672
  return enumerateQuestion(auth, params, tctx, { split, width: Math.ceil(allNames.length / 2) }, said);
672
673
  }
673
- if (splittable && isGatewayTimeout(r.text)) return regionHalves(auth, params, tctx, { ceiling, incomplete, cause: String(r.text), hear });
674
+ if (splittable && isGatewayTimeout(r.text) && !pastSendUntil(tctx)) return regionHalves(auth, params, tctx, { ceiling, incomplete, cause: String(r.text), hear });
674
675
  return incomplete(total, results.length, results, `provider error during enumeration (page ${page}): ${String(r.text ?? "")}`, timeoutMark(r.text));
675
676
  }
676
677
  const parsed = parseToolText(r);
@@ -20,6 +20,9 @@ import { nativeScriptIndexGap } from "./script-form.mjs"; import { floorOf, more
20
20
  import { entryTermIssues, goodsTermsList } from "./term-shape.mjs";
21
21
  import { awaitsReadingTurn, releasedFamiliesFile } from "./plan-guards.mjs";
22
22
  import { FOLLOW_UP_SEARCH_LIMIT, runDriverDirOf, followUpRoom, followUpClass, identicalMarkTerms, recordFollowUpSearch, recordOutstanding } from "./follow-up-searches.mjs";
23
+ import { bandWithSlicesOut, slicesTakenOutByAxis } from "../../shared/slices-taken-out.mjs";
24
+ import { pathInRun } from "../../shared/inside-run.mjs"; // a plan or a band path is served only inside this run
25
+ import { pastSendUntil } from "./send-until.mjs"; // a call past its time to stop sending sends no new question // a band read as answers, with the slices a check could not prove ran taken out
23
26
 
24
27
  /** How soon after an answer lands it is written to the band, while the call goes on (see `keepAnswers` below). */
25
28
  export const KEEP_ANSWERS_AFTER_MS = 2000;
@@ -344,6 +347,14 @@ export function makeExecutePlan(deps) {
344
347
  const outPath = String(params?.output_path ?? "");
345
348
  if (!planPath || !axis || !outPath)
346
349
  return { type: "text", text: "ERROR: plan_path, axis and output_path are all required." };
350
+ // THIS RUN'S PLAN, AND THIS RUN'S BAND (shared/inside-run.mjs). The run is the one its record log names, never the
351
+ // call's; a search outside a run has none to confine it to.
352
+ const runOf = runDriverDirOf(tctx?.recordLog);
353
+ if (runOf) {
354
+ const run = dirname(runOf);
355
+ if (pathInRun(run, planPath) === null) return { type: "text", text: "ERROR: plan_path is outside this run's folder. Use the frozen plan path the driver gave you." };
356
+ if (pathInRun(run, outPath) === null) return { type: "text", text: "ERROR: output_path is outside this run's folder. Use the band path the driver gave you." };
357
+ }
347
358
  let plan;
348
359
  try { plan = JSON.parse(readFileSync(planPath, "utf8")); }
349
360
  catch (err) { return { type: "text", text: `ERROR: cannot read the plan at ${planPath}: ${err.message}` }; }
@@ -375,10 +386,20 @@ export function makeExecutePlan(deps) {
375
386
  try { identicalTerms = identicalMarkTerms(JSON.parse(readFileSync(join(driverDir, "register-plan.json"), "utf8"))); } catch { /* no frozen plan: no identical terms known */ }
376
387
  }
377
388
  const refused = [];
389
+ // THE BAND AS ANSWERS, for the checks below that read it so: a slice the run's searches record took out of the band,
390
+ // because a check could not prove it ran, is read as the open slice it was delivered as, whatever wrote the band
391
+ // since (shared/slices-taken-out.mjs). The run is the band's own, as the propose tool reads it. The writes below
392
+ // still merge the file as it stands.
393
+ const asAnswers = (file) => {
394
+ const prior = JSON.parse(readFileSync(file, "utf8"));
395
+ return Array.isArray(prior) ? bandWithSlicesOut(prior, slicesTakenOutByAxis(dirname(dirname(outPath))).get(axis)).blocks : prior;
396
+ };
397
+ const bandAsAnswers = () => asAnswers(outPath);
378
398
  // The follow-ups this axis's band already answers, read once and only when the net meets a question sent before:
379
- // an answered one adds nothing when it is sent again, and a failed one counts again. A unit whose pass was killed at
380
- // its time limit is parked: its band is renamed (`<band>.tainted-<n>`) so the resume runs the unit again, and the
381
- // re-run sends the same follow-ups. Their answers are in the renamed band, so they count nothing either.
399
+ // an answered one adds nothing when it is sent again, and a failed one counts again, as does one taken out. A unit
400
+ // whose pass was killed at its time limit is parked: its band is renamed (`<band>.tainted-<n>`) so the resume runs the
401
+ // unit again, and the re-run sends the same follow-ups. Their answers are in the renamed band, so they count nothing
402
+ // either. Every band on record is read as answers, so a slice taken out counts again whichever band holds it.
382
403
  let answeredQids = null;
383
404
  // This axis's band, and the bands a parked pass left (`<band>.tainted-<n>`), oldest first and the band's own last.
384
405
  const bandsOnRecord = () => {
@@ -393,7 +414,7 @@ export function makeExecutePlan(deps) {
393
414
  answeredQids = new Set();
394
415
  for (const band of bandsOnRecord()) {
395
416
  try {
396
- const prior = JSON.parse(readFileSync(band, "utf8"));
417
+ const prior = asAnswers(band);
397
418
  for (const b of Array.isArray(prior) ? prior : []) if (b?.qid && b.error !== true) answeredQids.add(String(b.qid));
398
419
  } catch { /* no band yet: nothing answered */ }
399
420
  }
@@ -426,7 +447,7 @@ export function makeExecutePlan(deps) {
426
447
  const seeded = new Set();
427
448
  if (qidsFilter) {
428
449
  try {
429
- const existing = JSON.parse(readFileSync(outPath, "utf8"));
450
+ const existing = bandAsAnswers(); // a parent taken out of the band releases no child
430
451
  if (Array.isArray(existing)) {
431
452
  for (const b of existing) {
432
453
  if (b && typeof b === "object" && b.qid && typeof b.state === "string" && !qidsFilter.has(b.qid)) {
@@ -695,7 +716,11 @@ export function makeExecutePlan(deps) {
695
716
  // change the answer. An unhandled fault here is an unknown of unknown durability, so it keeps the
696
717
  // transient reading. The reason names it an EXECUTOR fault so it is never read as something the
697
718
  // register said — a block that fabricated a provider response would be the worse defect.
719
+ // PAST ITS TIME TO STOP SENDING, A CALL SENDS NO NEW QUESTION (send-until.mjs). The question is not reached by
720
+ // this call and keeps whatever block the band holds for it; the call returns what it has, naming it.
721
+ const notReached = [];
698
722
  const runEntry = async (e) => {
723
+ if (pastSendUntil(tctx)) { notReached.push(e.qid); return; }
699
724
  if (driverDir && followUpClass(e, { identicalTerms }) === "counted") {
700
725
  const room = followUpRoom(driverDir, FOLLOW_UP_SEARCH_LIMIT, e, { answered: answeredBefore });
701
726
  if (!room.open) {
@@ -754,8 +779,8 @@ export function makeExecutePlan(deps) {
754
779
  // fringe) gets no block and any stale one is dropped (a fringe slice must never outlive its
755
780
  // parent's crowd verdict). This makes the initial call and every warm followup the SAME call.
756
781
  // A follow-up the net did not send is not owned by this call: whatever answer it already had stands.
757
- const refusedSet = new Set(refused);
758
- const owned = new Set(targeted.map((e) => e.qid).filter((q) => !refusedSet.has(q)));
782
+ const refusedSet = new Set(refused), notReachedSet = new Set(notReached);
783
+ const owned = new Set(targeted.map((e) => e.qid).filter((q) => !refusedSet.has(q) && !notReachedSet.has(q)));
759
784
  let preserved = [];
760
785
  try {
761
786
  const existing = JSON.parse(readFileSync(outPath, "utf8"));
@@ -793,6 +818,7 @@ export function makeExecutePlan(deps) {
793
818
  states: Object.fromEntries([...stateByQid].filter(([q]) => !seeded.has(q))),
794
819
  ...(Object.keys(spellingCounts).length ? { spelling_counts: spellingCounts } : {}),
795
820
  ...(refused.length ? { outstanding: refused } : {}),
821
+ ...(notReached.length ? { not_reached: notReached } : {}),
796
822
  }, null, 2) };
797
823
  };
798
824
  }
@@ -0,0 +1,25 @@
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
+ //
4
+ // send-until.mjs — the time after which a plan call sends no further question to the register.
5
+ //
6
+ // A plan call runs inside a model's turn, and the turn has a deadline. When the call outlasted it, the call and the
7
+ // turn ended together, so the unit could not write what the call had answered: on a production run on 2026-10-21 a
8
+ // unit's plan search ran into the register's gateway timeouts on every attempt and never wrote its result. The
9
+ // driver now hands each turn's tool servers a time to stop sending (CLEAROTRON_GATHER_SEND_UNTIL_MS, set per turn by
10
+ // the gateway, ahead of the turn's own deadline by a reserve). Past it, the executor sends no new question and a
11
+ // timed-out question is not split again; the call returns what it holds and the turn has the time to write it.
12
+ //
13
+ // A pure leaf. A call with no time to stop sending, the driver's own dispatches among them, runs as it always did.
14
+
15
+ /** The gateway's reserve for a turn of `timeoutSec`: a quarter of the turn, and never more than five minutes. */
16
+ export const sendReserveMs = (timeoutSec) => Math.min(300_000, Math.max(0, Number(timeoutSec) || 0) * 250);
17
+
18
+ /** Whether a call given `tctx` is past its time to stop sending. False when it has none. */
19
+ export function pastSendUntil(tctx, now = Date.now()) {
20
+ const until = Number(tctx?.sendUntilMs);
21
+ return Number.isFinite(until) && until > 0 && now >= until;
22
+ }
23
+
24
+ /** The call context's time to stop sending, read from the environment a turn's tool server was started with. */
25
+ export const sendUntilFromEnv = (env = process.env) => Number(env.CLEAROTRON_GATHER_SEND_UNTIL_MS) || null;
@@ -1,5 +1,13 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.5.0-beta.4
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.5.0-beta.3
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.5.0-beta.2
4
12
 
5
13
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.5.0-beta.2",
3
+ "version": "0.5.0-beta.4",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "OAuth 2.1 MCP stdio bridge used by the engine's case-law gather stage (courtlistener / legaldatahunter).",
@@ -65,7 +65,7 @@ if (!/^\d+$/.test(String(opts.seed)) || Number(opts.seed) <= 0)
65
65
 
66
66
  // ── the inputs deriveBandShape uses, read the same way it reads them ─────────────────────────────────
67
67
  //
68
- // Deliberately mirrors driver/pipeline.mjs:441-490 mergeRegisterBands rather than inventing a gathering step. If the two
68
+ // Deliberately mirrors driver/pipeline.mjs:501-563 mergeRegisterBands rather than inventing a gathering step. If the two
69
69
  // diverge, this probe measures a shape the run never had.
70
70
 
71
71
  const runDir = opts.run;
@@ -76,7 +76,7 @@ export const COUNTED_EVENTS = {
76
76
  "post-repair-correction-unrecoverable": FAILED, "post-repair-state-not-kept": FAILED,
77
77
  "digest-flush-timeout": FAILED, "document-coverage-render-failed": FAILED, "doubts-failed": FAILED,
78
78
  "evidence-claim-invariant": { kind: FAILED, when: (e) => e.ok === false || Number(e.violations) > 0 },
79
- "floor-duty-failed": FAILED, "frame-reopen-redigest-failed": FAILED, "goods-evidence-failed": FAILED, "placement-decisions-failed": FAILED, "placement-band-ids-failed": FAILED, "late-evidence-failed": FAILED, "band-refused-records-failed": FAILED, "verdict-write-deferred": FAILED, "ranking-read-by-writer-failed": FAILED, "audit-rebuild-failed": FAILED, "audit-build-failed": FAILED, "write-up-goods-from-failed": FAILED, "house-element-ownership-failed": FAILED,
79
+ "floor-duty-failed": FAILED, "frame-reopen-redigest-failed": FAILED, "goods-evidence-failed": FAILED, "placement-decisions-failed": FAILED, "placement-band-ids-failed": FAILED, "late-evidence-failed": FAILED, "band-refused-records-failed": FAILED, "verdict-write-deferred": FAILED, "ranking-read-by-writer-failed": FAILED, "audit-rebuild-failed": FAILED, "audit-build-failed": FAILED, "write-up-goods-from-failed": FAILED, "declination-goods-states-failed": FAILED, "house-element-ownership-failed": FAILED,
80
80
  "intake-asks-missing": FAILED, "invalid-artifact-strikes": FAILED, "knockout-band-checks-inert": FAILED,
81
81
  "knockout-in-use-as-unanswered": FAILED, "knockout-ladder-unreadable": FAILED, "knockout-method-checks-inert": FAILED,
82
82
  "knockout-owner-checks-failed": FAILED, "knockout-register-records-failed": FAILED, "knockout-sweep-outage": FAILED,
@@ -231,6 +231,7 @@ export const NOT_COUNTED_EVENTS = {
231
231
  ],
232
232
  "the twin of an incident counted under another record — its dispatch, its repair, its clamp or its failure": [
233
233
  "assessment-not-completed-delivery", "part-completed", "overview-not-completed-delivery", "review-not-completed-delivery",
234
+ "core-artifact-restored", "searches-not-completed-delivery", "unit-not-completed-delivery",
234
235
  "action-reemit", "ask-answer-reemit", "authority-write-denied", "connotation-remedy", "corrections-applied",
235
236
  "corrective-cycle-receipt", "corrective-cycle-settled", "corrective-findings-stale", "coverage-absence-rendered",
236
237
  "coverage-closure", "coverage-ledger-recovered", "delivery-stale-repair", "delivery-stale-requeued", "envelope-close-rows", "escalation-noop", "floor-duty-after-repair", "writer-duty-after-repair", "writer-duty-after-correction", "guard-clause-replaced", "guard-gap-closed", "verdict-recomputed-gap-closed", "guard-clause-named-elsewhere", "audit-rebuilt", "audit-withdrawn", "record-carry-doubts-again",
@@ -29,7 +29,7 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
29
29
  export const GROUPS = ["Before you upgrade", "New", "Fixed", "For operators"];
30
30
 
31
31
  /** At most this many words in a sentence. The contract's number, not a tuned one. */
32
- export const MAX_WORDS = 25;
32
+ export const MAX_WORDS = 26;
33
33
 
34
34
  /**
35
35
  * Our internal vocabulary. Every one of these is a word we use fluently and the reader has never met in
@@ -0,0 +1,54 @@
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
+ //
4
+ // inside-run.mjs — is a path a tool was handed inside THIS run's folder, where it really is?
5
+ //
6
+ // A tool that takes a path served whatever the account could open. Artifact search refused an absolute path and `..`,
7
+ // and still followed a link placed inside the run folder to wherever it pointed; the grid tools took a spec in any
8
+ // run's folder; the register tools read a plan and wrote a band at any path. A run may read nothing from another run,
9
+ // so a tool now resolves the path to its real location, links and `..` included, and serves it only when that
10
+ // location is inside the run's own real folder.
11
+ //
12
+ // A path not written yet is judged by its nearest folder that exists, plus the names below it, none of which may be
13
+ // `..`. A run folder that cannot be resolved holds nothing.
14
+ //
15
+ // A pure leaf: node:fs and node:path only, so every tool server can import it.
16
+ import { lstatSync, realpathSync } from "node:fs";
17
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
18
+
19
+ /** The real location of `p`, or of its nearest existing folder plus the names below it; null when neither resolves. */
20
+ function realLocation(p) {
21
+ const rest = [];
22
+ let at = resolve(p);
23
+ for (;;) {
24
+ try { return join(realpathSync(at), ...rest.reverse()); }
25
+ catch (e) {
26
+ if (e?.code !== "ENOENT" && e?.code !== "ENOTDIR") return null;
27
+ // A link whose target is missing is not a name still to be written: a write through it lands where it points.
28
+ try { lstatSync(at); return null; } catch { /* nothing at all is there */ }
29
+ const up = dirname(at);
30
+ if (up === at) return null;
31
+ rest.push(basename(at));
32
+ at = up;
33
+ }
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Where `p` really is relative to the run folder `runDir` — a relative path that never starts with `..` — or null when
39
+ * it is outside the run, or either cannot be resolved. "" is the run folder itself. PURE but for the reads of the disk.
40
+ */
41
+ export function pathInRun(runDir, p) {
42
+ if (typeof runDir !== "string" || !runDir || typeof p !== "string" || !p) return null;
43
+ let root;
44
+ try { root = realpathSync(runDir); } catch { return null; }
45
+ const real = realLocation(isAbsolute(p) ? p : join(runDir, p));
46
+ if (!real) return null;
47
+ const rel = relative(root, real);
48
+ if (rel === "") return "";
49
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;
50
+ return rel;
51
+ }
52
+
53
+ /** True when `p` is really inside the run folder `runDir`. */
54
+ export const insideRun = (runDir, p) => pathInRun(runDir, p) !== null;
@@ -71,6 +71,7 @@ export const NAMES_IN_FORCE = Object.freeze([
71
71
  "CLEAROTRON_FRAME_REOPEN_MAX",
72
72
  "CLEAROTRON_GATHER_AGENT",
73
73
  "CLEAROTRON_GATHER_CONCURRENCY",
74
+ "CLEAROTRON_GATHER_SEND_UNTIL_MS",
74
75
  "CLEAROTRON_GATHER_SESSION_ID",
75
76
  "CLEAROTRON_GATHER_SESSION_KEY",
76
77
  "CLEAROTRON_HARD_MS",