clearotron 0.4.1 → 0.5.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +1 -0
  2. package/THIRD-PARTY-NOTICES.md +1 -1
  3. package/bin/start.mjs +33 -8
  4. package/build-info.json +2 -2
  5. package/driver/CHANGELOG.md +23 -0
  6. package/driver/ask-ledger.mjs +86 -15
  7. package/driver/band-shape.mjs +61 -11
  8. package/driver/band-size.mjs +10 -0
  9. package/driver/close-verify.mjs +29 -2
  10. package/driver/common-law-coverage-status.mjs +1 -1
  11. package/driver/connotation-search.mjs +7 -9
  12. package/driver/contract-e3-backlog.mjs +34 -34
  13. package/driver/contract-e3-baseline.json +5 -5
  14. package/driver/contract-vocabulary.mjs +12 -12
  15. package/driver/corrections-delivered.mjs +113 -0
  16. package/driver/corrections-feedforward.mjs +16 -6
  17. package/driver/corrective-arm.mjs +11 -8
  18. package/driver/coverage-form.mjs +3 -0
  19. package/driver/coverage-union.mjs +11 -2
  20. package/driver/digest-queue.mjs +1 -1
  21. package/driver/disposition-call.mjs +1 -1
  22. package/driver/document-coverage.mjs +3 -2
  23. package/driver/effective-scope.mjs +2 -2
  24. package/driver/engine/mcp/band-server.mjs +77 -13
  25. package/driver/engine/mcp/dispositions-server.mjs +2 -2
  26. package/driver/engine/mcp/recording-server.mjs +10 -6
  27. package/driver/findings-model.mjs +16 -4
  28. package/driver/floor-duty.mjs +9 -0
  29. package/driver/gate-metrics.mjs +7 -1
  30. package/driver/gateway.mjs +28 -14
  31. package/driver/goods-evidence.mjs +295 -0
  32. package/driver/knockout-assess-record.mjs +1 -1
  33. package/driver/narrative-refutation-record.mjs +40 -2
  34. package/driver/package.json +1 -1
  35. package/driver/pipeline.mjs +1945 -314
  36. package/driver/placement-decisions.mjs +243 -0
  37. package/driver/placement-form.mjs +42 -8
  38. package/driver/placement-union.mjs +14 -6
  39. package/driver/provider-usage.mjs +10 -7
  40. package/driver/publish/audit-from-spine.mjs +5 -2
  41. package/driver/publish/index.mjs +19 -4
  42. package/driver/publish/xlsx.mjs +6 -6
  43. package/driver/recall-reconciliation.mjs +15 -5
  44. package/driver/record-carry.mjs +70 -6
  45. package/driver/record-discard.mjs +7 -1
  46. package/driver/record-fold.mjs +14 -0
  47. package/driver/record-goods.mjs +101 -5
  48. package/driver/register-plan.mjs +89 -0
  49. package/driver/registry-fidelity.mjs +34 -3
  50. package/driver/remedy-accounting.mjs +52 -14
  51. package/driver/repair-composers.mjs +265 -105
  52. package/driver/repair-scope.mjs +327 -0
  53. package/driver/repair-targets.mjs +92 -0
  54. package/driver/result-noun-fields.mjs +3 -3
  55. package/driver/screen-gate.mjs +96 -0
  56. package/driver/skills/clearance-common-law/SKILL.md +8 -0
  57. package/driver/skills/clearance-register/SKILL.md +5 -5
  58. package/driver/skills/clearance-register/digest.md +14 -14
  59. package/driver/skills/clearance-register/unit.md +3 -3
  60. package/driver/skills/clearance-search/delivery-contract.md +2 -58
  61. package/driver/skills/clearance-search/report-prose.md +12 -3
  62. package/driver/skills/clearance-search/risk-framework-triage.md +1 -1
  63. package/driver/skills/clearance-search/synthesis-rules.md +10 -13
  64. package/driver/skills/clearance-variants/SKILL.md +4 -4
  65. package/driver/skills/knockout-assess/SKILL.md +9 -14
  66. package/driver/skills/knockout-frame/SKILL.md +5 -5
  67. package/driver/skills/narrative-refutation/SKILL.md +1 -1
  68. package/driver/skills/placement-inquiry/SKILL.md +1 -1
  69. package/driver/skills/whatif-memo/SKILL.md +1 -1
  70. package/driver/stage-context.mjs +2 -1
  71. package/driver/stages-knockout.mjs +1 -1
  72. package/driver/stages.mjs +95 -60
  73. package/driver/suite-census.json +367 -19
  74. package/driver/synthesis-record.mjs +310 -8
  75. package/driver/terminal-clamp.mjs +53 -2
  76. package/driver/verify-knockout.mjs +22 -4
  77. package/driver/verify.mjs +23 -3
  78. package/mcp-server/CHANGELOG.md +4 -0
  79. package/mcp-server/lib/driver.mjs +5 -0
  80. package/mcp-server/lib/events.mjs +25 -4
  81. package/mcp-server/lib/findings.mjs +4 -2
  82. package/mcp-server/package.json +1 -1
  83. package/package.json +1 -1
  84. package/portal-ui/package.json +1 -1
  85. package/providers/jx/src/core.js +3 -4
  86. package/providers/jx/src/judge.js +2 -2
  87. package/providers/jx/src/nativeread.js +1 -2
  88. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  89. package/providers/oauth-mcp-bridge/package.json +1 -1
  90. package/scripts/e2e-first-time.mjs +29 -12
