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.
- package/.env.example +7 -0
- package/build-info.json +2 -2
- package/docs/architecture/03-run-lifecycle.md +6 -0
- package/docs/architecture/04-configuration-reference.md +1 -0
- package/docs/architecture/05-config-governance.md +1 -1
- package/driver/CHANGELOG.md +21 -0
- package/driver/band-shape.mjs +19 -11
- package/driver/contract-vocabulary.mjs +3 -3
- package/driver/coverage-form-io.mjs +3 -1
- package/driver/coverage-form.mjs +21 -2
- package/driver/coverage-ledger.mjs +7 -1
- package/driver/declination-call.mjs +33 -16
- package/driver/degraded-parts.mjs +6 -0
- package/driver/engine/mcp/band-server.mjs +12 -12
- package/driver/engine/mcp/clarivate-server.mjs +2 -0
- package/driver/engine/mcp/codex-config.mjs +12 -7
- package/driver/engine/mcp/corsearch-server.mjs +2 -0
- package/driver/engine/mcp/declination-server.mjs +4 -5
- package/driver/engine/mcp/dispositions-server.mjs +7 -0
- package/driver/engine/mcp/euipo-server.mjs +2 -0
- package/driver/engine/mcp/free-tier-server.mjs +2 -0
- package/driver/engine/mcp/gather-config.mjs +15 -0
- package/driver/engine/mcp/held-answers.mjs +11 -3
- package/driver/engine/mcp/perplexity-server.mjs +29 -1
- package/driver/engine/mcp/recording-server.mjs +16 -1
- package/driver/engine/mcp/signa-server.mjs +2 -0
- package/driver/engine/mcp/supplemental.mjs +11 -3
- package/driver/engine/mcp/uspto-local-server.mjs +2 -0
- package/driver/engine/openai-agent.mjs +48 -9
- package/driver/envelope-settle.mjs +13 -3
- package/driver/findings-model.mjs +17 -0
- package/driver/gateway.mjs +31 -10
- package/driver/package.json +1 -1
- package/driver/parts-not-completed.mjs +52 -11
- package/driver/pipeline-knockout.mjs +5 -4
- package/driver/pipeline.mjs +474 -81
- package/driver/placement-decisions.mjs +24 -9
- package/driver/placement-form-io.mjs +21 -1
- package/driver/placement-form.mjs +25 -15
- package/driver/placement-union.mjs +53 -8
- package/driver/predelivery-lint.mjs +8 -1
- package/driver/publish/index.mjs +7 -3
- package/driver/register-digest-record.mjs +6 -3
- package/driver/research-answers.mjs +54 -7
- package/driver/runner.mjs +4 -4
- package/driver/saved-findings-block.mjs +36 -0
- package/driver/skills/clearance-register/digest.md +1 -1
- package/driver/skills/clearance-search/phase2-execution.md +7 -5
- package/driver/skills/clearance-search/synthesis-rules.md +3 -3
- package/driver/skills/placement-inquiry/SKILL.md +3 -3
- package/driver/stage-context.mjs +18 -0
- package/driver/stages.mjs +41 -13
- package/driver/suite-census.json +140 -14
- package/driver/synthesis-record.mjs +167 -1
- package/driver/verify.mjs +6 -6
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/_shared/README.md +1 -0
- package/providers/_shared/enumerate.mjs +4 -3
- package/providers/_shared/execute-plan.mjs +33 -7
- package/providers/_shared/send-until.mjs +25 -0
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/band-shape-probe.mjs +1 -1
- package/scripts/e2e-first-time.mjs +2 -1
- package/scripts/release-notes-lint.mjs +1 -1
- package/shared/inside-run.mjs +54 -0
- package/shared/names-in-force.mjs +1 -0
- package/shared/slices-taken-out.mjs +65 -0
- package/skills/clearotron-account/SKILL.md +1 -1
- 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:
|
|
2693
|
-
+ "sheet-2
|
|
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)}.
|
|
2697
|
-
+ `
|
|
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
|
-
+ "
|
|
2759
|
-
+
|
|
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.");
|
package/mcp-server/CHANGELOG.md
CHANGED
package/mcp-server/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-artifacts-mcp",
|
|
3
|
-
"version": "0.5.0-beta.
|
|
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
package/portal-ui/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "portal-ui",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.5.0-beta.
|
|
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
|
|
380
|
-
// its time limit is parked: its band is renamed (`<band>.tainted-<n>`) so the resume runs the
|
|
381
|
-
// re-run sends the same follow-ups. Their answers are in the renamed band, so they count nothing
|
|
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 =
|
|
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 =
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-oauth-mcp-bridge",
|
|
3
|
-
"version": "0.5.0-beta.
|
|
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:
|
|
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 =
|
|
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",
|