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
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
14
14
|
import { dirname } from "node:path";
|
|
15
15
|
import { createHash } from "node:crypto";
|
|
16
|
-
import { buildSelectionIndex, setAsideGrounds, isSetAsideGroupRow, lineKey, groupName, servedLines } from "./placement-form.mjs";
|
|
16
|
+
import { buildSelectionIndex, setAsideGrounds, isSetAsideGroupRow, lineKey, groupName, servedLines, FLOOR_GROUND_ON } from "./placement-form.mjs";
|
|
17
17
|
import { unionPlacementForm } from "./placement-union.mjs";
|
|
18
|
-
import { readPlacementForm, readPlacementFormInput } from "./placement-form-io.mjs";
|
|
18
|
+
import { readPlacementForm, readPlacementFormInput, placementFormStamp, placementFormPaths } from "./placement-form-io.mjs";
|
|
19
19
|
import { parsePlacementsJson } from "./placement-model.mjs";
|
|
20
20
|
|
|
21
21
|
const safeReadJson = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
|
|
@@ -78,7 +78,7 @@ export function placementSetAsideGrounds(P) { // @internal — driven by its t
|
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
80
|
* THE FLOORS PLACEMENT SET ASIDE, AS THE REVIEW IS TOLD THEM: one line per standing decision on the shape now on disk,
|
|
81
|
-
* with placement's ground once. A set-aside by name, on its status or
|
|
81
|
+
* with placement's ground once. A set-aside by name, on its status, territory, goods or mark, covers one floor and names it. A
|
|
82
82
|
* crowd's row names its ground once and then every floor it covers, by record: the ground is the group's, so it was
|
|
83
83
|
* never the floors' to repeat, and on a test run of a crowded matter repeating it on each of 1,177 floors made the
|
|
84
84
|
* review's opening message 866 KB of its 899. Only decisions that stand on the current shape are listed
|
|
@@ -99,7 +99,7 @@ export function placementFloorDecisionLines(P) { // @internal — driven by it
|
|
|
99
99
|
const key = `${g.group}\u0000${g.ground}`;
|
|
100
100
|
if (!rows.has(key)) { rows.set(key, { group: g.group, ground: g.ground, ids: [] }); out.push(rows.get(key)); }
|
|
101
101
|
rows.get(key).ids.push(f.record_id);
|
|
102
|
-
} else if (
|
|
102
|
+
} else if (FLOOR_GROUND_ON.includes(g.on)) {
|
|
103
103
|
out.push(`- ${f.mark_text ?? "(unnamed)"}${f.owner_name ? ` — ${f.owner_name}` : ""} (${f.record_id}): set aside on its ${g.on}: ${g.ground}`);
|
|
104
104
|
}
|
|
105
105
|
}
|
|
@@ -118,9 +118,20 @@ export function placementFloorDecisionLines(P) { // @internal — driven by it
|
|
|
118
118
|
* enough for a stage to open. [] when nothing stands or nothing can be read. IMPURE (reads the run).
|
|
119
119
|
*/
|
|
120
120
|
export function standingPlacementDecisions(P) { // @internal — driven by its test with a run's own files
|
|
121
|
+
// A FORM THAT IS NOT THERE IS NOT "NOTHING SET ASIDE". Every failure here used to read as an empty list, so a writer in
|
|
122
|
+
// the experiment sandbox, which held no form, was told of no set-aside at all on a run whose placement had set 178
|
|
123
|
+
// aside. Where this run's placement records its decisions on a form (the era stamp), a missing or unreadable form is
|
|
124
|
+
// refused by name, and so is a failure reading what stands. A run from before the form had none, and its empty list
|
|
125
|
+
// is true.
|
|
126
|
+
const form = readPlacementForm(P.runDir);
|
|
127
|
+
if (form.error) throw new Error(`placement_form_unreadable: ${form.error} — placement's set-asides cannot be read, and an unread form is not "none set aside"`);
|
|
128
|
+
if (!form.present) {
|
|
129
|
+
if (placementFormStamp(P.runDir).required)
|
|
130
|
+
throw new Error(`placement_form_missing: this run's placement records its decisions on a form, and ${placementFormPaths(P.runDir).sidecar} is not there — refusing to read its absence as "none set aside"`);
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
if (!form.set_aside?.length) return [];
|
|
121
134
|
try {
|
|
122
|
-
const form = readPlacementForm(P.runDir);
|
|
123
|
-
if (!form.set_aside?.length) return [];
|
|
124
135
|
const input = readPlacementFormInput(P.runDir);
|
|
125
136
|
const index = buildSelectionIndex(input);
|
|
126
137
|
return standingSetAside(form.set_aside, input, { placedUris: placedUriRows(P).map((r) => r.uri), rows: form.rows }).flatMap((e) => {
|
|
@@ -130,9 +141,9 @@ export function standingPlacementDecisions(P) { // @internal — driven by its
|
|
|
130
141
|
covered_lines_hash: createHash("sha256").update((e.covered_lines ?? []).join("\n")).digest("hex").slice(0, 16) }];
|
|
131
142
|
const c = index.resolve(e.set_aside);
|
|
132
143
|
return [{ kind: "name", record: e.set_aside, mark: c?.mark ?? null, owner: c?.owner ?? null, ground: e.ground,
|
|
133
|
-
...(
|
|
144
|
+
...(FLOOR_GROUND_ON.includes(e.on) ? { on: e.on } : {}) }];
|
|
134
145
|
});
|
|
135
|
-
} catch {
|
|
146
|
+
} catch (e) { throw new Error(`placement_decisions_unreadable: ${String(e?.message ?? e).slice(0, 200)} — what placement set aside could not be worked out from its form`); }
|
|
136
147
|
}
|
|
137
148
|
|
|
138
149
|
/**
|
|
@@ -144,7 +155,11 @@ export function rankingNow(P) { // @internal — driven by its test with a run
|
|
|
144
155
|
const records = new Map();
|
|
145
156
|
for (const r of placedUriRows(P)) records.set(String(r.uri).toLowerCase(), `placed:${r.tier ?? ""}`);
|
|
146
157
|
const groups = new Map();
|
|
147
|
-
|
|
158
|
+
// The re-rank count runs inside the delivery loop, and a run always delivers: here a refusal counts no decision, and
|
|
159
|
+
// the writer's own message, which reads the same decisions, is where it is refused.
|
|
160
|
+
let decisions = [];
|
|
161
|
+
try { decisions = standingPlacementDecisions(P); } catch { decisions = []; }
|
|
162
|
+
for (const d of decisions) {
|
|
148
163
|
if (d.kind === "group") groups.set(d.group, { key: `${d.ground}|${d.covers_lines}|${d.covered_lines_hash}`, records: d.records });
|
|
149
164
|
else records.set(String(d.record).toLowerCase(), `set aside${d.on ? ` on its ${d.on}` : ""}: ${d.ground}`);
|
|
150
165
|
}
|
|
@@ -25,6 +25,7 @@ import { join, dirname, basename } from "node:path";
|
|
|
25
25
|
import { driverDir } from "../shared/driver-dir.mjs"; //
|
|
26
26
|
import { PLACEMENT_FORM_NAME, placementFormSidecarName, parsePlacementForm, renderPlacementsJson } from "./placement-form.mjs";
|
|
27
27
|
import { parsePlacementsJson } from "./placement-model.mjs";
|
|
28
|
+
import { normalizeRecordUri } from "./registry-fidelity.mjs";
|
|
28
29
|
import { atomicWrite } from "./progress.mjs";
|
|
29
30
|
|
|
30
31
|
export { PLACEMENT_FORM_NAME };
|
|
@@ -54,13 +55,32 @@ export function placementFormStamp(runDir) {
|
|
|
54
55
|
export function readPlacementFormInput(runDir) {
|
|
55
56
|
const shape = readJson(driverDir(runDir, "band-shape.json"));
|
|
56
57
|
const pos = readJson(driverDir(runDir, "register-positions.json"));
|
|
57
|
-
|
|
58
|
+
const scope = readJson(driverDir(runDir, "instructed-scope.json"));
|
|
59
|
+
const input = {
|
|
58
60
|
floors: Array.isArray(shape?.floors?.in_class_identical_or_near) ? shape.floors.in_class_identical_or_near : null,
|
|
59
61
|
positions: Array.isArray(pos?.positions) ? pos.positions : null,
|
|
60
62
|
// The shape's groups, which a group row names and is checked against; null on a shape that has none.
|
|
61
63
|
groups: shape?.groups && typeof shape.groups === "object" ? shape.groups : null,
|
|
62
64
|
served: readServed(runDir),
|
|
65
|
+
// The applied-for marks, written by the job before any model ran: a ground on a floor's mark is refused on the
|
|
66
|
+
// client's own mark. A scope that names one mark as a string names it all the same.
|
|
67
|
+
marks: Array.isArray(scope?.marks) ? scope.marks : (typeof scope?.marks === "string" && scope.marks ? [scope.marks] : []),
|
|
63
68
|
};
|
|
69
|
+
// What each record's goods rest on (goods-evidence.mjs), which a ground on a floor's goods is checked against. Read
|
|
70
|
+
// only when a ground needs it: the record runs to megabytes and the form is judged on every pass. null where the
|
|
71
|
+
// run wrote none, which establishes no goods.
|
|
72
|
+
let evidence;
|
|
73
|
+
Object.defineProperty(input, "evidence", { enumerable: false, get: () => (evidence === undefined ? (evidence = readGoodsStates(runDir)) : evidence) });
|
|
74
|
+
return input;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** canonical uri → the state goods-evidence.mjs recorded for that record's goods; null with no record. */
|
|
78
|
+
export function readGoodsStates(runDir) {
|
|
79
|
+
const doc = readJson(driverDir(runDir, "goods-evidence.json"));
|
|
80
|
+
if (!Array.isArray(doc?.records)) return null;
|
|
81
|
+
const out = new Map();
|
|
82
|
+
for (const r of doc.records) { const k = normalizeRecordUri(r?.uri); if (k && typeof r?.state === "string") out.set(k, r.state); }
|
|
83
|
+
return out;
|
|
64
84
|
}
|
|
65
85
|
|
|
66
86
|
/**
|
|
@@ -112,11 +112,15 @@ export const SELECT_ROW_CONTRACT = Object.freeze({
|
|
|
112
112
|
// (ruled 2026-10-12). It is not a candidate, it renders into no placement, and nothing bounds how many there are.
|
|
113
113
|
export const SET_ASIDE_ROW_FIELDS = ["set_aside", "ground", "on"];
|
|
114
114
|
/**
|
|
115
|
-
* What a ground setting a floor aside by name may be about
|
|
116
|
-
*
|
|
117
|
-
*
|
|
115
|
+
* What a ground setting a floor aside by name may be about: its status, its territory, its registered goods or a
|
|
116
|
+
* reasoned comparison of its mark with the client's (ruled 2026-10-27, superseding the status-or-territory rule of
|
|
117
|
+
* 2026-10-12). Never its owner's customers or field. The union checks each against the run's own evidence: a goods
|
|
118
|
+
* ground needs the line read and every floor's goods held complete, and a mark ground is refused on the client's
|
|
119
|
+
* own mark. The words of a ground on status or territory are read too, for customers, field and goods.
|
|
118
120
|
*/
|
|
119
|
-
export const FLOOR_GROUND_ON = Object.freeze(["status", "territory"]);
|
|
121
|
+
export const FLOOR_GROUND_ON = Object.freeze(["status", "territory", "goods", "mark"]);
|
|
122
|
+
/** The grounds whose words the form reads for customers, field and goods: the two that are about neither. */
|
|
123
|
+
export const FLOOR_GROUND_WORDS_READ = Object.freeze(["status", "territory"]);
|
|
120
124
|
export const FLOOR_GROUND_REFUSED = /\b(?:customers?|consumers?|clients|clientele|users?|audiences?|buyers?|purchasers?|patients?|professionals?|channels?|markets?|marketplaces?|sectors?|fields?|industr(?:y|ies|ial)|business(?:es)?|activit(?:y|ies)|commerce|commercial|trad(?:e|es|ing)(?!\s*-?\s*marks?)|distribution|wholesale|retail(?:ers?)?|sells?|sold|selling|b2b|b2c|off[- ]field|goods|products?|services?(?!\s*-?\s*marks?)|merchandise)\b/i;
|
|
121
125
|
/** The lists a group row may name: the shape's groups, the rest, and the lines the goods test could not place. */
|
|
122
126
|
export const ROW_GROUPS = Object.freeze([...BAND_GROUPS, "no_goods"]);
|
|
@@ -124,24 +128,30 @@ export const SET_ASIDE_ROW_CONTRACT = Object.freeze({
|
|
|
124
128
|
when: "a line the shape lists, one mark of one owner, whose records you do not carry. One row gives that "
|
|
125
129
|
+ "line its ground.",
|
|
126
130
|
fields: SET_ASIDE_ROW_FIELDS,
|
|
127
|
-
set_aside: "
|
|
128
|
-
+ "
|
|
129
|
-
+ "
|
|
130
|
-
+ "own position only.",
|
|
131
|
+
set_aside: "One record id of the line, from the band, or a list of record ids, one per line, that share one ground. "
|
|
132
|
+
+ "The ground covers every unplaced record of that mark of that owner. Another mark of the same owner needs its "
|
|
133
|
+
+ "own fate. A record that names no owner stands for its own position only.",
|
|
131
134
|
ground: "why this line's records are not carried, in a lawyer's words.",
|
|
132
|
-
on: "
|
|
133
|
-
+ "
|
|
134
|
-
+ "
|
|
135
|
+
on: "Required when the line holds a floor (a live identical or near-identical mark in an ordered class): one of "
|
|
136
|
+
+ "status, territory, goods, mark. Use goods when the registered goods, read in full, do not meet the client's "
|
|
137
|
+
+ "particular goods. Use mark when a reasoned comparison with the client's mark shows the two do not conflict. "
|
|
138
|
+
+ "The owner, its customers and its field are never a ground.",
|
|
135
139
|
not_a_candidate: "It places nothing. A record of the same line you do place stays placed.",
|
|
136
140
|
latest: "A later row for the same line replaces the earlier ground. To remove one, hand back "
|
|
137
141
|
+ "{\"retract\":\"<its set_aside id>\"}.",
|
|
138
|
-
unresolved: "
|
|
139
|
-
+ "
|
|
140
|
-
+ "
|
|
142
|
+
unresolved: "The driver says a row back by id when: its record is not in the band; it has no ground; a floor's "
|
|
143
|
+
+ "`on` is missing or not one of the four; a status or territory ground speaks of goods, customers, channels, "
|
|
144
|
+
+ "markets or field; a goods ground rests on a line not read, or on goods the run does not hold in full; a mark "
|
|
145
|
+
+ "ground names the client's own mark, or the run holds no applied-for mark to compare with. In a list, each "
|
|
146
|
+
+ "line is judged on its own.",
|
|
141
147
|
});
|
|
142
148
|
|
|
149
|
+
/** The record ids a set-aside row names: one, or the list of lines one ground covers. PURE. */
|
|
150
|
+
export const setAsideMembers = (row) => (Array.isArray(row?.set_aside) ? row.set_aside : [row?.set_aside])
|
|
151
|
+
.filter((id) => typeof id === "string" && id.trim() !== "").map((id) => id.trim());
|
|
152
|
+
|
|
143
153
|
/** Is this submitted row an owner's set-aside? PURE. */
|
|
144
|
-
export const isSetAsideRow = (row) =>
|
|
154
|
+
export const isSetAsideRow = (row) => setAsideMembers(row).length > 0;
|
|
145
155
|
|
|
146
156
|
// ONE ROW PER GROUP. The band shape lists the band in the lawyer's order, one line per mark of an owner,
|
|
147
157
|
// the rest last. A listed line gets a fate, not a row: it is placed, or set aside by name, or covered
|
|
@@ -27,9 +27,11 @@ import { buildSelectionIndex, selectionOf, rowIsSettled, seatFields, formRowKey,
|
|
|
27
27
|
SEAT_ROW_CONTRACT, SELECT_ROW_CONTRACT, PLACEMENT_FORM_PROVENANCE,
|
|
28
28
|
SET_ASIDE_ROW_CONTRACT, isSetAsideRow,
|
|
29
29
|
SET_ASIDE_GROUP_CONTRACT, isSetAsideGroupRow, groupName, groupOfKeys, namedKey, lineKey, servedLines,
|
|
30
|
-
groupRowCoversFloors, groupRowCoversFloorLine, FLOOR_GROUND_ON, FLOOR_GROUND_REFUSED, ROW_GROUPS, renderPlacementsJson,
|
|
30
|
+
groupRowCoversFloors, groupRowCoversFloorLine, FLOOR_GROUND_ON, FLOOR_GROUND_WORDS_READ, FLOOR_GROUND_REFUSED, ROW_GROUPS, renderPlacementsJson,
|
|
31
|
+
setAsideGrounds, setAsideMembers } from "./placement-form.mjs";
|
|
31
32
|
import { groupPageCall } from "./band-shape.mjs";
|
|
32
33
|
import { normalizeRecordUri } from "./registry-fidelity.mjs";
|
|
34
|
+
import { formKey } from "../providers/_shared/script-form.mjs"; // — the one fold for "is this the same form of the mark"
|
|
33
35
|
import { reconcileFloorDuty } from "./floor-duty.mjs";
|
|
34
36
|
|
|
35
37
|
/** Why a `select` the fold does not hold is refused, said back with it. */
|
|
@@ -162,8 +164,8 @@ export function unionPlacementForm(prior, submitted, input, { covered = false }
|
|
|
162
164
|
// placements.json. Prior first, so a later row for the same owner replaces the earlier ground, and a
|
|
163
165
|
// retraction names the record id the row set aside. A row the band cannot resolve, or one with no
|
|
164
166
|
// ground, is reported back by id, never dropped in silence.
|
|
165
|
-
// A ground by name covers the named line only. On a line holding a floor it is about
|
|
166
|
-
// its
|
|
167
|
+
// A ground by name covers the named line only. On a line holding a floor it is about its status, its territory,
|
|
168
|
+
// its registered goods or its mark, and says which in `on`; a ground about the owner never sets a floor aside.
|
|
167
169
|
const groups = input?.groups ?? null;
|
|
168
170
|
const floorUris = new Set((Array.isArray(input?.floors) ? input.floors : []).map((f) => normalizeRecordUri(f?.record_id) || String(f?.record_id ?? "")).filter(Boolean));
|
|
169
171
|
const holdsFloor = (canonical) => (canonical?.records ?? []).some((u) => floorUris.has(normalizeRecordUri(u) || String(u ?? "")));
|
|
@@ -174,10 +176,52 @@ export function unionPlacementForm(prior, submitted, input, { covered = false }
|
|
|
174
176
|
...["a", "b", "c", "d", "no_goods"].flatMap((g) => (Array.isArray(groups?.[g]?.list) ? groups[g].list : []).filter((l) => l?.floors > 0).map((l) => l.key)),
|
|
175
177
|
]);
|
|
176
178
|
const lineHoldsFloor = (canonical) => holdsFloor(canonical) || floorLines.has(namedKey(canonical));
|
|
179
|
+
// THE FLOORS OF EACH LINE, which a ground on a floor's goods or mark is checked against record by record.
|
|
180
|
+
const floorsOfLine = new Map();
|
|
181
|
+
for (const f of (Array.isArray(input?.floors) ? input.floors : [])) {
|
|
182
|
+
const u = normalizeRecordUri(f?.record_id) || String(f?.record_id ?? "");
|
|
183
|
+
const k = u ? namedKey(index.resolve(u)) : null;
|
|
184
|
+
if (k) floorsOfLine.set(k, [...(floorsOfLine.get(k) ?? []), { uri: u, mark: f?.mark_text ?? null }]);
|
|
185
|
+
}
|
|
186
|
+
// The records a ground on a line's goods or mark must hold for: the line's floors and the records of the position
|
|
187
|
+
// the row names, so a line the projection keys apart from its floors is still checked on the records it names.
|
|
188
|
+
const checkedRecords = (canonical) => {
|
|
189
|
+
const byUri = new Map((floorsOfLine.get(namedKey(canonical)) ?? []).map((f) => [f.uri, f]));
|
|
190
|
+
for (const u of canonical?.records ?? []) { const k = normalizeRecordUri(u) || String(u ?? ""); if (k && !byUri.has(k)) byUri.set(k, { uri: k, mark: canonical?.mark ?? null }); }
|
|
191
|
+
return [...byUri.values()];
|
|
192
|
+
};
|
|
193
|
+
const servedSet = servedLines(groups, input?.served, index);
|
|
194
|
+
const clientMarks = new Set((Array.isArray(input?.marks) ? input.marks : []).map((m) => formKey(String(m ?? ""))).filter(Boolean));
|
|
195
|
+
// WHY A GROUND ON A FLOOR'S GOODS OR MARK DOES NOT HOLD, or "" where it does (ruled 2026-10-27). Identical spelling
|
|
196
|
+
// and a shared class no longer bar the judgment; what bars it is the evidence. A goods ground stands on a line the
|
|
197
|
+
// step read and on goods the run holds complete for every floor of it: cut, unknown or missing goods establish
|
|
198
|
+
// nothing. A mark ground is a comparison with the client's mark, so it cannot set aside the client's mark itself.
|
|
199
|
+
const evidenceProblem = (on, canonical) => {
|
|
200
|
+
const records = checkedRecords(canonical);
|
|
201
|
+
if (on === "goods") {
|
|
202
|
+
if (!servedSet.has(namedKey(canonical)))
|
|
203
|
+
return "this line has not been read. Read it in band_shape or its group's page, or open its record with band_record, before you set it aside on its goods";
|
|
204
|
+
const states = input?.evidence instanceof Map ? input.evidence : null;
|
|
205
|
+
if (!states) return "the run has no record of this line's goods, so a goods ground cannot be checked. Place the line, or set it aside on its status, territory or mark";
|
|
206
|
+
const short = records.filter((r) => states.get(r.uri) !== "complete");
|
|
207
|
+
if (short.length)
|
|
208
|
+
return `the run does not hold the goods of ${short.slice(0, 3).map((r) => `${r.uri} (${states.get(r.uri) ?? "not recorded"})`).join(", ")}${short.length > 3 ? `, and ${short.length - 3} more` : ""} in full, so a goods ground cannot stand. Place the line, or set it aside on its status, territory or mark`;
|
|
209
|
+
}
|
|
210
|
+
if (on === "mark") {
|
|
211
|
+
if (!clientMarks.size) return "the run has no applied-for mark to compare with. Place the line, or set it aside on its status, territory or goods";
|
|
212
|
+
const own = records.find((r) => clientMarks.has(formKey(String(r.mark ?? ""))));
|
|
213
|
+
if (own) return `${own.uri} carries the client's own mark, so a mark ground cannot set it aside. Place the line, or set it aside on its status, territory or goods`;
|
|
214
|
+
}
|
|
215
|
+
return "";
|
|
216
|
+
};
|
|
177
217
|
const setAside = new Map();
|
|
178
218
|
const setAsideRefused = [];
|
|
179
219
|
const takeSetAside = (row) => {
|
|
180
|
-
|
|
220
|
+
// A LIST UNDER ONE GROUND is taken line by line: each member stands or is reported back on its own evidence, so a
|
|
221
|
+
// shared ground covers only the lines it holds for, and the exceptions are said back by id.
|
|
222
|
+
const members = setAsideMembers(row);
|
|
223
|
+
if (members.length > 1) { for (const m of members) takeSetAside({ ...row, set_aside: m }); return; }
|
|
224
|
+
const id = members[0];
|
|
181
225
|
const ground = typeof row.ground === "string" ? row.ground.trim() : "";
|
|
182
226
|
const canonical = index.resolve(id);
|
|
183
227
|
const refuse = (why) => { if (!setAsideRefused.some((x) => x.set_aside === id && x.why === why)) setAsideRefused.push({ set_aside: id, why }); };
|
|
@@ -185,8 +229,10 @@ export function unionPlacementForm(prior, submitted, input, { covered = false }
|
|
|
185
229
|
if (!ground) { refuse("no ground"); return; }
|
|
186
230
|
const on = typeof row.on === "string" ? row.on.trim().toLowerCase() : "";
|
|
187
231
|
if (lineHoldsFloor(canonical)) {
|
|
188
|
-
if (!FLOOR_GROUND_ON.includes(on)) { refuse(`a floor
|
|
189
|
-
if (FLOOR_GROUND_REFUSED.test(ground)) { refuse(
|
|
232
|
+
if (!FLOOR_GROUND_ON.includes(on)) { refuse(`a floor's set-aside needs "on": one of ${FLOOR_GROUND_ON.join(", ")}`); return; }
|
|
233
|
+
if (FLOOR_GROUND_WORDS_READ.includes(on) && FLOOR_GROUND_REFUSED.test(ground)) { refuse(`a ${on} ground is about the ${on} only, and this one speaks of goods, customers, channels, markets or field. Rewrite it about the ${on}, or send a ground about the registered goods with "on": "goods"`); return; }
|
|
234
|
+
const problem = evidenceProblem(on, canonical);
|
|
235
|
+
if (problem) { refuse(problem); return; }
|
|
190
236
|
}
|
|
191
237
|
const key = namedKey(canonical);
|
|
192
238
|
setAside.delete(key);
|
|
@@ -217,7 +263,6 @@ export function unionPlacementForm(prior, submitted, input, { covered = false }
|
|
|
217
263
|
// does not list, a count that is not the shape's, or a row with no ground is reported back, never dropped.
|
|
218
264
|
const setAsideGroups = new Map();
|
|
219
265
|
const groupRefused = [];
|
|
220
|
-
const servedSet = servedLines(groups, input?.served, index);
|
|
221
266
|
const takeGroup = (row) => {
|
|
222
267
|
const written = String(row.set_aside_group).trim();
|
|
223
268
|
const refuse = (why) => { if (!groupRefused.some((x) => x.set_aside_group === written && x.why === why)) groupRefused.push({ set_aside_group: written, why }); };
|
|
@@ -289,7 +334,7 @@ export function unionPlacementForm(prior, submitted, input, { covered = false }
|
|
|
289
334
|
// AN IDENTICAL MARK LIVE IN AN ORDERED OR FRAME-ADDED CLASS IS NEVER OUT-OF-SCOPE-FILTERED. The tier is
|
|
290
335
|
// refused, said back, and the row left open, so the floor duty still owes it a fate.
|
|
291
336
|
if (row.tier === "out-of-scope-filtered" && holdsFloor(row)) {
|
|
292
|
-
tierRefused.push({ select: row.select ?? null, why: "
|
|
337
|
+
tierRefused.push({ select: row.select ?? null, why: "a floor never takes out-of-scope-filtered. Place it at headline-candidate or sheet-2, or set its line aside by name" });
|
|
293
338
|
row.tier = null;
|
|
294
339
|
}
|
|
295
340
|
// JUDGED ON THE MERGED ROW, not on either input — a tier written on one pass and its reason on the
|
|
@@ -1256,11 +1256,18 @@ export function correctionConsistencyChecks({ reportMd, clientSummaryMd, auditMd
|
|
|
1256
1256
|
const report = String(reportMd ?? "");
|
|
1257
1257
|
const cs = String(clientSummaryMd ?? "");
|
|
1258
1258
|
const audit = String(auditMd ?? "");
|
|
1259
|
+
// A CARD THAT CARRIES ITS OWN `- ord:` LINE IS THE FINDING THAT LINE NAMES. One owner can hold the same mark in
|
|
1260
|
+
// several records, each its own finding, and a live one keeps its card under the same head when another is
|
|
1261
|
+
// withdrawn: read by its head, it was the withdrawn finding back, and that one false failure closed the client
|
|
1262
|
+
// export of a crowded test run (2026-10-28). The ord line already answers for such a card, so the head match is
|
|
1263
|
+
// for a card with none, the older card shape.
|
|
1264
|
+
const unnumberedHeads = [...report.matchAll(/^##\s+([^\n]+)\n?((?:- [^\n]*\n?)*)/gm)]
|
|
1265
|
+
.filter(([, , meta]) => !/^-\s*ord:/m.test(meta)).map(([, h]) => h);
|
|
1259
1266
|
for (const f of withdrawn) {
|
|
1260
1267
|
const ord = f.ordinal;
|
|
1261
1268
|
const markN = norm(f.mark), ownerN = norm(f.owner?.name);
|
|
1262
1269
|
const ordHit = ord != null && new RegExp(`^-\\s*ord:\\s*${ord}\\s*$`, "m").test(report);
|
|
1263
|
-
const headHit =
|
|
1270
|
+
const headHit = unnumberedHeads.some((h) => {
|
|
1264
1271
|
const hN = norm(h);
|
|
1265
1272
|
return markN && ownerN && hN.includes(markN) && hN.includes(ownerN);
|
|
1266
1273
|
});
|
package/driver/publish/index.mjs
CHANGED
|
@@ -1823,15 +1823,19 @@ export function composeEmailHtml(reportMdPath, url, auditFile, names = [], deliv
|
|
|
1823
1823
|
// doc-52 — the cover banner speaks the SAME plain "Only you can close these" wording as the report, never
|
|
1824
1824
|
// the engine clamp reasons (opts.conditions = verdict.json reasons). Machinery-only clamps (no client
|
|
1825
1825
|
// item authored) degrade to one generic plain line — never raw engine jargon, never truncated mid-sentence.
|
|
1826
|
-
|
|
1826
|
+
// A BLOCKING RESULT KEEPS THE REPORT'S ITEMS TOO (ruled 2026-10-27). The box showed only on a CONDITIONAL result, so
|
|
1827
|
+
// a blocking one lost the client's own actions the report lists. It shows them under the report's heading in the
|
|
1828
|
+
// report's words, and only where the report names any: no empty box, no fallback line.
|
|
1829
|
+
const emailConditions = opts?.verdict === 'CONDITIONAL' || opts?.verdict === 'BLOCKING' ? actYouConditions(parseActionBuckets(secs['Actions']).you) : [];
|
|
1827
1830
|
// THE REPORT IS THE MASTER (ruled 2026-09-22). The banner used to print a second summary composed
|
|
1828
1831
|
// beside the report — "On hold — …" on a run the report called conditional, although nothing is ever
|
|
1829
1832
|
// held — and before that the delivery gate's word. It now quotes the report: the rating, then the
|
|
1830
1833
|
// report's own conclusion (below), and where the report names what only the client can close, those
|
|
1831
1834
|
// items under the report's own heading. Nothing here composes a sentence of its own.
|
|
1835
|
+
const closeBox = (items) => `<p style="margin:0 0 8px;padding:8px 10px;background:#fdeeee;border:1px solid #c98a86;color:#6e1512"><b>Only you can close these</b><br>${items.map((r) => `• ${cell(String(r))}`).join('<br>')}</p>`;
|
|
1832
1836
|
const verdictBound = opts?.verdict === 'CONDITIONAL'
|
|
1833
|
-
?
|
|
1834
|
-
: '';
|
|
1837
|
+
? closeBox(emailConditions.length ? emailConditions : ['the open items set out in the report, before relying on a clean result'])
|
|
1838
|
+
: opts?.verdict === 'BLOCKING' && emailConditions.length ? closeBox(emailConditions) : '';
|
|
1835
1839
|
|
|
1836
1840
|
const reviewHeadline = `<div style="${FONT};font-size:11pt;color:#1a1a2e;margin:0 0 14px">`
|
|
1837
1841
|
+ flagBanner
|
|
@@ -146,13 +146,16 @@ export function accountedUris(model, owed = []) {
|
|
|
146
146
|
* THE SHEET-2 ROWS THE DRIVER WRITES. A sheet-2 entry is adopted as placement placed it: one row, on the entry's first
|
|
147
147
|
* live record (else its first), carrying placement's reason word for word, with verify "yes" because nothing has checked
|
|
148
148
|
* it against the live register. An entry the write-up overrode gets none: any row of its own naming any of the entry's
|
|
149
|
-
* records, or
|
|
150
|
-
*
|
|
149
|
+
* records, or an OVERRODE resolution naming one, is its ending instead. An ADOPTED resolution keeps placement's
|
|
150
|
+
* placement, so the entry keeps its row: counting it as an ending rendered an adoption exactly as an override, and
|
|
151
|
+
* kept entries vanished from the table the writer is owed from. An entry whose record the band does not hold gets
|
|
152
|
+
* none. PURE.
|
|
151
153
|
*/
|
|
152
154
|
export function adoptedRows(model, facts = emptyFacts()) {
|
|
153
155
|
const adopt = Array.isArray(facts?.adopt) ? facts.adopt : [];
|
|
154
156
|
if (!adopt.length) return [];
|
|
155
|
-
const
|
|
157
|
+
const overrides = { ...model, disagreement_resolutions: (model?.disagreement_resolutions ?? []).filter((d) => d?.decision !== "ADOPTED") };
|
|
158
|
+
const named = accountedUris(overrides, adopt.flatMap((e) => e.uris));
|
|
156
159
|
const out = [];
|
|
157
160
|
for (const e of adopt) {
|
|
158
161
|
if (e.uris.some((u) => named.has(u))) continue;
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// A first attempt has none, and its message is unchanged.
|
|
26
26
|
//
|
|
27
27
|
// A pure leaf: node:fs, node:path and shared/driver-dir.mjs only, so the research server can import it.
|
|
28
|
-
import { appendFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
29
29
|
import { dirname, join } from "node:path";
|
|
30
30
|
import { driverDir } from "../shared/driver-dir.mjs";
|
|
31
31
|
|
|
@@ -51,9 +51,13 @@ export function keepResearchAnswer(runDir, { session = null, agent = null, task,
|
|
|
51
51
|
/**
|
|
52
52
|
* The research answers the session `sessionKey` and its retries got, oldest first, one per question: the latest answer
|
|
53
53
|
* to each exact question. [] when there are none, or no record. A torn line is skipped. IMPURE (reads the record).
|
|
54
|
+
*
|
|
55
|
+
* `ofRun` widens it to every stage of this run: the writer is told to use an answer this run already holds, from an
|
|
56
|
+
* earlier stage as from an earlier attempt, and it cannot open `_driver/` itself. The record is the run's own file, so
|
|
57
|
+
* nothing another run asked can be in it.
|
|
54
58
|
* @returns {{ task: string, text: string, ts: string }[]}
|
|
55
59
|
*/
|
|
56
|
-
export function researchAnswersFor(runDir, sessionKey) {
|
|
60
|
+
export function researchAnswersFor(runDir, sessionKey, { ofRun = false } = {}) {
|
|
57
61
|
if (!runDir || !sessionKey) return [];
|
|
58
62
|
let raw = "";
|
|
59
63
|
try { raw = readFileSync(driverDir(runDir, RESEARCH_ANSWERS_FILE), "utf8"); } catch { return []; }
|
|
@@ -63,7 +67,7 @@ export function researchAnswersFor(runDir, sessionKey) {
|
|
|
63
67
|
if (!line.trim()) continue;
|
|
64
68
|
let row;
|
|
65
69
|
try { row = JSON.parse(line); } catch { continue; }
|
|
66
|
-
if (!own(row?.session) || typeof row.task !== "string" || !row.task.trim() || !isResearchAnswer(row.text)) continue;
|
|
70
|
+
if ((!ofRun && !own(row?.session)) || typeof row.task !== "string" || !row.task.trim() || !isResearchAnswer(row.text)) continue;
|
|
67
71
|
byTask.delete(row.task); // the latest answer to a question takes the place of an earlier one
|
|
68
72
|
byTask.set(row.task, { task: row.task, text: row.text, ts: String(row.ts ?? "") });
|
|
69
73
|
}
|
|
@@ -83,8 +87,8 @@ export const researchAnswerLine = (n, { task, text }) => `Question ${n}: ${oneLi
|
|
|
83
87
|
* folder, one question and its answer per line, and return how many. Nothing is written when there are none, and 0 is
|
|
84
88
|
* returned when the file could not be written: a message must never name a file that is not there. IMPURE.
|
|
85
89
|
*/
|
|
86
|
-
export function handResearchAnswers(runDir, sessionKey) {
|
|
87
|
-
const answers = researchAnswersFor(runDir, sessionKey);
|
|
90
|
+
export function handResearchAnswers(runDir, sessionKey, opts = {}) {
|
|
91
|
+
const answers = researchAnswersFor(runDir, sessionKey, opts);
|
|
88
92
|
if (!answers.length) return 0;
|
|
89
93
|
try {
|
|
90
94
|
const p = join(runDir, RESEARCH_ANSWERS_MD);
|
|
@@ -101,8 +105,51 @@ export function handResearchAnswers(runDir, sessionKey) {
|
|
|
101
105
|
export function researchAnsweredBlock(n) {
|
|
102
106
|
if (!(n > 0)) return "";
|
|
103
107
|
const asked = n === 1 ? "asked 1 question and got its answer" : `asked ${n} questions and got their answers`;
|
|
104
|
-
return `RESEARCH THIS RUN HAS ALREADY ANSWERED:
|
|
108
|
+
return `RESEARCH THIS RUN HAS ALREADY ANSWERED: earlier stages or attempts of this run ${asked}. They are in ${RESEARCH_ANSWERS_MD}, `
|
|
105
109
|
+ "one question and its answer per line. Before you ask about a finding's owner and mark, search that file for the owner's name "
|
|
106
|
-
+ "with search_run_artifacts. Where one of them
|
|
110
|
+
+ "with search_run_artifacts. Where one of them answers the same owner, mark, goods, territory and material time question, use its answer as "
|
|
107
111
|
+ "that finding's check, cite its URL and record it as you would a fresh one; ask only what none of them covers.";
|
|
108
112
|
}
|
|
113
|
+
|
|
114
|
+
// ── A REPLAY ANSWERS FROM THE RUN'S OWN RESEARCH, AND ASKS NOTHING NEW ────────────────────────────────────────────────
|
|
115
|
+
//
|
|
116
|
+
// A stage replayed offline in the experiment sandbox would send its research questions out again: a cost, and an
|
|
117
|
+
// answer that is not the one the run got. With `--research replay` the sandbox holds the run's own answers and says so
|
|
118
|
+
// in this marker, and the research server answers a question the run already asked from what the run got, and refuses
|
|
119
|
+
// a question it did not ask, sending nothing out. Only the sandbox writes the marker; a run never does.
|
|
120
|
+
export const RESEARCH_REPLAY_FILE = "research-replay.json";
|
|
121
|
+
|
|
122
|
+
/** True when this run folder is a replay of a run's research. IMPURE (reads the marker). */
|
|
123
|
+
export const researchReplayOn = (runDir) => Boolean(runDir) && existsSync(driverDir(runDir, RESEARCH_REPLAY_FILE));
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The run's latest answer to exactly this question, whichever of its stages asked it, or null. The question is
|
|
127
|
+
* matched as written, trimmed at its ends: a question worded differently is a new question. IMPURE (reads the record).
|
|
128
|
+
*/
|
|
129
|
+
export function savedResearchAnswer(runDir, task) {
|
|
130
|
+
const want = typeof task === "string" ? task.trim() : "";
|
|
131
|
+
if (!runDir || !want) return null;
|
|
132
|
+
let raw = "";
|
|
133
|
+
try { raw = readFileSync(driverDir(runDir, RESEARCH_ANSWERS_FILE), "utf8"); } catch { return null; }
|
|
134
|
+
let found = null;
|
|
135
|
+
for (const line of raw.split("\n")) {
|
|
136
|
+
if (!line.trim()) continue;
|
|
137
|
+
let row;
|
|
138
|
+
try { row = JSON.parse(line); } catch { continue; }
|
|
139
|
+
if (typeof row?.task !== "string" || row.task.trim() !== want || !isResearchAnswer(row.text)) continue;
|
|
140
|
+
if (!found || String(row.ts ?? "") >= String(found.ts ?? "")) found = { task: row.task, text: row.text, ts: String(row.ts ?? ""), session: row.session ?? null };
|
|
141
|
+
}
|
|
142
|
+
return found;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** How many questions the run's record answers: the experiment refuses a replay with nothing to replay. IMPURE. */
|
|
146
|
+
export function savedResearchCount(runDir) {
|
|
147
|
+
let raw = "";
|
|
148
|
+
try { raw = readFileSync(driverDir(runDir, RESEARCH_ANSWERS_FILE), "utf8"); } catch { return 0; }
|
|
149
|
+
const tasks = new Set();
|
|
150
|
+
for (const line of raw.split("\n")) {
|
|
151
|
+
try { const row = JSON.parse(line); if (typeof row?.task === "string" && row.task.trim() && isResearchAnswer(row.text)) tasks.add(row.task.trim()); }
|
|
152
|
+
catch { /* a torn line answers nothing */ }
|
|
153
|
+
}
|
|
154
|
+
return tasks.size;
|
|
155
|
+
}
|
package/driver/runner.mjs
CHANGED
|
@@ -43,7 +43,7 @@ import { checkResolvedProduct } from "./scope-rules.mjs"; // every resolved-pr
|
|
|
43
43
|
import { freeSlots } from "./slot-lock.mjs"; // admission arithmetic — see drainQueue
|
|
44
44
|
import { orderedQueueFiles } from "./queue-order.mjs"; // the order jobs are admitted in (shared with the portal)
|
|
45
45
|
import { isCancelled } from "./cancel.mjs"; // a stopped run must not come back through any resume path
|
|
46
|
-
import {
|
|
46
|
+
import { whatsappRouting } from "./stages.mjs";
|
|
47
47
|
import { writeOutboxPacket } from "./outbox.mjs";
|
|
48
48
|
import { writeRunStatus, terminalRunState } from "./progress.mjs"; // A5 — grace-exit park + reclaim-bound terminal go through the guarded status writer; terminalRunState is the run record's own terminal vocabulary (2155)
|
|
49
49
|
import { identitySeed } from "./progress.mjs"; // — the same stamp a run seeds, for the process that runs them
|
|
@@ -702,7 +702,7 @@ function preRunFailNotify(agentId, base, job, reason) {
|
|
|
702
702
|
jobId: job?.id ?? null, msgId: job?.msgId ?? null,
|
|
703
703
|
forwarder: job?.forwarder ?? null, forwarderEmail: job?.forwarderEmail ?? null,
|
|
704
704
|
markName: (job?.markName ?? job?.name ?? job?.marks?.[0]?.name) || null,
|
|
705
|
-
|
|
705
|
+
...whatsappRouting(job, agentId), // to whoever asked, as every other notice (the-notice-goes-to-whoever-asked)
|
|
706
706
|
reason: terminalReasonFields(reason).reason, text,
|
|
707
707
|
});
|
|
708
708
|
note(`[runner] pre-run failure ${p ? `packet \u2192 ${p}` : "packet write FAILED"} for ${base}`);
|
|
@@ -739,7 +739,7 @@ async function backstopFailureNotice({ res, job, agentId, base, codename, studio
|
|
|
739
739
|
// canonical runId form (charter P1 §3): `<slug>-<date>-<codename>` — owned.dir's leaf IS
|
|
740
740
|
// "<date>-<codename>", so the dated id is derivable even here (matches status.runId + rescan).
|
|
741
741
|
runId: `${slug}-${basename(owned.dir)}`, agent: agentId, job, failedStage: "pre-run",
|
|
742
|
-
shortReason, reasonVerbatim: reason.slice(0, 1000),
|
|
742
|
+
shortReason, reasonVerbatim: reason.slice(0, 1000),
|
|
743
743
|
});
|
|
744
744
|
// same per-send invariant as the pipeline's failure path: a fresh notice supersedes an older .sent
|
|
745
745
|
// and its per-channel receipts (B4 — stale receipts would make clearotron-deliver skip this notice)
|
|
@@ -2089,7 +2089,7 @@ function reclaimOrphanedClaims(qdir, agentId) {
|
|
|
2089
2089
|
// comment above calls out for NAMING THE ARTIFACT DIRECTORY at its end: the status field
|
|
2090
2090
|
// was repaired by and the notice a human actually reads was left cutting it blind.
|
|
2091
2091
|
shortReason: rt.reason, reasonVerbatim: reasonText, terminalKind: "reclaim-exhausted",
|
|
2092
|
-
priorAttempts: priorReclaims,
|
|
2092
|
+
priorAttempts: priorReclaims,
|
|
2093
2093
|
});
|
|
2094
2094
|
try { rmSync(join(owned.dir, ".sent"), { force: true }); } catch { /* none */ }
|
|
2095
2095
|
try { rmSync(driverDir(owned.dir, "send-receipts.json"), { force: true }); } catch { /* none */ }
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
// saved-findings-block.mjs — what a later attempt of the writer is told about the findings this run already saved.
|
|
5
|
+
//
|
|
6
|
+
// The writer saves each finding as it completes it (synthesis-record.mjs, `saveFindings`), and an attempt that is
|
|
7
|
+
// stopped keeps them. A later attempt is handed them as a file, never in its message: a saved finding carries its
|
|
8
|
+
// evidence, and a few hundred of them would swamp the message. The message says how many there are, where they are,
|
|
9
|
+
// that they stand, and which positions are still to settle. A first attempt has none, and its message is unchanged.
|
|
10
|
+
// The model reads this block, so its words change only as model-facing wording does.
|
|
11
|
+
//
|
|
12
|
+
// A pure leaf, so the stage table can import it.
|
|
13
|
+
|
|
14
|
+
/** The file in the run folder a later attempt is handed its run's saved findings in. */
|
|
15
|
+
export const SAVED_FINDINGS_FILE = "synthesis-saved.json";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The block a later attempt's message ends with, or "" when nothing is saved. `h` is what
|
|
19
|
+
* synthesis-record.mjs's `handSavedFindings` returned: `{ findings, declined, outstanding }`, where `outstanding` is
|
|
20
|
+
* null when the run cannot say which positions are still owed. PURE.
|
|
21
|
+
*/
|
|
22
|
+
export function savedFindingsBlock(h) {
|
|
23
|
+
if (!h || !(h.findings > 0)) return "";
|
|
24
|
+
const saved = h.findings === 1 ? "saved 1 finding" : `saved ${h.findings} findings`;
|
|
25
|
+
const declined = h.declined > 0 ? ` and declined ${h.declined === 1 ? "1 record" : `${h.declined} records`} through record_declination` : "";
|
|
26
|
+
const shown = Array.isArray(h.outstanding) ? h.outstanding.slice(0, 40) : [];
|
|
27
|
+
const more = Array.isArray(h.outstanding) && h.outstanding.length > shown.length ? `, and ${h.outstanding.length - shown.length} more` : "";
|
|
28
|
+
const owed = !Array.isArray(h.outstanding) ? ""
|
|
29
|
+
: h.outstanding.length ? ` Still to settle: ${h.outstanding.length === 1 ? "1 position" : `${h.outstanding.length} positions`}: ${shown.join(", ")}${more}.`
|
|
30
|
+
: " Every position you were handed is already settled by a saved finding or a declination.";
|
|
31
|
+
return `SAVED IN THIS RUN: an earlier attempt of this stage ${saved} through record_synthesis${declined}. The saved findings are in ${SAVED_FINDINGS_FILE}, `
|
|
32
|
+
+ "each as it was accepted, with its evidence. They stand: save one again only for identified contrary evidence, a reasoning error "
|
|
33
|
+
+ `or an applicable change in the instructions.${owed} Save each remaining finding as you complete it. Then send the whole record, `
|
|
34
|
+
+ "`narrative` with `findings`: it takes every saved finding for a position its `findings[]` does not carry, so that list need hold "
|
|
35
|
+
+ "only the findings not saved yet, and the overall conclusions answer for the complete set.";
|
|
36
|
+
}
|
|
@@ -396,7 +396,7 @@ Every judgment below spans the band and/or needs data a single unit cannot have,
|
|
|
396
396
|
**Consume `placement-recommendations.md` (from Touchpoint 2 `placement-inquiry`).** The per-candidate tiers ALSO arrive structured in `placements.json` beside the md — `{mark, owner, jurisdiction, records[], tier, reason}` per candidate (plus an optional `borderline: true` — see below), `reason` carrying placement's stated reasoning. When present, the JSON is the authoritative tier record: adopt or override each tier BY ENGAGING ITS `reason` (an override quotes the reason it contradicts — never a silent re-tier; a kept tier may tighten a label while reusing the reason). The md remains the home of the rulings tail (Disagreements, Coverage rulings, Open questions), which you carry forward exactly as below. On a corrective/repair pass do NOT re-read the whole md — the tiers are in the JSON, and the driver hands you the md's rulings tail verbatim in the dispatch (a `PLACEMENT RULINGS TAIL` block): adjudicate against it there, adopting or counter-reasoning each ruling. For each candidate `placement-inquiry` placed:
|
|
397
397
|
- Find the matching entry in `placements.json` (mark text + owner + jurisdiction match; fall back to the md's tier sections on runs that predate the JSON)
|
|
398
398
|
- Adopt the graduated outcome (`headline-candidate` / `sheet-2` / `watchlist-annex` / `out-of-scope-filtered`) as the placement
|
|
399
|
-
-
|
|
399
|
+
- Keep each placement decision that its evidence supports, with its reason. Before an entry goes to the writer, decide its relevance from the evidence you hold. Carry plausible conflicts and the questions the evidence leaves open. Drop every other entry once, with a substantive reason. The length of the report, thin use, a small owner and possible revocation are never substantive reasons. On its own, a sheet-2 tier, a class match or the words “completes the record” is no reason to carry an entry. Change a placement decision only by naming the evidence or the error that changes it.
|
|
400
400
|
- **Between `headline-candidate` and `sheet-2`, what you are adopting is an ANSWER, not a label.** Those two tiers are placement-inquiry's written answer to one question — *does this conflict change the advice, or only complete the record?* (a headline entry names what it changes; a sheet-2 entry states why the advice reads the same either way). Adopting means adopting that answer. **Overriding means answering the SAME question the other way, in writing**, in your `### Disagreement resolutions` row — never re-labelling the tier and never substituting a risk-tier argument for the question. An override that does not say what the conflict does (or does not do) to the advice has not engaged the placement
|
|
401
401
|
- **A `"borderline": true` entry in `placements.json` is placement declaring that its own answer could be argued either way on this record.** That is the expected place for your judgment to differ, and it is a correct declaration rather than a defect — but it must be **resolved either way, explicitly**, in a `### Disagreement resolutions` row (`ADOPTED placement-inquiry — <the reading you took, and why>` / `OVERRODE — <the other reading, and why>`), never silently carried and never left as a shrug. **The declaration is internal adjudication only**: it belongs in your audit trail beside the other open verification flags, and no borderline mark, no "arguably", no "either way" derived from it goes into the findings prose the client reads — you resolve it here so the report does not have to hedge it there
|
|
402
402
|
- The "Disagreements / flags surfaced to downstream" section names cases where placement-inquiry deviated from class-match — read those reasonings carefully
|
|
@@ -155,9 +155,9 @@ is not a reason to skip it, and "the rule said so" is not an answer.
|
|
|
155
155
|
headline.)
|
|
156
156
|
- **Dominant-element spine / calibration:** is each finding ranked by the *dominant element* + whole-mark confusion
|
|
157
157
|
test? Is a distinguished mark (house-mark-prefixed) **over-rated**, or a
|
|
158
|
-
bare-dominant-element-in-field conflict **under-rated or dropped**? Did
|
|
159
|
-
near-identical
|
|
160
|
-
|
|
158
|
+
bare-dominant-element-in-field conflict **under-rated or dropped**? Did an **on-point identical or
|
|
159
|
+
near-identical mark in the class** fall out of the report? It must not, even if revocable and whatever its
|
|
160
|
+
owner's size. Is any escalation of a distinguished mark resting on optics/PR/partner rather than a
|
|
161
161
|
named confusion theory? Is the headline risk driven by the top on-point conflicts, not a distinguished
|
|
162
162
|
mark or unrelated-field noise?
|
|
163
163
|
- **Coverage ledger:** is any `deferred` / `coverage-limited` row being narrated (or about to be narrated) as a
|
|
@@ -218,7 +218,9 @@ Every cross-check (including "checked, found nothing") logged in the unified aud
|
|
|
218
218
|
|
|
219
219
|
## Step 3.5 — Actual-use and enforcement check (mandatory for register hits rated medium risk or higher)
|
|
220
220
|
|
|
221
|
-
|
|
221
|
+
For **every register finding** advisory-rated on a rung **the framework rates medium risk or higher** (the stage message names those rungs; a rung below that is not checked, on any client's ladder), run a marketplace check via `perplexity_research` to determine whether the registered mark is **actually being used in the marketplace within the scope of the current search** (i.e. for the goods/services and field this clearance targets). **The same query also asks for the owner's enforcement record:** whether the owner has opposed, disputed or sued over this mark or a similar one.
|
|
222
|
+
|
|
223
|
+
Apply the existing triggers for actual-use, enforcement, and owner checks after the relevant legal assessment. First use a suitable answer already held by this run, including an earlier stage or attempt, where it answers the same owner, mark, goods, territory, and material time question. A source requirement is not a requirement for a new call. One answer may support several findings only where it answers each finding's question. Ask only what remains unanswered and record the result against every finding it supports.
|
|
222
224
|
|
|
223
225
|
Example: a JELLY register hit owned by Jellycat (a toy company) for Class 9 video game software — check whether Jellycat actually sells or markets video games under JELLY. If they don't (their commercial use is toys), note the filed-vs-used distinction in Key Factors and weigh it as a Stage-2 factor together with revocation-vulnerability — no automatic downgrade (see `synthesis-rules.md` → *Actual-use assessment results*).
|
|
224
226
|
|
|
@@ -245,7 +247,7 @@ If the marketplace query genuinely returned nothing, write the honest value verb
|
|
|
245
247
|
|
|
246
248
|
The driver's `narrative` validator (`verify.mjs` → `findUseCheckViolations`) **rejects the whole narrative** if a
|
|
247
249
|
Composite 3+ use-negative finding lacks this line, so the synthesis stage cannot ship an *inferred* "no use" —
|
|
248
|
-
it must actually run the scoped query (owner + mark + goods/field) and cite the result, or state the honest
|
|
250
|
+
it must actually run the scoped query (owner + mark + goods/field), unless an answer this run already holds answers that question, and cite the result, or state the honest
|
|
249
251
|
"no result". Never dress an un-run check as a clean negative. Also mirror the result into the matching `# Actions`
|
|
250
252
|
item: "confirm the owner's actual use" becomes the stated result, not a task handed onward (this is what fills
|
|
251
253
|
spec 09's "Checks we ran — what we found" bucket). On a perplexity outage the "no result" value is the accepted
|