clearotron 0.5.0-beta.3 → 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/build-info.json +2 -2
- package/docs/architecture/03-run-lifecycle.md +6 -0
- package/driver/CHANGELOG.md +10 -0
- package/driver/band-shape.mjs +15 -7
- package/driver/engine/mcp/band-server.mjs +12 -12
- package/driver/engine/mcp/codex-config.mjs +12 -7
- package/driver/engine/mcp/dispositions-server.mjs +7 -0
- package/driver/engine/mcp/perplexity-server.mjs +29 -1
- package/driver/engine/mcp/recording-server.mjs +16 -1
- package/driver/engine/mcp/supplemental.mjs +6 -1
- package/driver/engine/openai-agent.mjs +48 -9
- package/driver/findings-model.mjs +17 -0
- package/driver/package.json +1 -1
- package/driver/pipeline.mjs +38 -9
- package/driver/placement-decisions.mjs +20 -5
- package/driver/predelivery-lint.mjs +8 -1
- package/driver/register-digest-record.mjs +6 -3
- package/driver/research-answers.mjs +54 -7
- package/driver/saved-findings-block.mjs +36 -0
- package/driver/skills/clearance-search/phase2-execution.md +4 -2
- package/driver/stage-context.mjs +18 -0
- package/driver/stages.mjs +36 -8
- package/driver/suite-census.json +56 -2
- package/driver/synthesis-record.mjs +167 -1
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/_shared/execute-plan.mjs +9 -0
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/release-notes-lint.mjs +1 -1
- package/shared/inside-run.mjs +54 -0
package/build-info.json
CHANGED
|
@@ -438,6 +438,7 @@ node pipeline.mjs --job <file.json> [--agent <id>] # cold run (still take
|
|
|
438
438
|
node pipeline.mjs --resume <codename> # idempotent resume: valid outputs skip
|
|
439
439
|
node pipeline.mjs --resume <codename> --from <stage> # force re-run from a stage ordinal
|
|
440
440
|
node pipeline.mjs --resume <codename> --experiment <stage> [--label <t>] # shadow dir
|
|
441
|
+
node pipeline.mjs --resume <codename> --experiment synthesis --research replay # research from the run's own answers
|
|
441
442
|
```
|
|
442
443
|
|
|
443
444
|
- **Resume** refuses a delivered run (any of the three delivered markers above — re-handing off a
|
|
@@ -448,6 +449,11 @@ node pipeline.mjs --resume <codename> --experiment <stage> [--label <t>]
|
|
|
448
449
|
- **`--experiment`** runs one stage in a shadow dir (`_experiments/<ts>-<tag>/`) on copies of its
|
|
449
450
|
inputs, under a `clearance-exp-…` session key that is excluded from the run's provider-usage
|
|
450
451
|
attribution. The canonical run is untouched.
|
|
452
|
+
- **`--research replay`** answers the arm's research from the answers the run saved
|
|
453
|
+
(`_driver/research-answers.jsonl`, copied into the shadow dir). A question the run asked gets the
|
|
454
|
+
run's latest answer to it; a question it did not ask, and any grid, is refused, and nothing is sent
|
|
455
|
+
to the research service. It is refused for a stage that holds no research tool, or a run that saved
|
|
456
|
+
no answers. The arm's `experiment-context.json` records `research: {mode, answers}`.
|
|
451
457
|
- **Orphan self-resume**: a manually resumed run that parks has no queue sidecars; the runner scans
|
|
452
458
|
run dirs for due, payload-complete `.postponed` sentinels not owned by any queue and resumes them
|
|
453
459
|
(claim = atomic rename to `.resuming`).
|
package/driver/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# clearotron-driver
|
|
2
2
|
|
|
3
|
+
## 0.5.0-beta.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Fixed: On the Codex engine, a clearance no longer sees temporary files that another clearance left on the same machine.
|
|
8
|
+
- Fixed: A report's copy for the client is no longer held back when a live finding shares its mark and owner with a finding the review withdrew.
|
|
9
|
+
- Fixed: A register entry the engine decided to keep as background now appears in the report's context table, instead of being left out of it.
|
|
10
|
+
- Fixed: When the report's writing step is interrupted, the next attempt continues from the findings it had already finished instead of starting again.
|
|
11
|
+
- Fixed: On a crowded field, the report is written from the work the clearance has already done, without reading every register result again.
|
|
12
|
+
|
|
3
13
|
## 0.5.0-beta.3
|
|
4
14
|
|
|
5
15
|
### Patch Changes
|
package/driver/band-shape.mjs
CHANGED
|
@@ -1135,18 +1135,26 @@ export const AN_UNREAD_LISTS_FLOOR = "Place each floor here, or set it aside by
|
|
|
1135
1135
|
// alone, as its other orders are: every other stage keeps the markers as evidence and is not told of a fate.
|
|
1136
1136
|
export const AN_UNREAD_LINE_IS_CHECKED = 'A line marked "on file, not readable", "cut by the register, no goods word found", "goods held; completeness not established" or "no goods text held; completeness not established" must be checked with band_record before its fate. Read the evidence held for it, keeping its source and goods completeness clear. A line marked "no goods on file", "fetch failed, no goods on file" or "the register\'s record lists no goods" has no goods text on file. Missing goods do not establish that the goods are unrelated.';
|
|
1137
1137
|
export const PLACEMENT_LINE_ORDERS = Object.freeze([FLOORS_BY_NAME_HERE, A_CROWDS_ONE_ROW, A_CROWDS_ROW_COVERS_A_READ_FLOOR, AN_UNREAD_CROWDS_ONE_ROW, AN_UNREAD_CROWDS_FLOOR, AN_UNREAD_LISTS_FLOOR, AN_UNREAD_LINE_IS_CHECKED]);
|
|
1138
|
+
// AND THE SIZE RULE'S ORDERS TO READ (ruled 2026-10-27). A list's size sentence tells placement how much of it to read,
|
|
1139
|
+
// and only placement owes the lines that read. Every other stage is served the shape without these orders too: the
|
|
1140
|
+
// writer is told the groups are a way to find evidence, never a list it must read to completion. Each list's count,
|
|
1141
|
+
// what it holds and the fact that a crowded one prints its first page stay.
|
|
1142
|
+
export const READ_THEM_ALL = "Fifty? Read them all.";
|
|
1143
|
+
export const READ_EVERY_LINE = ": read every line.";
|
|
1144
|
+
export const READ_THE_LIVE_ONES_FIRST = "A few hundred? Read the live ones in the ordered classes first.";
|
|
1138
1145
|
const SHAPE_LISTS = ["a", "b", "c", "d", "rest", "no_goods"];
|
|
1139
1146
|
const pageEveryOtherLine = (g) => `; page every other line with ${groupPageCall(g)}`;
|
|
1140
1147
|
const pageWhereALineNeedsIt = (g) => `; page with ${groupPageCall(g)} only where a line needs reading`;
|
|
1141
1148
|
const pageTheFloorsLeft = (g) => `Where more floors are counted than this page shows, page the rest with ${groupPageCall(g)}; they come first.`;
|
|
1142
1149
|
/**
|
|
1143
|
-
* The shape as every stage but placement is served it: placement's orders and its group-page commands removed,
|
|
1150
|
+
* The shape as every stage but placement is served it: placement's orders, its size rule's orders to read and its group-page commands removed,
|
|
1144
1151
|
* each with the space before it, inside its line, so no line moves and the line each listed mark is printed on
|
|
1145
1152
|
* still holds. A crowded group's "The first N lines are below" stays, as a fact about what is printed. PURE.
|
|
1146
1153
|
*/
|
|
1147
1154
|
export function withoutPlacementOrders(text) {
|
|
1148
1155
|
let t = String(text ?? "");
|
|
1149
|
-
for (const order of [...PLACEMENT_GROUP_ORDERS, ...PLACEMENT_LINE_ORDERS, ...SHAPE_LISTS.map(pageTheFloorsLeft)]) t = t.split(` ${order}`).join("").split(order).join("");
|
|
1156
|
+
for (const order of [...PLACEMENT_GROUP_ORDERS, ...PLACEMENT_LINE_ORDERS, ...SHAPE_LISTS.map(pageTheFloorsLeft), READ_THEM_ALL, READ_THE_LIVE_ONES_FIRST]) t = t.split(` ${order}`).join("").split(order).join("");
|
|
1157
|
+
t = t.split(READ_EVERY_LINE).join(".");
|
|
1150
1158
|
for (const g of SHAPE_LISTS) t = t.split(pageEveryOtherLine(g)).join("").split(pageWhereALineNeedsIt(g)).join("");
|
|
1151
1159
|
return t;
|
|
1152
1160
|
}
|
|
@@ -1200,16 +1208,16 @@ function renderGroupsMd(md, groups, rows = []) {
|
|
|
1200
1208
|
md.push(`### ${heading(g)}`, "");
|
|
1201
1209
|
if (!grp.lines) { md.push("(none)", ""); continue; }
|
|
1202
1210
|
const crowded = grp.records >= groups.crowded_at;
|
|
1203
|
-
const readA =
|
|
1211
|
+
const readA = `These are the identical and near-identical marks live in the ordered classes for the client's goods${READ_EVERY_LINE}`;
|
|
1204
1212
|
md.push(g === "a"
|
|
1205
1213
|
? (crowded
|
|
1206
1214
|
? `${count(grp)}. ${readA} ${firstPage}${pageEveryOtherLine(g)}. ${FLOORS_BY_NAME_HERE}`
|
|
1207
|
-
: grp.lines <= READ_ALL_LINES ? `${count(grp)}.
|
|
1215
|
+
: grp.lines <= READ_ALL_LINES ? `${count(grp)}. ${READ_THEM_ALL}` : `${count(grp)}. ${readA}`)
|
|
1208
1216
|
: crowded
|
|
1209
1217
|
? `${count(grp)}. A thousand or more? The field is crowded. These are ${crowd[g]}. ${A_CROWDS_ONE_ROW} ${firstPage}${pageWhereALineNeedsIt(g)}.${
|
|
1210
1218
|
g === "b" ? ` ${A_CROWDS_ROW_COVERS_A_READ_FLOOR} ${pageTheFloorsLeft(g)}` : ""}`
|
|
1211
1219
|
: grp.lines <= READ_ALL_LINES
|
|
1212
|
-
? `${count(grp)}.
|
|
1220
|
+
? `${count(grp)}. ${READ_THEM_ALL}`
|
|
1213
1221
|
: `${count(grp)}. ${SORT_THE_NEAR_ONES}`, "");
|
|
1214
1222
|
md.push(...head);
|
|
1215
1223
|
for (const l of crowded ? grp.list.slice(0, groups.first_page) : grp.list) { rows.push([md.length, l.key]); md.push(row(l)); }
|
|
@@ -1223,7 +1231,7 @@ function renderGroupsMd(md, groups, rows = []) {
|
|
|
1223
1231
|
md.push(`**The rest: ${count(rest)}${outside}** — every record in none of (a) to (d) above. ${!rest.lines ? ONE_SET_ASIDE_ROW
|
|
1224
1232
|
: restCrowded
|
|
1225
1233
|
? `A thousand or more? The field is crowded. ${A_CROWDS_ONE_ROW} ${firstPage}${pageWhereALineNeedsIt("rest")}.`
|
|
1226
|
-
: `${rest.lines <= READ_ALL_LINES ?
|
|
1234
|
+
: `${rest.lines <= READ_ALL_LINES ? READ_THEM_ALL : SORT_THE_NEAR_ONES} ${ONE_SET_ASIDE_ROW}`}`);
|
|
1227
1235
|
if (rest.lines) {
|
|
1228
1236
|
md.push("", ...head);
|
|
1229
1237
|
for (const l of restCrowded ? rest.list.slice(0, groups.first_page) : rest.list) { rows.push([md.length, l.key]); md.push(row(l)); }
|
|
@@ -1240,7 +1248,7 @@ function renderGroupsMd(md, groups, rows = []) {
|
|
|
1240
1248
|
: `**Goods the test could not read: ${count(ng)}.**`;
|
|
1241
1249
|
md.push("", crowded
|
|
1242
1250
|
? `${lead} A thousand or more? ${firstPage}, the floors first, then the live ones in the ordered classes. ${AN_UNREAD_CROWDS_ONE_ROW} ${AN_UNREAD_CROWDS_FLOOR} ${pageTheFloorsLeft("no_goods")}`
|
|
1243
|
-
: `${lead} ${ng.lines <= READ_ALL_LINES ?
|
|
1251
|
+
: `${lead} ${ng.lines <= READ_ALL_LINES ? READ_THEM_ALL : READ_THE_LIVE_ONES_FIRST} ${A_FATE_BY_NAME}${tested.source === "none" ? "" : ` ${AN_UNREAD_LINE_IS_CHECKED}`} ${AN_UNREAD_LISTS_FLOOR}`, "");
|
|
1244
1252
|
md.push(...head);
|
|
1245
1253
|
for (const l of crowded ? ng.list.slice(0, groups.first_page) : ng.list) { rows.push([md.length, l.key]); md.push(row(l)); }
|
|
1246
1254
|
}
|
|
@@ -280,9 +280,9 @@ function bandShape(params) {
|
|
|
280
280
|
...(read ? { served_lines: read.keys[part - 1], group_parts: read.groupParts } : {}) });
|
|
281
281
|
const head = format === "json"
|
|
282
282
|
? `[band_shape part ${part}/${total} of the json; concatenate the parts before parsing it.]`
|
|
283
|
-
: `[band_shape part ${part}/${total}. Read each group by the size sentence at its head; the next part continues the groups.]`;
|
|
283
|
+
: `[band_shape part ${part}/${total}. ${GROUP_PAGES ? "Read each group by the size sentence at its head; the next part continues the groups." : "The next part continues the groups."}]`;
|
|
284
284
|
const tail = part < total
|
|
285
|
-
? `\n[end of part ${part}/${total} —
|
|
285
|
+
? `\n[end of part ${part}/${total} — part ${part + 1} is available with {"part": ${part + 1}}]`
|
|
286
286
|
: `\n[end of the shape]`;
|
|
287
287
|
return head + "\n" + parts[part - 1] + tail;
|
|
288
288
|
}
|
|
@@ -351,7 +351,7 @@ function groupLookup(params) {
|
|
|
351
351
|
const body = () => JSON.stringify({
|
|
352
352
|
group: g, lines_total: lines.length, returned: page.length, ...(offset ? { offset } : {}),
|
|
353
353
|
...(lines.length > offset + page.length ? { next_offset: offset + page.length,
|
|
354
|
-
note: `
|
|
354
|
+
note: `the next lines of group ${g} are available with offset: ${offset + page.length}` } : {}),
|
|
355
355
|
lines: page.map(({ _key, ...l }) => l),
|
|
356
356
|
}, null, 2);
|
|
357
357
|
let out = body();
|
|
@@ -440,8 +440,8 @@ function bandLookup(params = {}) {
|
|
|
440
440
|
const body = () => JSON.stringify({
|
|
441
441
|
matched, returned: hits.length, ...(offset ? { offset } : {}),
|
|
442
442
|
...(matched > offset + hits.length ? { next_offset: offset + hits.length } : {}),
|
|
443
|
-
...(matched > offset + hits.length ? { note: `capped at ${hits.length} —
|
|
444
|
-
...(byteCapped ? { byte_cap_note: `${byteCapped} matched record(s) withheld to fit one tool response —
|
|
443
|
+
...(matched > offset + hits.length ? { note: `capped at ${hits.length} — the next page is available with offset: ${offset + hits.length}; the count above is the full match` } : {}),
|
|
444
|
+
...(byteCapped ? { byte_cap_note: `${byteCapped} matched record(s) withheld to fit one tool response — the rest are available with offset: ${offset + hits.length}` } : {}),
|
|
445
445
|
records: hits,
|
|
446
446
|
...(crowds.length ? { matching_crowds: crowds, crowd_note: "these matching slices were NOT enumerated — records under them may exist that no lookup can return; weigh the descriptor, never treat this result as their clean"
|
|
447
447
|
+ (refusedCrowds ? ` — and ${refusedCrowds} of them was/were NEVER ANSWERED at all (see "refusal"): no count exists for those, so they are unsearched rather than empty, and nothing about them can be read off a zero` : "")
|
|
@@ -515,7 +515,7 @@ function bandRecord(params = {}) {
|
|
|
515
515
|
logRead("band_record", { record_id: uri, part, parts: total }, { ok: true, bytes: parts[part - 1].length, total_bytes: text.length, ...resolution, ...served.audit });
|
|
516
516
|
const head = `[band_record part ${part}/${total}. This record is served in ${total} parts; the next part continues it.]`;
|
|
517
517
|
const tail = part < total
|
|
518
|
-
? `\n[end of part ${part}/${total} —
|
|
518
|
+
? `\n[end of part ${part}/${total} — part ${part + 1} is available with {"record_id": "${uri}", "part": ${part + 1}}]`
|
|
519
519
|
: `\n[end of the record]`;
|
|
520
520
|
return head + "\n" + parts[part - 1] + tail;
|
|
521
521
|
}
|
|
@@ -537,7 +537,7 @@ function bandRecord(params = {}) {
|
|
|
537
537
|
const part = Number.isInteger(wanted) && wanted >= 1 && wanted <= parts.length ? wanted : 1;
|
|
538
538
|
logRead("band_record", { record_id: uri }, { ok: true, bytes: text.length, via: "ledger", part, parts: parts.length, ...served.audit });
|
|
539
539
|
return { type: "text", text: `[record ${uri} — part ${part}/${parts.length}]\n` + parts[part - 1]
|
|
540
|
-
+ (part < parts.length ? `\n[end of part ${part}/${parts.length} —
|
|
540
|
+
+ (part < parts.length ? `\n[end of part ${part}/${parts.length} — part ${part + 1} is available with {"record_id": "${uri}", "part": ${part + 1}}]` : "") };
|
|
541
541
|
}
|
|
542
542
|
|
|
543
543
|
// A GENUINE miss — neither materialised NOR in this run's ledger. Now the sentence below is true.
|
|
@@ -581,7 +581,7 @@ function bandRecord(params = {}) {
|
|
|
581
581
|
...(parts.length > 1 ? { part, parts: parts.length } : {}), ...(failed ? { fetch_failed: true } : {}) });
|
|
582
582
|
if (parts.length === 1) return { isError: true, text };
|
|
583
583
|
return { isError: true, text: `[record ${uri} — part ${part}/${parts.length}]\n` + parts[part - 1]
|
|
584
|
-
+ (part < parts.length ? `\n[end of part ${part}/${parts.length} —
|
|
584
|
+
+ (part < parts.length ? `\n[end of part ${part}/${parts.length} — part ${part + 1} is available with {"record_id": "${uri}", "part": ${part + 1}}]` : "") };
|
|
585
585
|
}
|
|
586
586
|
|
|
587
587
|
// The band row a record id names, from the band file, read once per server. null where the band holds none.
|
|
@@ -610,10 +610,10 @@ const GROUP_PAGES = process.env.CLEAROTRON_BAND_GROUPS === "1";
|
|
|
610
610
|
// served the shape without them (the owner's ruling, 2026-10-14). Each is removed with the space before it, inside
|
|
611
611
|
// its line, so no line moves and the line each listed mark is printed on, which `linesByPart` reads, still holds.
|
|
612
612
|
// (band-shape.mjs `withoutPlacementOrders` removes them, with the shape's other placement-only orders and its group-page commands.)
|
|
613
|
-
const LOOKUP_DESCRIPTION = "Look up enumerated records in this run's frozen register band (read-only; logged to the reading audit). Filters AND together: record_id (exact uri), owner (substring), nice_class, tier (identical|near-identical|same-family|other|unclassifiable — classified against the shape's frozen targets), qid (exact plan-entry id — the plan-execution ledger join; a record surfaced by several slices matches under EVERY one of them), query (the _query slice that surfaced the record), text (substring of mark_text), status (substring), live_only, goods (words: records whose goods contain any of them, as whole words, singular or plural; a record with no goods on file matches too), group (a, b, c, d, rest or no_goods: that list's lines in the shape's order, one per mark of an owner), limit (default 20, max 100), offset (default 0: where in the matching records or lines to start — a capped answer names the
|
|
613
|
+
const LOOKUP_DESCRIPTION = "Look up enumerated records in this run's frozen register band (read-only; logged to the reading audit). Filters AND together: record_id (exact uri), owner (substring), nice_class, tier (identical|near-identical|same-family|other|unclassifiable — classified against the shape's frozen targets), qid (exact plan-entry id — the plan-execution ledger join; a record surfaced by several slices matches under EVERY one of them), query (the _query slice that surfaced the record), text (substring of mark_text), status (substring), live_only, goods (words: records whose goods contain any of them, as whole words, singular or plural; a record with no goods on file matches too), group (a, b, c, d, rest or no_goods: that list's lines in the shape's order, one per mark of an owner), limit (default 20, max 100), offset (default 0: where in the matching records or lines to start — a capped answer names the offset where the next page starts). On each record served, `goods_words` gives the client's goods words its goods hold, as the shape tests them (k of N: the words), or null where its goods cannot be read; `goods_from` says what those goods rest on: official record; official record, cut by the register; official record, completeness not established; official record, no goods listed; search answer; search answer, cut by the register; search answer, completeness not established; fetch failed; or nothing held. The goods text is in band_record. Matching un-enumerated crowd descriptors ride along so a lookup can never mistake a counted-only zone for a clean.";
|
|
614
614
|
const LOOKUP_GROUP_CLAUSE = "group (a, b, c, d, rest or no_goods: that list's lines in the shape's order, one per mark of an owner), ";
|
|
615
|
-
const LOOKUP_OFFSET_GROUPS = "offset (default 0: where in the matching records or lines to start — a capped answer names the
|
|
616
|
-
const LOOKUP_OFFSET_BAND = "offset (default 0: where in the matching records to start — a capped answer names the
|
|
615
|
+
const LOOKUP_OFFSET_GROUPS = "offset (default 0: where in the matching records or lines to start — a capped answer names the offset where the next page starts)";
|
|
616
|
+
const LOOKUP_OFFSET_BAND = "offset (default 0: where in the matching records to start — a capped answer names the offset where the next page starts)";
|
|
617
617
|
/** The lookup's description as a stage is offered it: whole where it reads the groups, without them elsewhere. PURE. */
|
|
618
618
|
const lookupDescription = (groupPages) => (groupPages ? LOOKUP_DESCRIPTION
|
|
619
619
|
: LOOKUP_DESCRIPTION.replace(LOOKUP_GROUP_CLAUSE, "").replace(LOOKUP_OFFSET_GROUPS, LOOKUP_OFFSET_BAND));
|
|
@@ -623,7 +623,7 @@ serve({
|
|
|
623
623
|
tools: [
|
|
624
624
|
{
|
|
625
625
|
name: "band_shape",
|
|
626
|
-
description: "The deterministic shape of this run's merged register band: totals, mechanical tiers, the floors (every live in-class identical/near-identical record), each marked on its line in the band in the lawyer's order, class/status/registry/recency census, owner concentrations, crowd descriptors (with crowd-context joins) and blind spots.
|
|
626
|
+
description: "The deterministic shape of this run's merged register band: totals, mechanical tiers, the floors (every live in-class identical/near-identical record), each marked on its line in the band in the lawyer's order, class/status/registry/recency census, owner concentrations, crowd descriptors (with crowd-context joins) and blind spots. What it names can be looked up with band_lookup and band_record. A large shape is served in parts, and the end of each part names the next one. Every call is logged to the run's reading audit.",
|
|
627
627
|
inputSchema: { type: "object", properties: { format: { type: "string", enum: ["md", "json"], description: "md (default, readable) or json (machine shape)" }, part: { type: "number", description: "1-based part number when the shape is served in parts (default 1)" } } },
|
|
628
628
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
629
629
|
handler: bandShape,
|
|
@@ -275,10 +275,15 @@ export function featuresToml(withheld = []) {
|
|
|
275
275
|
// codex's `workspace-write` sandbox confines where a command WRITES and lets it read the whole disk, the
|
|
276
276
|
// install's settings files and the program's own sign-in included. A permission profile confines reading
|
|
277
277
|
// too (the vendor's Permissions guide, a beta feature since 0.138.0; the floor was 0.154.0 when this was written and is 0.159.0 now, for the judgment tier's model — driver.config.mjs): a stage's
|
|
278
|
-
// commands read the platform's own paths
|
|
279
|
-
//
|
|
280
|
-
// cannot get a command to print the account's files.
|
|
281
|
-
//
|
|
278
|
+
// commands read the platform's own paths and the instruction trees, and write the run folder, their
|
|
279
|
+
// working folder and their Codex home's working folder. Nothing else is readable, so a page a stage
|
|
280
|
+
// fetched cannot get a command to print the account's files.
|
|
281
|
+
//
|
|
282
|
+
// NO SHARED TEMP FOLDER. The profile granted `:tmpdir` and `:slash_tmp`, which every stage of every run on
|
|
283
|
+
// the machine shares, and a later run's commands read what an earlier run left in `/tmp` (measured on codex
|
|
284
|
+
// 0.162.0 with that profile: a command read a file planted in `/tmp` and wrote another beside it). The
|
|
285
|
+
// turn's TMPDIR, TMP and TEMP now name its home's working folder (openai-agent.mjs, `withWorkFolder`), and
|
|
286
|
+
// this grants that folder, named, in their place.
|
|
282
287
|
//
|
|
283
288
|
// ONLY WITH THE SANDBOX ON. Under the bypass codex builds no sandbox, so a profile would enforce nothing;
|
|
284
289
|
// the caller passes no `fence` then, and the file stays what it was. With the sandbox on the command line
|
|
@@ -297,10 +302,10 @@ export function featuresToml(withheld = []) {
|
|
|
297
302
|
// wins over the run folder's: measured on codex 0.162.0 with this profile, a write into the run folder
|
|
298
303
|
// succeeded, a read of `_driver/` succeeded, and a write or an append there was refused.
|
|
299
304
|
export const FENCE_PROFILE = "clearotron-stage";
|
|
300
|
-
export function fenceToml({ runDir = null, readRoots = [] } = {}) {
|
|
305
|
+
export function fenceToml({ runDir = null, workDir = null, readRoots = [] } = {}) {
|
|
301
306
|
const key = (p) => tomlString(p);
|
|
302
|
-
const out = [`[permissions.${FENCE_PROFILE}.filesystem]`,
|
|
303
|
-
|
|
307
|
+
const out = [`[permissions.${FENCE_PROFILE}.filesystem]`, `":minimal" = "read"`];
|
|
308
|
+
if (workDir) out.push(`${key(workDir)} = "write"`);
|
|
304
309
|
for (const r of [...new Set(readRoots.filter(Boolean))]) out.push(`${key(r)} = "read"`);
|
|
305
310
|
if (runDir) out.push(`${key(runDir)} = "write"`, `${key(driverDir(runDir))} = "read"`);
|
|
306
311
|
out.push("", `[permissions.${FENCE_PROFILE}.filesystem.":workspace_roots"]`, `"." = "write"`, "");
|
|
@@ -60,6 +60,7 @@ import { MAX_ROWS_PER_CALL } from "../../disposition-call.mjs";
|
|
|
60
60
|
// The lane's second statement, and a different one: which coverage units were searched to what end. Its
|
|
61
61
|
// own module and its own file, never a row in the disposition form — see record_coverage_status below.
|
|
62
62
|
import { recordCoverageStatus, COMMON_LAW_COVERAGE_STATUSES } from "../../common-law-coverage-status.mjs";
|
|
63
|
+
import { insideRun } from "../../../shared/inside-run.mjs"; // a path a tool is handed is served only inside this run
|
|
63
64
|
|
|
64
65
|
// ── B — THE TYPED DISPOSITION TRANSPORT ─────────────────────────────────────────────────────────────
|
|
65
66
|
//
|
|
@@ -76,11 +77,17 @@ function specFrom(params) {
|
|
|
76
77
|
const { grid_spec_path } = params ?? {};
|
|
77
78
|
if (!grid_spec_path)
|
|
78
79
|
return { error: "ERROR: grid_spec_path is required — it is the same driver-written spec path the grid tool was given. Do not compose a path." };
|
|
80
|
+
// THIS RUN'S SPEC AND LEDGER ONLY (shared/inside-run.mjs): the run folder is the one the driver wired, never the call's.
|
|
81
|
+
const runDir = process.env.CLEAROTRON_BAND_RUN_DIR || "";
|
|
82
|
+
if (runDir && !insideRun(runDir, grid_spec_path))
|
|
83
|
+
return { error: "ERROR: grid_spec_path is outside this run's folder. The driver writes the spec inside the run; use the path it gave you." };
|
|
79
84
|
let spec;
|
|
80
85
|
try { spec = validateGridSpec(JSON.parse(readFileSync(grid_spec_path, "utf8"))); }
|
|
81
86
|
catch (err) { return { error: `ERROR: grid_spec_path unreadable/invalid (${err.message}). The driver writes this file; do not hand-author it.` }; }
|
|
82
87
|
if (!underStudioSegment(spec.output_path)) // either spelling: an install keeps the studio segment it has
|
|
83
88
|
return { error: `ERROR: grid spec.output_path must be within a studio/clearance-search run dir; got ${spec.output_path}` };
|
|
89
|
+
if (runDir && !insideRun(runDir, spec.output_path))
|
|
90
|
+
return { error: "ERROR: the grid spec's output_path is outside this run's folder. The driver writes the spec inside the run; use the path it gave you." };
|
|
84
91
|
return { spec };
|
|
85
92
|
}
|
|
86
93
|
|
|
@@ -29,7 +29,8 @@ import { parsePrRiskResults, connotationObligations, renderConnotationObligation
|
|
|
29
29
|
// What stays is the GRID path's own need — the obligations sidecar written beside the grid's ledger.
|
|
30
30
|
import { obligationsSidecarPath } from "../../disposition-tool.mjs";
|
|
31
31
|
import { writeGridProvenance } from "./grid-provenance.mjs"; // — what served the grid, beside the ledger
|
|
32
|
-
import {
|
|
32
|
+
import { insideRun } from "../../../shared/inside-run.mjs"; // a path a tool is handed is served only inside this run
|
|
33
|
+
import { keepResearchAnswer, researchReplayOn, savedResearchAnswer } from "../../research-answers.mjs"; // each answer's text, kept for a later attempt of its stage; a replay answers from them
|
|
33
34
|
|
|
34
35
|
const API_KEY = process.env.PERPLEXITY_API_KEY || "";
|
|
35
36
|
|
|
@@ -219,14 +220,21 @@ function judgeBlock(cands, part, gridSpecPath, reserve = 0) {
|
|
|
219
220
|
}
|
|
220
221
|
|
|
221
222
|
async function research(params) {
|
|
223
|
+
// A REPLAY SENDS NOTHING OUT, so it is answered before the key is asked for: an offline replay needs none.
|
|
224
|
+
if (researchReplayOn(RUN_DIR)) return replayResearch(params);
|
|
222
225
|
if (!API_KEY) return "ERROR: PERPLEXITY_API_KEY not set — marketplace research unavailable.";
|
|
223
226
|
const { task, depth, model: modelOverride, allow_fetch = false, domain_filter, enable_sandbox = false, response_schema, schema_name, grid_spec_path, part } = params;
|
|
224
227
|
|
|
225
228
|
// ── Deterministic grid mode: the caller dictates the cells; the tool runs + captures them. ──
|
|
226
229
|
if (grid_spec_path) {
|
|
230
|
+
// THIS RUN'S SPEC, WRITING THIS RUN'S LEDGER: a spec, or a ledger it names, in another run's folder is never served.
|
|
231
|
+
if (RUN_DIR && !insideRun(RUN_DIR, grid_spec_path))
|
|
232
|
+
return "ERROR: grid_spec_path is outside this run's folder. The driver writes the spec inside the run; use the path it gave you.";
|
|
227
233
|
let spec;
|
|
228
234
|
try { spec = validateGridSpec(JSON.parse(readFileSync(grid_spec_path, "utf8"))); }
|
|
229
235
|
catch (err) { return `ERROR: grid_spec_path unreadable/invalid (${err.message}). The driver writes this file; do not hand-author it.`; }
|
|
236
|
+
if (RUN_DIR && !insideRun(RUN_DIR, spec.output_path))
|
|
237
|
+
return requiredLedgerRefusal("ERROR: the grid spec's output_path is outside this run's folder. The driver writes the spec inside the run; use the path it gave you.", { spec, gridSpecPath: grid_spec_path });
|
|
230
238
|
if (!underStudioSegment(spec.output_path)) // either spelling: an install keeps the studio segment it has
|
|
231
239
|
return requiredLedgerRefusal(`ERROR: grid spec.output_path must be within a studio/clearance-search run dir; got ${spec.output_path}`, { spec, gridSpecPath: grid_spec_path });
|
|
232
240
|
// — already recorded and complete? Answer from the ledger; do not buy the grid twice. This is also
|
|
@@ -309,6 +317,26 @@ async function research(params) {
|
|
|
309
317
|
}
|
|
310
318
|
}
|
|
311
319
|
|
|
320
|
+
// ── A REPLAY: THE RUN'S OWN ANSWER, OR NOTHING ─────────────────────────────────────────────────────────
|
|
321
|
+
//
|
|
322
|
+
// In the experiment sandbox under `--research replay` (research-answers.mjs), a question the run already asked
|
|
323
|
+
// gets the answer the run got, and any other question, a grid among them, is refused: nothing goes out to the
|
|
324
|
+
// research service. Both are logged as a lookup is, marked as a replay, so the arm's reading log says which.
|
|
325
|
+
function replayResearch(params) {
|
|
326
|
+
const { task, grid_spec_path } = params ?? {};
|
|
327
|
+
if (grid_spec_path) {
|
|
328
|
+
logCall("perplexity_research", { task: task ?? "", grid: true, replay: true }, { ok: false, error: "replay: grid not replayed" });
|
|
329
|
+
return "ERROR: replay — this replays the run's saved research answers and runs no grid; nothing was sent to the research service.";
|
|
330
|
+
}
|
|
331
|
+
const saved = savedResearchAnswer(RUN_DIR, task);
|
|
332
|
+
if (!saved) {
|
|
333
|
+
logCall("perplexity_research", { task: task ?? "", replay: true }, { ok: false, error: "replay: no saved answer" });
|
|
334
|
+
return "ERROR: replay — the run asked no question worded exactly like this one, so there is no saved answer to give; nothing was sent to the research service.";
|
|
335
|
+
}
|
|
336
|
+
logCall("perplexity_research", { task, replay: true }, { ok: true, bytes: saved.text.length, replayed: true });
|
|
337
|
+
return saved.text;
|
|
338
|
+
}
|
|
339
|
+
|
|
312
340
|
serve({
|
|
313
341
|
name: "perplexity", version: "0.1.0",
|
|
314
342
|
tools: [{
|
|
@@ -1046,7 +1046,10 @@ serve({
|
|
|
1046
1046
|
"are. The driver renders narrative.md and findings.json. Your coverage rows are joined to this " +
|
|
1047
1047
|
"run's plan-execution receipt as you send them, so a clean claim over a slice that did not run is " +
|
|
1048
1048
|
"refused here — in this turn, where restating it is free — rather than at a gate that can only " +
|
|
1049
|
-
"re-ask the whole stage. The answer tells you what was stored, or names the exact defect token."
|
|
1049
|
+
"re-ask the whole stage. The answer tells you what was stored, or names the exact defect token. " +
|
|
1050
|
+
"Save each finding as you complete it, alone or a few at a time, with `save_findings`: each is checked as " +
|
|
1051
|
+
"the record checks a finding and kept the moment it passes, so a stopped attempt loses none of them. The " +
|
|
1052
|
+
"call that sends the whole record takes every saved finding for a position it does not carry itself.",
|
|
1050
1053
|
inputSchema: {
|
|
1051
1054
|
type: "object",
|
|
1052
1055
|
// ── NO BLANKET `required`, AND THAT IS THE FIX FOR ─────────────────────────
|
|
@@ -1079,6 +1082,18 @@ serve({
|
|
|
1079
1082
|
// reads the run's own accepted-call record rather than trusting the shape. That check is older
|
|
1080
1083
|
// than this change and is the reason relaxing `required` costs nothing.
|
|
1081
1084
|
properties: {
|
|
1085
|
+
// A COMPLETED FINDING, KEPT BEFORE THE WHOLE RECORD EXISTS (synthesis-record.mjs, `saveFindings`). Its items
|
|
1086
|
+
// are findings as the record carries them, declared from the same key ladder as a patch row.
|
|
1087
|
+
save_findings: {
|
|
1088
|
+
type: "array",
|
|
1089
|
+
description:
|
|
1090
|
+
"COMPLETED FINDINGS, saved before the whole record exists. Send one or a few complete finding objects as the findings record carries them, and nothing else on the call. Each is checked as the record checks a finding and kept as soon as it passes; one that is refused leaves the others saved. A finding saved again for the same position replaces the earlier one. Saving writes no report: the record is complete only when you send `narrative` with `findings`, and that call takes every saved finding for a position it does not carry.",
|
|
1091
|
+
items: {
|
|
1092
|
+
type: "object",
|
|
1093
|
+
description: "One complete finding object, as the findings record carries it.",
|
|
1094
|
+
properties: Object.fromEntries(FINDING_KEYS_CURRENT.map((k) => [k, { description: `Part of the finding record; see findings-model.mjs for what \`${k}\` carries.` }])),
|
|
1095
|
+
},
|
|
1096
|
+
},
|
|
1082
1097
|
findings_patch: {
|
|
1083
1098
|
type: "array",
|
|
1084
1099
|
description:
|
|
@@ -34,7 +34,8 @@ import { dirname, join } from "node:path";
|
|
|
34
34
|
import { driverDir } from "../../../shared/driver-dir.mjs"; //
|
|
35
35
|
import { PLAN_PREDICATES, PLAN_MAX_OR_WIDTH, PLAN_MAX_NAME_LENGTH, planExactOrWidth, planPhoneticOrWidth, fingerprint, ownerIntersectionGap, goodsTextGap, resolveRegions, houseElementOf, withoutHouseElementTerms, containsFormSubstitution } from "../../register-plan.mjs";
|
|
36
36
|
import { entryTermIssues } from "../../../providers/_shared/term-shape.mjs";
|
|
37
|
-
import { OUTSTANDING_REASON } from "../../../providers/_shared/follow-up-searches.mjs";
|
|
37
|
+
import { OUTSTANDING_REASON, runDriverDirOf } from "../../../providers/_shared/follow-up-searches.mjs";
|
|
38
|
+
import { pathInRun } from "../../../shared/inside-run.mjs"; // the band path is served only inside this run
|
|
38
39
|
import { heldAnswers, questionKey } from "./held-answers.mjs";
|
|
39
40
|
import { bandWithSlicesOut, slicesTakenOutByAxis } from "../../../shared/slices-taken-out.mjs";
|
|
40
41
|
import { readWithheldFamilies } from "../../withheld-families.mjs";
|
|
@@ -391,6 +392,10 @@ export async function proposeSupplemental(params, tctx, deps) {
|
|
|
391
392
|
const proposals = Array.isArray(params?.proposals) ? params.proposals : null;
|
|
392
393
|
if (!axis || !outPath || !proposals || !proposals.length)
|
|
393
394
|
return { type: "text", text: "ERROR: axis, output_path and a non-empty proposals[] are all required." };
|
|
395
|
+
// The supplemental plan is written beside the band, so the band path must be this run's before anything is written.
|
|
396
|
+
const runOf = runDriverDirOf(tctx?.recordLog);
|
|
397
|
+
if (runOf && pathInRun(dirname(runOf), outPath) === null)
|
|
398
|
+
return { type: "text", text: "ERROR: output_path is outside this run's folder. Use the band path the driver gave you." };
|
|
394
399
|
|
|
395
400
|
const suppPath = join(dirname(outPath), `${axis}-supplemental-plan.json`);
|
|
396
401
|
// ── the supplemental plan INHERITS the frozen plan's regions (review findings 4/12) ───────────────
|
|
@@ -317,8 +317,10 @@ export function sweepStaleCodexHomes(root, { now = Date.now(), alive = processAl
|
|
|
317
317
|
for (const e of entries) {
|
|
318
318
|
if (!e.isDirectory() || !/^(?:stage|turn)-/.test(e.name)) continue;
|
|
319
319
|
const dir = join(root, e.name);
|
|
320
|
-
|
|
321
|
-
const
|
|
320
|
+
// A home's working folder is judged by its home's name, and goes when its home's maker has ended.
|
|
321
|
+
const home = e.name.endsWith(WORK_SUFFIX) ? e.name.slice(0, -WORK_SUFFIX.length) : e.name;
|
|
322
|
+
if (OWNED.has(join(root, home))) continue;
|
|
323
|
+
const maker = home.match(MAKER_RE);
|
|
322
324
|
let stale;
|
|
323
325
|
if (maker) stale = Number(maker[1]) !== process.pid && !alive(Number(maker[1]));
|
|
324
326
|
else { try { stale = now - statSync(dir).mtimeMs > STALE_UNMARKED_MS; } catch { stale = false; } }
|
|
@@ -329,7 +331,7 @@ export function sweepStaleCodexHomes(root, { now = Date.now(), alive = processAl
|
|
|
329
331
|
}
|
|
330
332
|
|
|
331
333
|
const removeOwned = () => {
|
|
332
|
-
for (const dir of OWNED) { try { rmSync(
|
|
334
|
+
for (const dir of OWNED) for (const d of [dir, workFolderOf(dir)]) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best-effort */ } }
|
|
333
335
|
OWNED.clear();
|
|
334
336
|
};
|
|
335
337
|
|
|
@@ -363,13 +365,46 @@ export function makeCodexHome(prefix) {
|
|
|
363
365
|
return dir;
|
|
364
366
|
}
|
|
365
367
|
|
|
366
|
-
/** Remove a home this process made. Best-effort: never throws. */
|
|
368
|
+
/** Remove a home this process made, and its working folder. Best-effort: never throws. */
|
|
367
369
|
export function releaseCodexHome(dir) {
|
|
368
370
|
if (!dir) return;
|
|
369
371
|
OWNED.delete(dir);
|
|
370
|
-
try { rmSync(
|
|
372
|
+
for (const d of [dir, workFolderOf(dir)]) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best-effort */ } }
|
|
371
373
|
}
|
|
372
374
|
|
|
375
|
+
// ── A STAGE'S TEMPORARY FILES ARE ITS OWN ─────────────────────────────────────────────────────────────
|
|
376
|
+
//
|
|
377
|
+
// The stage's profile granted the account's temp folder and `/tmp`, both shared by every stage of every run on
|
|
378
|
+
// the machine, and nothing removed what a stage left there. In a test run of a crowded matter, 2026-10-27, the
|
|
379
|
+
// writer's commands found and read the working notes, a state file with ratings, and a families file that an
|
|
380
|
+
// earlier run's writer had left in `/tmp` the day before. A run must read nothing from another run.
|
|
381
|
+
//
|
|
382
|
+
// So each home has one working folder of its own, beside it and never inside it (the home holds the sign-in,
|
|
383
|
+
// which the profile never names). The turn's TMPDIR, TMP and TEMP name it, and the profile grants it in place
|
|
384
|
+
// of the shared folders. It lives as long as its home: a stage's ladder shares one home across its attempts,
|
|
385
|
+
// its warm resumes and its repair turns, so they keep the notes they made, and the folder goes when the ladder
|
|
386
|
+
// settles. A home whose maker has ended takes its working folder with it in the sweep.
|
|
387
|
+
const WORK_SUFFIX = ".work";
|
|
388
|
+
/** The working folder of a Codex home. PURE. */
|
|
389
|
+
export const workFolderOf = (home) => `${home}${WORK_SUFFIX}`;
|
|
390
|
+
/** Make a home's working folder if it is not there yet, private to the account. Returns its path. */
|
|
391
|
+
export function ensureWorkFolder(home) {
|
|
392
|
+
const dir = workFolderOf(home);
|
|
393
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
394
|
+
return dir;
|
|
395
|
+
}
|
|
396
|
+
/** The turn's environment with every temp-folder name pointing at the home's working folder. PURE. */
|
|
397
|
+
export function withWorkFolder(env, dir) {
|
|
398
|
+
return { ...env, TMPDIR: dir, TMP: dir, TEMP: dir };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// WHAT THE MODEL IS TOLD, as the design ruling of 2026-10-27 approved it (its section on isolation). It describes the
|
|
402
|
+
// mechanism and replaces nothing: the profile is what keeps another run's files out of reach. Codex alone carries it,
|
|
403
|
+
// since only a Codex stage runs commands and only a Codex turn has a working folder of its own.
|
|
404
|
+
export const TEMP_FOLDER_RULE = "Keep temporary working files in the directory given by TMPDIR. Use only this run's supplied evidence and accepted work.";
|
|
405
|
+
/** The instructions every Codex turn carries: the output rule, then the temp-folder rule. */
|
|
406
|
+
export const CODEX_DEVELOPER_INSTRUCTIONS = `${WRITE_DISCIPLINE} ${TEMP_FOLDER_RULE}`;
|
|
407
|
+
|
|
373
408
|
/**
|
|
374
409
|
* The folder holding codex's own programs, which the stage's profile must let it read. codex runs every
|
|
375
410
|
* shell command through a helper of its own inside the sandbox, so a profile that cannot read the helper
|
|
@@ -884,7 +919,10 @@ export const openaiAgentEngine = {
|
|
|
884
919
|
let authMaster = null, seeded = null;
|
|
885
920
|
try {
|
|
886
921
|
// Auth toggle (may throw loud on api-key-without-key — a config error, not a retryable failure).
|
|
887
|
-
const { env, mode } = spawnEnv(process.env, codexHome);
|
|
922
|
+
const { env: baseEnv, mode } = spawnEnv(process.env, codexHome);
|
|
923
|
+
// The home's own working folder, as the turn's temp folder, with the sandbox on or off.
|
|
924
|
+
const workDir = ensureWorkFolder(codexHome);
|
|
925
|
+
const env = withWorkFolder(baseEnv, workDir);
|
|
888
926
|
if (mode === "subscription") {
|
|
889
927
|
const af = authFilePath(process.env);
|
|
890
928
|
if (!existsSync(af))
|
|
@@ -906,16 +944,17 @@ export const openaiAgentEngine = {
|
|
|
906
944
|
// backstop if a tool consumes the lot.
|
|
907
945
|
//
|
|
908
946
|
// — the fence: with the sandbox on, the stage's commands read only the instruction trees, the run
|
|
909
|
-
// folder and the
|
|
947
|
+
// folder and the home's working folder (codex-config.mjs, `fenceToml`). The same roots claude's file
|
|
948
|
+
// tools get, and a working folder claude needs none of, since no claude stage runs a command.
|
|
910
949
|
//
|
|
911
950
|
// — what those commands inherit: none of the register and research keys the program holds for its
|
|
912
951
|
// tool servers, and not the Codex key (engine-env.mjs, `codexCommandWithheld`), with codex's shell
|
|
913
952
|
// snapshot off because it replays them (codex-config.mjs, `commandEnvToml`). With the sandbox on or
|
|
914
953
|
// off, since the bypass builds no fence and a command could otherwise print them.
|
|
915
954
|
const fence = codexSandboxBypassed() ? null
|
|
916
|
-
: { runDir, readRoots: [...(skillsGrantRoots?.length ? skillsGrantRoots : [skillsDir]), codexProgramRoot(codexBin())].filter(Boolean) };
|
|
955
|
+
: { runDir, workDir, readRoots: [...(skillsGrantRoots?.length ? skillsGrantRoots : [skillsDir]), codexProgramRoot(codexBin())].filter(Boolean) };
|
|
917
956
|
writeFileSync(join(codexHome, "config.toml"),
|
|
918
|
-
renderCodexConfigToml({ mcpConfig, allowedTools, developerInstructions:
|
|
957
|
+
renderCodexConfigToml({ mcpConfig, allowedTools, developerInstructions: CODEX_DEVELOPER_INSTRUCTIONS, toolTimeoutSec: timeoutSec, fence,
|
|
919
958
|
withheldFromCommands: codexCommandWithheld() }));
|
|
920
959
|
|
|
921
960
|
const input = absolutizeSkillRefs(message, skillsDir, resolveSkill);
|
|
@@ -2670,3 +2670,20 @@ export function topKeysForVersion(schemaVersion) {
|
|
|
2670
2670
|
const v = typeof schemaVersion === "number" ? schemaVersion : 1;
|
|
2671
2671
|
return [...topKeysFor({ v4: v >= 4, v5: v >= 5, v6: v >= 6, v7: v >= 7 })];
|
|
2672
2672
|
}
|
|
2673
|
+
|
|
2674
|
+
/**
|
|
2675
|
+
* ONE FINDING, CHECKED AS THE WHOLE RECORD CHECKS IT, before the record exists. The writer saves a completed finding as
|
|
2676
|
+
* it completes it, and a saved finding must pass exactly what it would pass inside the record, under the schema the
|
|
2677
|
+
* record is written at and the run's frozen framework and method. The record-wide rules (an empty record, ordinals
|
|
2678
|
+
* unique across the record, the actions and the coverage) belong to the record and are checked when it is complete.
|
|
2679
|
+
* Returns the finding as validated; throws the record's own token-first refusal. PURE.
|
|
2680
|
+
*/
|
|
2681
|
+
export function validateFindingForSave(f, opts = {}) {
|
|
2682
|
+
const schemaVersion = FINDINGS_SCHEMA_VERSION;
|
|
2683
|
+
const mode = { v4: schemaVersion >= 4, v5: schemaVersion >= 5, v6: schemaVersion >= 6, v7: schemaVersion >= 7, manifest: opts.manifest ?? null,
|
|
2684
|
+
method: "method" in opts ? (opts.method ?? null) : undefined,
|
|
2685
|
+
recordOrigins: Array.isArray(opts.recordOrigins) ? opts.recordOrigins : null, foreignLinks: Array.isArray(opts.recordOrigins) ? [] : null };
|
|
2686
|
+
const out = validateFinding(f, 0, new Set(), mode);
|
|
2687
|
+
throwForeignLinks(mode);
|
|
2688
|
+
return out;
|
|
2689
|
+
}
|
package/driver/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "clearotron-driver",
|
|
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": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
|
|
8
8
|
"engines": {
|