@@ -0,0 +1,327 @@
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
+ // repair-scope.mjs — WHAT A DELIVERY-CHECK REPAIR CHANGED, AND WHAT RESTS ON IT.
5
+ //
6
+ // After the delivery check finds a fault in the narrative, the writer repairs its record, and the reviewer
7
+ // used to read the whole narrative again, cold, as if no review had happened. The review after a repair
8
+ // reads what the repair changed and every statement that rests on it, with the evidence to check both, and
9
+ // its flags go to one correction by the writer. This module works out the first two from the writer's
10
+ // saved record before and after the repair, by VALUE: a file written again with the same values is no
11
+ // change, and a reworded coverage area is one line removed and one added, said rather than hidden.
12
+ //
13
+ // PURE, apart from three thin IO helpers at the bottom. It holds no sentence the model reads: the composer
14
+ // in repair-composers.mjs renders the list from what this returns, and the labels below are the item names
15
+ // that list prints and the reviewer is asked to repeat, so a flag can be joined back to its item.
16
+ import { readFileSync, writeFileSync, renameSync, rmSync, mkdirSync } from "node:fs";
17
+ import { join, dirname } from "node:path";
18
+ import { driverDir } from "../shared/driver-dir.mjs";
19
+ import { synthesisCallPaths } from "./synthesis-record.mjs";
20
+ import { REPORT_LINE_KEY, REPORT_LINE_LABEL } from "./corrections-feedforward.mjs";
21
+
22
+ const isObj = (v) => v != null && typeof v === "object" && !Array.isArray(v);
23
+ const collapse = (s) => String(s).replace(/\s+/g, " ").trim();
24
+
25
+ /**
26
+ * One value's identity for "did it change": strings whitespace-collapsed, objects key-sorted, arrays in
27
+ * order, an absent key the same as an absent value. PURE.
28
+ */
29
+ export function canonicalValue(v) {
30
+ if (v === undefined || v === null) return "null";
31
+ if (typeof v === "string") return JSON.stringify(collapse(v));
32
+ if (typeof v !== "object") return JSON.stringify(v);
33
+ if (Array.isArray(v)) return `[${v.map(canonicalValue).join(",")}]`;
34
+ return `{${Object.keys(v).filter((k) => v[k] !== undefined).sort()
35
+ .map((k) => `${JSON.stringify(k)}:${canonicalValue(v[k])}`).join(",")}}`;
36
+ }
37
+ const same = (a, b) => canonicalValue(a) === canonicalValue(b);
38
+
39
+ /** The narrative's prose sections, with the titles the narrative renders them under. */
40
+ export const NARRATIVE_PROSE = Object.freeze([
41
+ Object.freeze({ key: "verdict", title: "Verdict", read: (n) => n?.verdict }),
42
+ Object.freeze({ key: "spine", title: "The spine", read: (n) => n?.spine }),
43
+ Object.freeze({ key: "coverage", title: "Coverage", read: (n) => n?.coverage?.read }),
44
+ Object.freeze({ key: "calibration", title: "Calibration challenges", read: (n) => n?.calibration }),
45
+ ]);
46
+
47
+ /** A section's value as one line of text, the challenges joined in order. PURE. */
48
+ export function sectionText(v) {
49
+ if (v == null) return "";
50
+ if (Array.isArray(v)) return v.map((c) => collapse(`${c?.challenge ?? ""} ${c?.answer ?? ""}`)).filter(Boolean).join(" ");
51
+ return collapse(v);
52
+ }
53
+
54
+ // ── THE LABELS: one name per item, printed in the list and repeated by the reviewer ──────────────────
55
+ export const LABEL = Object.freeze({
56
+ finding: (n) => `finding ${n}`,
57
+ coverage: (row) => REPORT_LINE_LABEL.coverage(row),
58
+ section: (title) => `the narrative's "${title}" section`,
59
+ "coverage-judgment": () => "the coverage judgment",
60
+ action: (a) => REPORT_LINE_LABEL.actions(a),
61
+ answer: (ask) => `the answer to "${ask}"`,
62
+ "mark-assessment": (key) => `the mark assessment's ${key}`,
63
+ "four-answers": (key) => `the four answers' ${key}`,
64
+ "context-notes": () => "the context notes",
65
+ });
66
+
67
+ const byKey = (list, keyOf) => {
68
+ const m = new Map();
69
+ for (const x of Array.isArray(list) ? list : []) m.set(keyOf(x), x);
70
+ return m;
71
+ };
72
+ const keyedChanges = (before, after, keyOf) => {
73
+ const b = byKey(before, keyOf), a = byKey(after, keyOf), out = [];
74
+ for (const [k, x] of a) {
75
+ if (!b.has(k)) out.push({ key: k, change: "added", before: null, after: x });
76
+ else if (!same(b.get(k), x)) out.push({ key: k, change: "changed", before: b.get(k), after: x });
77
+ }
78
+ for (const [k, x] of b) if (!a.has(k)) out.push({ key: k, change: "removed", before: x, after: null });
79
+ return out;
80
+ };
81
+ const objectKeyChanges = (before, after) => {
82
+ const keys = new Set([...Object.keys(isObj(before) ? before : {}), ...Object.keys(isObj(after) ? after : {})]);
83
+ return [...keys].sort().filter((k) => !same(before?.[k], after?.[k]))
84
+ .map((k) => ({ key: k, before: before?.[k] ?? null, after: after?.[k] ?? null }));
85
+ };
86
+
87
+ /**
88
+ * What the repair changed, from two accepted-call params `{narrative, findings}` in the model's numbering.
89
+ *
90
+ * `whole` is true when there is nothing to compare against, or when the record's version or framework moved:
91
+ * then nothing here can say which statements rest on what, and the review stays whole. The writer's own
92
+ * `corrections` note is not content a reader is given, so it is not compared. PURE.
93
+ */
94
+ export function changedContent(before, after) {
95
+ const none = { findings: [], lines: [], registers: [], sections: [], whole: false, why: null };
96
+ const bf = before?.findings, af = after?.findings;
97
+ if (!isObj(bf)) return { ...none, whole: true, why: "no-base" };
98
+ if (!isObj(af)) return { ...none, whole: true, why: "no-record" };
99
+ for (const k of ["schema_version", "rated_under_framework"]) {
100
+ if (!same(bf[k], af[k])) return { ...none, whole: true, why: `${k}-changed` };
101
+ }
102
+ const findings = keyedChanges(bf.findings, af.findings, (f) => f?.ordinal)
103
+ .map(({ key, change, before: b, after: a }) => ({
104
+ ordinal: key, change,
105
+ fields: change === "changed" ? objectKeyChanges(b, a).map((x) => x.key) : [],
106
+ before: b, after: a,
107
+ }))
108
+ .sort((x, y) => Number(x.ordinal) - Number(y.ordinal));
109
+ const lines = keyedChanges(bf.coverage, af.coverage, REPORT_LINE_KEY.coverage)
110
+ .map((x) => ({ ...x, area: (x.after ?? x.before)?.area ?? "" }));
111
+ const registers = [];
112
+ for (const x of keyedChanges(bf.actions, af.actions, REPORT_LINE_KEY.actions))
113
+ registers.push({ kind: "action", label: LABEL.action(x.after ?? x.before), ...x });
114
+ for (const x of keyedChanges(bf.ask_answers, af.ask_answers, (q) => collapse(q?.ask ?? "")))
115
+ registers.push({ kind: "answer", label: LABEL.answer((x.after ?? x.before)?.ask ?? ""), ...x });
116
+ for (const x of objectKeyChanges(bf.four_answers, af.four_answers))
117
+ registers.push({ kind: "four-answers", label: LABEL["four-answers"](x.key), change: "changed", ...x });
118
+ for (const x of objectKeyChanges(bf.mark_assessment, af.mark_assessment))
119
+ registers.push({ kind: "mark-assessment", label: LABEL["mark-assessment"](x.key), change: "changed", ...x });
120
+ if (!same(bf.coverage_judgment, af.coverage_judgment))
121
+ registers.push({ kind: "coverage-judgment", label: LABEL["coverage-judgment"](), key: "coverage_judgment", change: "changed",
122
+ before: bf.coverage_judgment ?? null, after: af.coverage_judgment ?? null });
123
+ if (!same(bf.context_notes, af.context_notes))
124
+ registers.push({ kind: "context-notes", label: LABEL["context-notes"](), key: "context_notes", change: "changed",
125
+ before: bf.context_notes ?? null, after: af.context_notes ?? null });
126
+ const sections = [];
127
+ for (const s of NARRATIVE_PROSE) {
128
+ const b = s.read(before?.narrative), a = s.read(after?.narrative);
129
+ if (!same(b, a)) sections.push({ key: s.key, title: s.title, before: sectionText(b), after: sectionText(a) });
130
+ }
131
+ return { findings, lines, registers, sections, whole: false, why: null };
132
+ }
133
+
134
+ /** Nothing changed: a file written again with the same values. PURE. */
135
+ export const isEmptyChange = (c) => Boolean(c) && !c.whole
136
+ && !c.findings.length && !c.lines.length && !c.registers.length && !c.sections.length;
137
+
138
+ /**
139
+ * The statements handed with the change because they rest on it, as they read now. The rule is typed and
140
+ * uses no prose matching; the reviewer judges any other dependent statement itself.
141
+ * · whenever anything changed: every prose section that did not itself change, and the coverage judgment;
142
+ * · when findings changed: every action, answer and four-answers row whose `ordinals` name one of them.
143
+ * PURE.
144
+ */
145
+ export function restingOn(changed, after) {
146
+ if (!changed || changed.whole || isEmptyChange(changed)) return [];
147
+ const out = [];
148
+ const changedSections = new Set(changed.sections.map((s) => s.key));
149
+ for (const s of NARRATIVE_PROSE) {
150
+ const text = sectionText(s.read(after?.narrative));
151
+ if (!changedSections.has(s.key) && text) out.push({ kind: "section", key: s.key, title: s.title, label: LABEL.section(s.title), text });
152
+ }
153
+ const af = after?.findings ?? {};
154
+ const changedRegister = (kind, key) => changed.registers.some((r) => r.kind === kind && String(r.key) === String(key));
155
+ if (af.coverage_judgment != null && !changedRegister("coverage-judgment", "coverage_judgment"))
156
+ out.push({ kind: "coverage-judgment", label: LABEL["coverage-judgment"](), value: af.coverage_judgment });
157
+ const moved = new Set(changed.findings.map((f) => Number(f.ordinal)));
158
+ if (moved.size) {
159
+ const names = (row) => (Array.isArray(row?.ordinals) ? row.ordinals.map(Number) : []).filter((o) => moved.has(o));
160
+ for (const a of Array.isArray(af.actions) ? af.actions : []) {
161
+ const hit = names(a);
162
+ if (hit.length && !changedRegister("action", REPORT_LINE_KEY.actions(a)))
163
+ out.push({ kind: "action", key: REPORT_LINE_KEY.actions(a), label: LABEL.action(a), ordinals: hit, text: collapse(a?.text ?? "") });
164
+ }
165
+ for (const q of Array.isArray(af.ask_answers) ? af.ask_answers : []) {
166
+ const hit = names(q);
167
+ if (hit.length && !changedRegister("answer", collapse(q?.ask ?? "")))
168
+ out.push({ kind: "answer", key: collapse(q?.ask ?? ""), label: LABEL.answer(q?.ask ?? ""), ordinals: hit, text: collapse(q?.answer ?? "") });
169
+ }
170
+ for (const [k, v] of Object.entries(isObj(af.four_answers) ? af.four_answers : {})) {
171
+ const hit = names(v);
172
+ if (hit.length && !changedRegister("four-answers", k))
173
+ out.push({ kind: "four-answers", key: k, label: LABEL["four-answers"](k), ordinals: hit, text: collapse(v?.read ?? "") });
174
+ }
175
+ }
176
+ return out;
177
+ }
178
+
179
+ /**
180
+ * Every item the review is about, with the one label the list prints for it: the changed items first, then
181
+ * the statements resting on them. A finding the delivery fold renumbered carries its delivered number too.
182
+ * PURE.
183
+ */
184
+ export function scopeItems(changed, resting, foldMap = null) {
185
+ const delivered = (n) => {
186
+ const m = foldMap?.modelToFolded?.[String(n)];
187
+ return m != null && Number(m) !== Number(n) ? Number(m) : null;
188
+ };
189
+ const items = [];
190
+ for (const f of changed?.findings ?? []) items.push({ kind: "finding", ordinal: Number(f.ordinal), delivered: delivered(f.ordinal), label: LABEL.finding(f.ordinal), change: f.change, role: "changed" });
191
+ for (const l of changed?.lines ?? []) items.push({ kind: "coverage", key: l.key, label: LABEL.coverage(l.after ?? l.before), change: l.change, role: "changed" });
192
+ for (const s of changed?.sections ?? []) items.push({ kind: "section", key: s.key, label: LABEL.section(s.title), change: "changed", role: "changed" });
193
+ for (const r of changed?.registers ?? []) items.push({ kind: r.kind, key: r.key, label: r.label, change: r.change, role: "changed" });
194
+ for (const r of resting ?? []) items.push({ kind: r.kind, key: r.key ?? null, label: r.label, ordinals: r.ordinals ?? null, role: "resting" });
195
+ return items;
196
+ }
197
+
198
+ /** Whether a delivered number differs from the model's for any listed finding. PURE. */
199
+ export const anyRenumbered = (items) => (items ?? []).some((i) => i.kind === "finding" && i.delivered != null);
200
+
201
+ /**
202
+ * The one target a flag names. A finding by its `on` ordinals, in the model's numbering as the review was
203
+ * asked; anything else by a flag text that BEGINS with one of the list's exact labels, the longest first. A
204
+ * label elsewhere in the text, or a quoted phrase, is not a target: matching prose is how a rewrite lands on
205
+ * the wrong thing. Otherwise the target is none. PURE.
206
+ */
207
+ export function resolveTarget(flag, items) {
208
+ const on = Array.isArray(flag?.on) ? flag.on.map(Number).filter((n) => Number.isInteger(n) && n > 0) : [];
209
+ if (on.length) return { kind: "finding", ordinals: on, label: on.map(LABEL.finding).join(", ") };
210
+ const text = String(flag?.text ?? "").trim();
211
+ const labels = [...new Set((items ?? []).filter((i) => i.kind !== "finding").map((i) => i.label))]
212
+ .sort((a, b) => b.length - a.length);
213
+ const hit = labels.find((l) => text.startsWith(l));
214
+ if (hit) {
215
+ const item = items.find((i) => i.label === hit);
216
+ return { kind: item.kind, key: item.key ?? null, label: hit };
217
+ }
218
+ return { kind: "none", label: null };
219
+ }
220
+
221
+ /**
222
+ * The three classes the scope decision asks for: MAPPED (the target is a changed item or a statement resting
223
+ * on one), UNRELATED (the target resolved, to something outside both), UNRESOLVED (no target). Every point
224
+ * goes to the correction either way; the class is recorded, so how often the review steps outside its scope
225
+ * can be measured. PURE.
226
+ */
227
+ export function classifyPoints(flags, items) {
228
+ const findings = new Set((items ?? []).filter((i) => i.kind === "finding").map((i) => i.ordinal));
229
+ const restingFindings = new Set((items ?? []).flatMap((i) => (i.role === "resting" && i.ordinals) ? i.ordinals : []));
230
+ const labels = new Set((items ?? []).filter((i) => i.kind !== "finding").map((i) => i.label));
231
+ return (flags ?? []).map((flag, i) => {
232
+ const target = resolveTarget(flag, items);
233
+ const cls = target.kind === "none" ? "unresolved"
234
+ : target.kind === "finding" ? (target.ordinals.some((o) => findings.has(o) || restingFindings.has(o)) ? "mapped" : "unrelated")
235
+ : (labels.has(target.label) ? "mapped" : "unrelated");
236
+ return { n: i + 1, kind: flag?.kind ?? null, on: Array.isArray(flag?.on) ? flag.on : null, target, class: cls };
237
+ });
238
+ }
239
+
240
+ /**
241
+ * Which of the reviewer's files the repair changed, for its inputs line: the narrative when a section, a
242
+ * coverage line or an answer moved (the narrative renders all three), the findings record when anything in it
243
+ * moved. PURE.
244
+ */
245
+ export function changedInputsOf(changed, { narrative, findings }) {
246
+ if (!changed || changed.whole) return [];
247
+ const inRecord = changed.findings.length || changed.lines.length || changed.registers.length;
248
+ const inNarrative = changed.sections.length || changed.lines.length || changed.registers.some((r) => r.kind === "answer");
249
+ return [inNarrative ? narrative : null, inRecord ? findings : null].filter(Boolean);
250
+ }
251
+
252
+ // ── IO: the repair base, and the review's own record ────────────────────────────────────────────────
253
+ const writeAtomic = (file, text) => {
254
+ mkdirSync(dirname(file), { recursive: true });
255
+ const tmp = `${file}.${process.pid}.tmp`;
256
+ writeFileSync(tmp, text);
257
+ renameSync(tmp, file);
258
+ };
259
+
260
+ /** Where the writer's accepted record is kept from just before the delivery-check repair. */
261
+ export function repairBasePath(runDir) {
262
+ return join(synthesisCallPaths(String(runDir ?? "")).dir, "accepted-before-repair.json");
263
+ }
264
+
265
+ /**
266
+ * Keep the writer's accepted record as it stands, just before the delivery-check repair. With no accepted
267
+ * record there is no base, and any earlier copy is removed so it cannot stand for this repair. Never throws.
268
+ * Returns whether a base was kept.
269
+ */
270
+ export function snapshotRepairBase(runDir) {
271
+ const at = repairBasePath(runDir);
272
+ try {
273
+ const bytes = readFileSync(synthesisCallPaths(String(runDir ?? "")).accepted, "utf8");
274
+ writeAtomic(at, bytes);
275
+ return true;
276
+ } catch {
277
+ try { rmSync(at, { force: true }); } catch { /* nothing to remove */ }
278
+ return false;
279
+ }
280
+ }
281
+
282
+ /** The kept base's params, or null. Never throws. */
283
+ export function readRepairBase(runDir) {
284
+ try { return JSON.parse(readFileSync(repairBasePath(runDir), "utf8"))?.params ?? null; } catch { return null; }
285
+ }
286
+
287
+ /** The review after a repair's own record: what it was handed, how each point was classed, what the pass did. */
288
+ export function postRepairRecordPath(runDir) { return driverDir(String(runDir ?? ""), "post-repair-review.json"); }
289
+
290
+ export function writePostRepairRecord(runDir, rec) {
291
+ try { writeAtomic(postRepairRecordPath(runDir), JSON.stringify({ ts: new Date().toISOString(), ...rec }, null, 2) + "\n"); return true; }
292
+ catch { return false; }
293
+ }
294
+
295
+ /**
296
+ * The review's list, as the values its composer renders: each change with its before and after, each
297
+ * statement resting on one, whether a delivered number differs, and which kinds of evidence to name. A
298
+ * changed finding carries only the fields that moved, and `records` is its `source` as the model saved it.
299
+ * PURE.
300
+ */
301
+ export function listForReview(changed, resting, foldMap = null) {
302
+ const delivered = (n) => {
303
+ const m = foldMap?.modelToFolded?.[String(n)];
304
+ return m != null && Number(m) !== Number(n) ? Number(m) : null;
305
+ };
306
+ const pick = (f, fields) => Object.fromEntries(fields.map((k) => [k, f?.[k] ?? null]));
307
+ const changes = [];
308
+ for (const f of changed?.findings ?? []) {
309
+ const base = { kind: "finding", change: f.change, ordinal: Number(f.ordinal), delivered: delivered(f.ordinal) };
310
+ if (f.change === "changed") changes.push({ ...base, fields: f.fields, before: pick(f.before, f.fields), after: pick(f.after, f.fields), records: f.after?.source ?? null });
311
+ else if (f.change === "added") changes.push({ ...base, after: f.after, records: f.after?.source ?? null });
312
+ else changes.push({ ...base, before: f.before });
313
+ }
314
+ for (const l of changed?.lines ?? []) changes.push({ kind: "coverage", change: l.change, label: LABEL.coverage(l.after ?? l.before), before: l.before, after: l.after });
315
+ for (const s of changed?.sections ?? []) changes.push({ kind: "section", change: "changed", title: s.title, before: s.before, after: s.after });
316
+ for (const r of changed?.registers ?? []) {
317
+ const val = (x) => (r.kind === "answer" ? (x?.answer ?? null) : x);
318
+ changes.push({ kind: r.kind, change: r.change, label: r.label, before: val(r.before), after: val(r.after) });
319
+ }
320
+ return {
321
+ changes,
322
+ resting: resting ?? [],
323
+ renumbered: changes.some((c) => c.kind === "finding" && c.delivered != null),
324
+ coverageChanged: changes.some((c) => c.kind === "coverage"),
325
+ evidence: { coverage: changes.some((c) => c.kind === "coverage"), finding: changes.some((c) => c.kind === "finding") },
326
+ };
327
+ }
@@ -0,0 +1,92 @@
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
+ // repair-targets.mjs — which part of the delivered report holds a delivery-check fault, so its repair can edit it.
4
+ //
5
+ // The report is assembled from parts that different writers own: the overview, which the report-overview step
6
+ // writes; each full card, which its own card step writes; and lines that code composes from the findings record —
7
+ // each card's finding sentence, every short entry and every grouped line. The pre-delivery check reads the assembled
8
+ // report, and most of its prose checks name no finding. A fault that names none went to the overview's repair, which
9
+ // cannot edit a card, so a fault inside a card shipped after a repair that could not reach it, and the overview was
10
+ // rewritten for nothing.
11
+ //
12
+ // So each such fault is located. Its own check runs again on each part's own text, and a part where it fails is a
13
+ // part that holds it: a card's fault goes to that card's repair, carrying the detail the check gives on that card
14
+ // alone, and the overview's goes to the overview's. A fault that only the lines code composes from the record hold is
15
+ // one neither of those repairs can edit: it goes to the writer, whose record those lines are composed from. A fault no
16
+ // single part holds (two cards counting one owner's registrations differently), or one whose check cannot be run on a
17
+ // part, keeps the overview's repair it always had.
18
+ import { readFileSync } from "node:fs";
19
+ import { prescriptionProseChecks, scopeNumberProseChecks, wipoLanguageChecks, countingChecks, countsFromFindings } from "./predelivery-lint.mjs";
20
+ import { netOf, memberLine } from "./write-up-render.mjs";
21
+
22
+ // The checks that judge a text by itself, run as the delivery check runs them, on one part at a time.
23
+ const ON_A_PART = {
24
+ "prescription-prose": (text) => prescriptionProseChecks({ reportMd: text }).find((c) => c.surface === "report"),
25
+ "scope-numbers-in-prose": (text) => scopeNumberProseChecks({ reportMd: text }).find((c) => c.surface === "report"),
26
+ "wipo-designation-language": (text) => wipoLanguageChecks({ reportMd: text }).find((c) => c.surface === "report"),
27
+ "counting-consistency": (text) => countingChecks({ report: text })[0],
28
+ "counting-vs-findings": (text, findings) => countsFromFindings(findings, { report: text })[0],
29
+ };
30
+ export const LOCATED_CHECKS = Object.freeze(Object.keys(ON_A_PART));
31
+
32
+ /**
33
+ * Give each report fault the part that holds it. PURE. A fault whose check named its finding goes to that finding's
34
+ * card, as it always did; one that names none is located.
35
+ *
36
+ * `parts` are the texts as their writers wrote them: `cards`, each full card's own file ({ordinal, text}); `overview`,
37
+ * the overview's own file; and `stamped`, the lines code composes from the record ({ordinal, text}). `findings` is
38
+ * the record the delivery check judged, for the check that compares a count with it.
39
+ *
40
+ * Returns `{cards, overview, stamped, unlocated}`: `cards` maps a card's ordinal to the faults it holds, each with
41
+ * that card's ordinal and the detail its check gives on that card alone; `overview` holds the faults the overview's
42
+ * repair is given, those it holds (with its own detail) and those no part holds alone; `stamped` the faults only
43
+ * lines composed from the record hold, each with `parts`, the finding of each line that holds it and the detail its
44
+ * check gives on that line; `unlocated` the faults no part holds alone, already counted in `overview`.
45
+ */
46
+ export function locateReportFaults(failures, { cards = [], overview = null, stamped = [], findings = [] } = {}) {
47
+ const out = { cards: new Map(), overview: [], stamped: [], unlocated: [] };
48
+ const fails = (rerun, text) => {
49
+ if (!String(text ?? "").trim()) return null;
50
+ const c = rerun(text, findings);
51
+ return c && c.pass === false ? c : null;
52
+ };
53
+ const toCard = (ordinal, fault) => out.cards.set(ordinal, [...(out.cards.get(ordinal) ?? []), fault]);
54
+ for (const f of failures ?? []) {
55
+ if (f?.ordinal != null) { toCard(f.ordinal, f); continue; } // the check named its finding: that card, as it always was
56
+ const rerun = ON_A_PART[f?.id];
57
+ if (!rerun) { out.overview.push(f); out.unlocated.push(f); continue; }
58
+ let held = false;
59
+ for (const c of cards) {
60
+ const hit = fails(rerun, c.text);
61
+ if (!hit) continue;
62
+ held = true;
63
+ toCard(c.ordinal, { ...f, ordinal: c.ordinal, detail: hit.detail });
64
+ }
65
+ const shell = fails(rerun, overview);
66
+ if (shell) { held = true; out.overview.push({ ...f, detail: shell.detail }); }
67
+ if (held) continue;
68
+ const lines = stamped.map((s) => ({ ordinal: s.ordinal, hit: fails(rerun, s.text) })).filter((x) => x.hit);
69
+ if (lines.length) { out.stamped.push({ ...f, parts: lines.map((x) => ({ ordinal: x.ordinal, detail: x.hit.detail ?? f.detail })) }); continue; }
70
+ out.overview.push(f);
71
+ out.unlocated.push(f);
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /**
77
+ * The report's parts as their writers wrote them, read from the run. Never throws: a part that cannot be read is left
78
+ * out, so no fault is placed in it. `plan` is the write-up plan the report was assembled from (pipeline.mjs
79
+ * planWriteUps): its full cards are the cards' own files, and its short entries and grouped lines, like each card's
80
+ * finding sentence, are composed from the record.
81
+ */
82
+ export function reportPartsOf(P, findings, plan) {
83
+ const read = (at) => { try { return readFileSync(at, "utf8"); } catch { return null; } };
84
+ const byOrdinal = new Map((findings ?? []).filter((f) => f?.ordinal != null).map((f) => [f.ordinal, f]));
85
+ const cards = (plan?.cardOrdinals ?? []).map((o) => ({ ordinal: o, text: read(P.reportCard(String(o))) })).filter((c) => c.text != null);
86
+ const stamped = [
87
+ ...(plan?.cardOrdinals ?? []).map((o) => ({ ordinal: o, text: netOf(byOrdinal.get(o)) })),
88
+ ...(plan?.entrySingles ?? []).map((f) => ({ ordinal: f.ordinal, text: read(P.reportCard(String(f.ordinal))) ?? netOf(f) })),
89
+ ...(plan?.groupedMembers ?? []).map((f) => ({ ordinal: f.ordinal, text: memberLine(f) })),
90
+ ];
91
+ return { cards, overview: read(P.reportOverview), stamped, findings };
92
+ }
@@ -70,8 +70,8 @@ export const RESULT_NOUN_FIELDS = Object.freeze([
70
70
  why: "closure counts derived from the landed set (`qids.filter(q => landed.has(q)).length`) and from the receipt" },
71
71
  { file: "driver/pipeline.mjs", noun: "executed", sites: 4, atWriteSite: 3, verdict: "result",
72
72
  why: "lengths of the executed set as the join computed it" },
73
- { file: "driver/pipeline.mjs", noun: "outcome", sites: 11, atWriteSite: 6, verdict: "result",
74
- why: "the one member that reported an invocation — taint-rerun's `r.ok ? \"ok\" : …`, which travels on a StageFailure packet — now carries a `cleared` sibling read from the taint, the remedy 10/5 -> 11/6 at the profile-store receipt's `outcome: pr.outcome`. Classified by READING ITS WRITING SITE, which is profiles.mjs and not this file: the value is one of three literals chosen by a situation the resolver decided (`overlay` and `bundled-fallback` are `pass`, `env-arrived-late` is `blocked`), never a call's return read as a verdict. `bundled-fallback` being `pass` is the point of the whole receipt — a legitimate install that nobody was told about is what this row says out loud." },
73
+ { file: "driver/pipeline.mjs", noun: "outcome", sites: 12, atWriteSite: 6, verdict: "result",
74
+ why: "the one member that reported an invocation — taint-rerun's `r.ok ? \"ok\" : …`, which travels on a StageFailure packet — now carries a `cleared` sibling read from the taint, the remedy 10/5 -> 11/6 at the profile-store receipt's `outcome: pr.outcome`. Classified by READING ITS WRITING SITE, which is profiles.mjs and not this file: the value is one of three literals chosen by a situation the resolver decided (`overlay` and `bundled-fallback` are `pass`, `env-arrived-late` is `blocked`), never a call's return read as a verdict. `bundled-fallback` being `pass` is the point of the whole receipt — a legitimate install that nobody was told about is what this row says out loud. 11/6 -> 12/6 at the reviewing lawyer's record, where a read review whose one correction never ran prints its points with `outcome: \"not-checkable\"`: a literal the situation decides (no correction is on record to compare), never a call's return." },
75
75
  { file: "driver/pipeline.mjs", noun: "permanent", sites: 1, atWriteSite: 1, verdict: "result",
76
76
  why: "`permanent.length` — a count of the classified set" },
77
77
  { file: "driver/pipeline.mjs", noun: "recovered", sites: 5, atWriteSite: 5, verdict: "result",
@@ -79,7 +79,7 @@ export const RESULT_NOUN_FIELDS = Object.freeze([
79
79
  + "The two newest record what an engine-side re-issue of a missing meaning search actually brought back, counted off the rows "
80
80
  + "in hand at the write site rather than from anything the call promised — an attempt that recovered nothing writes 0, which "
81
81
  + "is the fact the disclosure downstream depends on" },
82
- { file: "driver/pipeline.mjs", noun: "settled", sites: 5, atWriteSite: 4, verdict: "result",
82
+ { file: "driver/pipeline.mjs", noun: "settled", sites: 6, atWriteSite: 4, verdict: "result",
83
83
  why: "counts off the union and the doubt ledger" },
84
84
  { file: "driver/pipeline.mjs", noun: "verified", sites: 4, atWriteSite: 2, verdict: "result",
85
85
  why: "two sites, both measured states rather than calls returning. (1) `rows.filter(r => r.verified).length`, where each row's flag is `srRecords.has(senior.uri)` — a lookup, not a call. (2) the house-element ownership row's `verified: receipt.verified`, which the verifier sets only where a returned register record matched the client's own name AND was live AND carried an instructed class; the lookup CALL returning is a separate field on the same receipt (`reason`), and an unanswered lookup writes this false" },
@@ -231,3 +231,99 @@ export function findScreenGateParseGaps(findingsContent) {
231
231
  }
232
232
  return gaps;
233
233
  }
234
+
235
+ // ── WHAT EACH POLICED DROP RESTS ON (ruled 2026-10-18) ────────────────────────────────────────────────
236
+ //
237
+ // The gate above asks one question of each in-scope live goods drop: does the run hold, or has it fetched, the
238
+ // record? A record the run holds answers it whether or not the reading step ever opened it: on a register whose
239
+ // screen hydrates every band record, every drop is answered before any step reads a word. A record held is not
240
+ // evidence the model read it. The rule stays exactly as it is; this records, for every drop the rule polices,
241
+ // what its answer stood for, AS IT STOOD WHEN THE GATE JUDGED IT: the goods the drop rested on, the reading the
242
+ // run recorded for the step that wrote the drop, and what the gate then fetched, with the state after those
243
+ // fetches kept apart. Measured after the fetches alone, a record the gate fetched reads as one the run held all
244
+ // along, so the record could never show which fetch the drop's own goods had made unnecessary.
245
+ //
246
+ // WHAT THE READING STEP SAW is every route the reading log records for its sessions: a `band_record` open of
247
+ // the record, and a lookup, a part of the shape or a page of a group that served the record's line with its
248
+ // goods words. "No recorded read" means none of those is in the log for that step. The log is best-effort, and
249
+ // goods can reach a judgment through other permitted inputs, so it is never read as "never read".
250
+ //
251
+ // The counts say how many fetches the rule avoided and how many it added, and how many drops a rule that asked
252
+ // for an opened record would send back instead. Same parser, same rows, same in-scope test as
253
+ // findScreenGateViolations: every policed drop is a violation against an empty fetched set.
254
+ const gateKey = (u) => (typeof u === "string" ? (u.match(URI_RE)?.[0] ?? u).toLowerCase() : "");
255
+
256
+ /** The drops the gate polices in this findings text, by gate key, in order. PURE. */
257
+ export function policedDropUris(findingsContent = "") {
258
+ return [...new Set(findScreenGateViolations(findingsContent, new Set()).map((v) => gateKey(v.uri)).filter(Boolean))];
259
+ }
260
+
261
+ /**
262
+ * @param findingsContent the register-findings.md text the gate judged
263
+ * @param heldBefore record uris the gate found held or fetched before it fetched anything (gate-keyed)
264
+ * @param heldAfter the same after the gate's own fetches
265
+ * @param before uri → {goods, source}: the goods evidence (goods-evidence.mjs) as it stood before the gate fetched
266
+ * @param after the same after the gate's fetches; defaults to `before`
267
+ * @param reads goods-evidence.mjs recordReads: uri → {opened, shown, missed} session sets
268
+ * @param lines goods-evidence.mjs linesRead: line key → {band_shape, group_page} session sets
269
+ * @param lineOf uri → the band shape line key the record sits on, or null
270
+ * @param readerStage the stage that writes the drop rows, matched within a session key ("register-digest")
271
+ * @param gateFetched uri → {ok, cause} for each fetch the gate made
272
+ * @returns {{drops, counts, fetches, alternatives, unnamed}} PURE.
273
+ */
274
+ export function screenGateEvidence({ findingsContent = "", heldBefore = new Set(), heldAfter = new Set(), before = () => null, after = null,
275
+ reads = new Map(), lines = new Map(), lineOf = () => null, readerStage = "register-digest", gateFetched = new Map() } = {}) {
276
+ const policed = findScreenGateViolations(findingsContent, new Set());
277
+ const unnamed = policed.filter((v) => !v.uri).length;
278
+ const keyed = (set) => new Set([...(set ?? [])].map(gateKey));
279
+ const was = keyed(heldBefore), now = keyed(heldAfter);
280
+ const fetched = new Map([...(gateFetched ?? new Map())].map(([u, r]) => [gateKey(u), r]));
281
+ const byReader = (sessions) => [...(sessions ?? [])].some((s) => String(s).includes(readerStage));
282
+ const stateAt = (fn, uri) => { const e = typeof fn === "function" ? fn(uri) : null; return { goods: e?.goods || null, source: e?.source || null }; };
283
+ const seen = new Set();
284
+ const drops = [];
285
+ for (const v of policed) {
286
+ const uri = gateKey(v.uri);
287
+ if (!uri || seen.has(uri)) continue;
288
+ seen.add(uri);
289
+ const r = reads?.get?.(uri);
290
+ const line = lines?.get?.(lineOf(uri)) ?? null;
291
+ const routes = [
292
+ byReader(r?.opened) && "band_record", byReader(r?.answered) && "band_record:search-answer", byReader(r?.shown) && "band_lookup",
293
+ byReader(line?.band_shape) && "band_shape", byReader(line?.group_page) && "group_page",
294
+ ].filter(Boolean);
295
+ // The search answer's goods, served in full by the record tool where no official record was on file, are their
296
+ // own reading: more than a line's goods words, and not the official record the drop is policed on.
297
+ const reading = routes.includes("band_record") ? "opened" : routes.includes("band_record:search-answer") ? "search-answer-read"
298
+ : routes.length ? "goods-words-shown" : "no-recorded-read";
299
+ const f = fetched.get(uri);
300
+ const b = stateAt(before, uri), a = f ? stateAt(after ?? before, uri) : b;
301
+ drops.push({ uri, mark: v.mark, held_before_gate: was.has(uri), goods_before_gate: b.goods, source_before_gate: b.source, reading, routes,
302
+ gate_fetch: f ? (f.ok ? "fetched" : "failed") : "none", ...(f && !f.ok && f.cause ? { cause: String(f.cause).slice(0, 160) } : {}),
303
+ held_after_gate: now.has(uri), goods_after_gate: a.goods, source_after_gate: a.source });
304
+ }
305
+ const n = (pred) => drops.filter(pred).length;
306
+ const counts = {
307
+ policed: drops.length,
308
+ held_before_gate: n((d) => d.held_before_gate),
309
+ opened_by_reader: n((d) => d.reading === "opened"),
310
+ search_answer_read: n((d) => d.reading === "search-answer-read"),
311
+ goods_words_shown_only: n((d) => d.reading === "goods-words-shown"),
312
+ held_no_recorded_open: n((d) => d.held_before_gate && d.reading !== "opened"),
313
+ held_no_recorded_read: n((d) => d.held_before_gate && d.reading === "no-recorded-read"),
314
+ gate_fetched: n((d) => d.gate_fetch !== "none"),
315
+ gate_fetch_failed: n((d) => d.gate_fetch === "failed"),
316
+ still_unheld: n((d) => !d.held_after_gate),
317
+ };
318
+ return {
319
+ drops, counts, unnamed,
320
+ fetches: { avoided: counts.held_before_gate, added: counts.gate_fetched },
321
+ alternatives: {
322
+ // Were only a record the reading step OPENED to answer the gate: the drops it would send back to that step.
323
+ read_rule: { would_send_back: counts.held_no_recorded_open },
324
+ // Were a band row's whole goods to answer it: the fetches the gate made that the drop's own goods, as they
325
+ // stood when the gate judged it, would have made unnecessary.
326
+ row_goods_rule: { fetches_not_needed: n((d) => d.gate_fetch !== "none" && d.source_before_gate === "search-row" && d.goods_before_gate === "complete") },
327
+ },
328
+ };
329
+ }
@@ -392,6 +392,14 @@ Record the same statuses by calling `record_coverage_status`, passing `grid_spec
392
392
  - Brightwick "Raising Your Play" marketing presence in EU — coverage was thin, may need a region-specific follow-up
393
393
  ```
394
394
 
395
+ ## At every step: look at what you have before you work on it
396
+
397
+ Fifty? Read them all. A few hundred? Sort by the client's goods and markets, read the near ones first, list
398
+ the rest. A thousand or more? The field is crowded: read the identical and live ones for the client's
399
+ goods, tell the client it is crowded, and do not write up the rest. Carry forward only what a lawyer would
400
+ raise with the client. Write down what you set aside and why. This applies to the register's answer, to the
401
+ list of records, to the placements, to the off-register sweep and to the write-up alike.
402
+
395
403
  ## Process
396
404
 
397
405
  Four steps per mark.
@@ -22,10 +22,10 @@ Two modes, chosen by the orchestrator and stated in the spawn `task`:
22
22
  the **complete named band + crowd descriptors** cross the firewall as the band JSON.
23
23
  - **Digest mode (judgment — Layer B)** — the task gives the paths to the per-axis prose digests (audit
24
24
  summaries) and the manifest; the driver has already MERGED the per-axis bands into the run's complete
25
- band. The worker reads that COMPLETE band **through the band tools** (`band_shape` first, then
25
+ band. The worker reads the candidates placement carried forward and their supporting evidence **through the band tools** (`band_shape` first, then
26
26
  `band_lookup` / `band_record` — every call lands in the reading audit; never by opening or slicing band
27
27
  files), performs all cross-cutting judgment (relevance, owner aggregation, opposition, Option-D),
28
- DECIDES SUFFICIENCY, and writes `studio/clearance-search/<slug>/<date>/register-findings.md`.
28
+ DECIDES SUFFICIENCY, and hands the register-side findings back as typed rows — the driver renders the document the orchestrator synthesises from; the seat writes no file.
29
29
 
30
30
  Reads (both modes): the variant manifest at `studio/clearance-search/<slug>/<date>/variant-manifest.md` (archetype + risk
31
31
  theory), the `matter-context.md` at `studio/clearance-search/<slug>/<date>/matter-context.md` (Phase 0 strategic anchor — names materially-matters jurisdictions, watchlist-owner seeds, off-field sectors), the request context, the active **provider**. The **watchlist-owner seeds are enrichment / additive-surfacing context, not a search or priority filter** — an in-class identical/near-identical incumbent surfaces on field-relevance alone whether or not its owner is named (see `digest.md` Step 5).
@@ -34,7 +34,7 @@ theory), the `matter-context.md` at `studio/clearance-search/<slug>/<date>/matte
34
34
 
35
35
  Writes:
36
36
  - Unit mode — TWO artifacts, both gate-checked, neither optional. `studio/clearance-search/<slug>/<date>/register-units/<axis>.md` is the stage's DECLARED OUTPUT: the driver fails the pass outright when it is absent, and the digest worker reads it as an input. `studio/clearance-search/<slug>/<date>/register-units/<axis>-band.json` is the COMPLETE named band — a JSON array of `enumerated` / `incomplete` blocks (see `unit.md` → *Named-band artifact*) — carrying every record and no clearance verdict. The md narrates and proves nothing on its own: an md narrating a completed sweep while the band its plan entries call for is missing is refused as `named_band_missing`. In plan mode `register_execute_plan` writes the band itself and you never hand-write its blocks.
37
- - Digest mode: `studio/clearance-search/<slug>/<date>/register-findings.md`. Coverage statuses are NOT a file you write: they ride the `record_coverage` tool into a driver-held record, and the driver renders the `## Coverage ledger` table and its JSON mirror from it (see *Coverage ledger* below and `digest.md` → *Coverage ledger*). Optional: `register-details/` snapshots inside the run-dir.
37
+ - Digest mode: you write no file. The driver renders `studio/clearance-search/<slug>/<date>/register-findings.md` from what you send. Coverage statuses are NOT a file you write: they ride the `record_coverage` tool into a driver-held record, and the driver renders the `## Coverage ledger` table and its JSON mirror from it (see *Coverage ledger* below and `digest.md` → *Coverage ledger*).
38
38
 
39
39
  Returns to the orchestrator: your **final session message** — a 2–3 line summary (counts + the
40
40
  absolute path of the file you wrote). Keep raw character-noise records out of the message and out of any
@@ -118,8 +118,8 @@ is, then read that list in full and decide.
118
118
 
119
119
  1. Ask again for the identical mark, in the instructed classes, limited to the client's goods words. Read
120
120
  that list.
121
- 2. Still a crowd? Ask again with the dominant goods word alone, then one class at a time: the instructed
122
- classes first, then the incumbent's (the classes the owners crowding the list file in). Read each list.
121
+ 2. Still a crowd? Ask again with the dominant goods word alone, then one class at a time, in the instructed
122
+ classes. Read each list.
123
123
  3. Still a crowd? Ask again one question per market, in the order your dispatch names: the order's own countries;
124
124
  on a worldwide order, the customer's priority markets, else the markets the customer's field and brand
125
125
  point to, else the major markets. Read those lists.