clearotron 0.5.0-beta.3 → 0.5.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,7 +34,7 @@ import { paths, STAGES, axisTier, decideAxes, assertTierSanity, assertEffectiveT
34
34
  chainEntries, stageOrdinal, stageInputs, stageOutputs, dependencyOrder, REGISTER_AXES, REGISTER_ENUMERATE_TOOL,
35
35
  buildEscalationFollowup, buildEnvelopeCloseFollowup, buildFrameReopenFollowup,
36
36
  buildFrameReopenRetryMessage, thinkingFor, composeFollowup, stampDispatchBlocks, recordEmptyReturn, nothingFound, nothingToRead, PROVIDER_META, proseRungDirective, inquiryRungDirective } from "./stages.mjs"; import { bandSizeForStage, derivedLimitSec, limitExceedsCeiling, ceilingRefusal, stagesRefusingBand } from "./band-size.mjs";
37
- import { handResearchAnswers } from "./research-answers.mjs"; // the research a stage's earlier attempts got, handed to a later one
37
+ import { handResearchAnswers, RESEARCH_REPLAY_FILE, savedResearchCount } from "./research-answers.mjs"; // the research a stage's earlier attempts got, handed to a later one; a replay of it
38
38
  import { IDENTITY_FILE as REPORT_IDENTITY_FILE, renderReportOverview } from "./report-overview-record.mjs";
39
39
  import { writePartNotCompleted, readPartNotCompleted, clearPartNotCompleted, clearPartsNotCompleted, NOT_COMPLETED_DIR, slicesNotYetRecorded, axesNotYetDisclosed, unitsNotYetRecorded } from "./parts-not-completed.mjs";
40
40
  import { renderUnitNote, readBandAccount } from "./register-unit-record.mjs"; // a unit that could not finish, its note rendered from its band
@@ -153,7 +153,7 @@ import { escalatedAxes } from "./skeptic-record.mjs"; // THE escalation parse
153
153
  // — every placed candidate ends somewhere a reader can see; the ones that do not are counted by name
154
154
  import { PLACEMENT_CARRY_SCHEMA_VERSION, reconcilePlacementCarry, placementCarryEvent, mintPlacementCarryDoubts, entryUris, parseCarrySurfaces, classifyPlacement } from "./placement-carry.mjs";
155
155
  import { FLOOR_DUTY_SCHEMA_VERSION, reconcileFloorDuty, floorDutyEvent, armFloorDuty, floorDutyArmed, floorDutyBlock, floorDutyClosed, floorDutyBlocksSkip } from "./floor-duty.mjs"; // — the era stamp that turns disclosure into a delivery floor
156
- import { synthesisDutyForRun, snapshotSynthesisBase, restoreSynthesisBase, rebaseSynthesis, lastAcceptedCall, acceptSynthesis, driverReadsFor } from "./synthesis-record.mjs"; // — the duty checked against the DELIVERED document; the merge base a corrective pass holds and puts back
156
+ import { synthesisDutyForRun, snapshotSynthesisBase, restoreSynthesisBase, rebaseSynthesis, lastAcceptedCall, acceptSynthesis, driverReadsFor, handSavedFindings } from "./synthesis-record.mjs"; // — the duty checked against the DELIVERED document; the merge base a corrective pass holds and puts back
157
157
  import { RECORD_CARRY_SCHEMA_VERSION, traceRecordCarry, parseStageOutcomes, recordCarryEvent, mintRecordCarryDoubts, recordCarryDoubtsAgain, bandRecordUri, placementIndex, findingUris , silentlyLostFindings, statedDivergenceFindings } from "./record-carry.mjs"; import { pickingExits, notesExits, notesPageRows, exitsForLog } from "./hand-off-exits.mjs";
158
158
  import { reconcileSurfaceDuty, surfaceDutyNote } from "./surface-duty.mjs"; // item 3 — silence at the findings surface, read off the rows above
159
159
  import { DISCARD_LEDGER_NAME, seamRows, appendDiscardRows, foldDiscardLedger } from "./record-discard.mjs";
@@ -1679,6 +1679,8 @@ const AXIS_VOCABULARY = {
1679
1679
  // a preserved run's canonical artefact came from); the rest are the re-dispatch triggers runDigest
1680
1680
  // labels, and they carry the placement rulings tail a fresh pass does not.
1681
1681
  export const DISPATCH_TRIGGERS = ["fresh", "escalation", "envelope", "late-bind", "stale-repair", "settlement-flush", "corrective"]; // @internal — — the corrective pass is the seam the losses happen in; see driver/corrective-arm.mjs for warm vs cold
1682
+ // How an --experiment arm's research is answered: sent out as the run sent it, or replayed from the run's own answers.
1683
+ export const RESEARCH_MODES = ["live", "replay"]; // @internal
1682
1684
 
1683
1685
  /**
1684
1686
  * The sha of a sandbox file AS IT WOULD READ on the canonical run — the sandbox's own directory
@@ -5007,10 +5009,15 @@ export function messageCtxFor(name, ctx) { // @internal — driven by its test
5007
5009
  if (name === "narrative-refutation") return { ...ctx, placementFloorDecisions: reviewFloorDecisionLines(ctx.paths) };
5008
5010
  // The reading step and the writer are told placement's accepted set-asides the same way, read when the message is built.
5009
5011
  if (name === "register-digest") return { ...ctx, placementSetAsides: placementSetAsideLines(ctx.paths) };
5010
- // The writer is also handed the research its earlier attempts got (research-answers.mjs), written to the run folder when
5011
- // the message is built: none on a first attempt, so its message is unchanged.
5012
+ // The writer is also handed the research this run's earlier stages and attempts got (research-answers.mjs), written to
5013
+ // the run folder when the message is built: none on a run that has asked nothing yet, whose message is unchanged.
5014
+ // A sandbox arm reproducing a FIRST attempt hands on nothing: its copy holds the run's own answers under this stage's
5015
+ // key, which a first attempt in a run never has (`researchHandOff`, set by the experiment).
5012
5016
  if (name === "synthesis") return { ...ctx, placementSetAsides: placementSetAsideLines(ctx.paths),
5013
- researchAnswered: ctx.run ? handResearchAnswers(ctx.paths.runDir, stageSessionKey(ctx, name)) : 0 };
5017
+ researchAnswered: ctx.run && ctx.researchHandOff !== false ? handResearchAnswers(ctx.paths.runDir, stageSessionKey(ctx, name), { ofRun: true }) : 0,
5018
+ // The findings an earlier attempt saved, handed on as a file (synthesis-record.mjs): none on a first attempt, and none
5019
+ // in a sandbox arm, whose copy holds no saved findings.
5020
+ savedSynthesis: handSavedFindings(ctx.paths.runDir) };
5014
5021
  return ctx;
5015
5022
  }
5016
5023
 
@@ -18438,6 +18445,12 @@ async function runExperimentInner(job, opts) {
18438
18445
  throw new Error(`--experiment ${name}: --axis "${axis}" is not ${vocab.what} (${vocab.oneOf})`);
18439
18446
  // A typo here changes WHICH driver-computed blocks the arm carries and nothing would say so — the
18440
18447
  // same silent-degradation shape the RETIRED_FLAGS table at the bottom of this file exists for.
18448
+ // `--research replay` answers the arm's research from the run's own answers and sends nothing out
18449
+ // (research-answers.mjs). Only a stage that holds the research tool has research to replay.
18450
+ if (opts.research != null && !RESEARCH_MODES.includes(opts.research))
18451
+ throw new Error(`--research: unknown value "${opts.research}" — one of: ${RESEARCH_MODES.join(", ")}`);
18452
+ if (opts.research === "replay" && !experimentToolGroups(name).includes("perplexity"))
18453
+ throw new Error(`--experiment ${name} --research replay: ${name} holds no research tool, so there is no research to replay`);
18441
18454
  if (opts.dispatchTrigger != null && !DISPATCH_TRIGGERS.includes(opts.dispatchTrigger))
18442
18455
  throw new Error(`--dispatch-trigger: unknown value "${opts.dispatchTrigger}" — it decides which production pass the arm reproduces, so a typo would compose a quietly different prompt. One of: ${DISPATCH_TRIGGERS.join(", ")}`);
18443
18456
  const ctx = reconstructCtx(job, opts);
@@ -18451,7 +18464,10 @@ async function runExperimentInner(job, opts) {
18451
18464
  // Any ctx field holding a PATH is dropped rather than inherited: reconstructCtx resolved them against
18452
18465
  // the canonical run, and a sandbox whose prompt names a canonical path is not a sandbox. The
18453
18466
  // derivations below re-set them against ctx.paths, which here is the shadow dir.
18454
- const shadowCtx = { ...ctx, paths: paths(shadowDir), axis, gridSpecPath: null, clHalfTerms: null };
18467
+ // A `fresh` arm is a stage's first attempt, which is handed no earlier attempt's research: the copied answers are
18468
+ // what the replay answers from, never a hand-off production's first attempt does not get.
18469
+ const shadowCtx = { ...ctx, paths: paths(shadowDir), axis, gridSpecPath: null, clHalfTerms: null,
18470
+ researchHandOff: (opts.dispatchTrigger ?? "fresh") !== "fresh" };
18455
18471
  const inputOpts = { axes: ctx.axes, axis, registerOnly: ctx.registerOnly };
18456
18472
  const manifest = sandboxManifest(name, ctx.paths, inputOpts);
18457
18473
  // ── 1. GATHER — the sandbox manifest, not the freshness list ──────────────────────────────────────
@@ -18502,6 +18518,15 @@ async function runExperimentInner(job, opts) {
18502
18518
  throw new Error(`--experiment ${name}: the sandbox could not reproduce ${gaps.length} thing(s) this stage's context has on the canonical run — refusing to dispatch against an incomplete context:\n` +
18503
18519
  gaps.map((g) => ` · ${g.rel} [${g.kind}${g.via ? ` via ${g.via}` : ""}]${g.why ? ` — ${g.why}` : ""}`).join("\n"));
18504
18520
  }
18521
+ // A REPLAY NEEDS ANSWERS TO REPLAY. Refused by name when the run kept none, rather than dispatched to refuse
18522
+ // every question it asks; the marker is the research server's whole switch, and only a sandbox writes it.
18523
+ let replayedAnswers = null;
18524
+ if (opts.research === "replay") {
18525
+ replayedAnswers = savedResearchCount(shadowDir);
18526
+ if (!replayedAnswers)
18527
+ throw new Error(`--experiment ${name} --research replay: this run kept no research answers (_driver/research-answers.jsonl is absent or holds none), so there is nothing to replay — run the arm with --research live`);
18528
+ atomicWrite(driverDir(shadowDir, RESEARCH_REPLAY_FILE), JSON.stringify({ replayOf: "the run's saved research answers", answers: replayedAnswers }, null, 2) + "\n");
18529
+ }
18505
18530
  // ── 4. INLINE context — what no file copy can supply ──────────────────────────────────────────────
18506
18531
  //
18507
18532
  // `report-card` receives its finding as ctx.finding; its declared findings.json exists for staleness,
@@ -18588,6 +18613,10 @@ async function runExperimentInner(job, opts) {
18588
18613
  // omitted: absent and "there was none" are different facts and only one of them is a defect.
18589
18614
  ratedUnder: ctx.profile?.profileKey ?? null,
18590
18615
  dispatchTrigger: opts.dispatchTrigger ?? "fresh",
18616
+ // How the arm's research was answered: sent out live, or replayed from the run's own answers.
18617
+ research: opts.research === "replay" ? { mode: "replay", answers: replayedAnswers } : { mode: "live" },
18618
+ // Whether the arm's message hands on an earlier attempt's research, as a retry's does: never on a `fresh` arm.
18619
+ researchHandOff: shadowCtx.researchHandOff,
18591
18620
  // — WHICH PASS THIS ARM REPRODUCED, in a word. null on a non-corrective arm;
18592
18621
  // never "dispatched-warm", which is production's alone.
18593
18622
  correctivePass: whichPass,
@@ -18693,7 +18722,7 @@ function parseArgv(argv) {
18693
18722
  const o = {};
18694
18723
  const flags = { "--job": "job", "--agent": "agent", "--resume": "codename", "--from": "fromStage",
18695
18724
  "--experiment": "experiment", "--model": "model", "--instructions": "instructions",
18696
- "--label": "label", "--axis": "axis", "--dispatch-trigger": "dispatchTrigger" };
18725
+ "--label": "label", "--axis": "axis", "--dispatch-trigger": "dispatchTrigger", "--research": "research" };
18697
18726
  for (let i = 0; i < argv.length; i++) {
18698
18727
  const key = flags[argv[i]];
18699
18728
  if (!key) {
@@ -18861,7 +18890,7 @@ export function resumeAdvice({ result = null, error = null, signal = null, scrip
18861
18890
 
18862
18891
  const USAGE = "usage: node pipeline.mjs --job <file.json> [--agent <id>]\n" +
18863
18892
  " [--resume <codename> [--from <stage>]]\n" +
18864
- " [--resume <codename> --experiment <stage> [--model <id>] [--instructions <text>] [--axis <axis>] [--label <text>] [--dispatch-trigger <t>]]\n" +
18893
+ " [--resume <codename> --experiment <stage> [--model <id>] [--instructions <text>] [--axis <axis>] [--label <text>] [--dispatch-trigger <t>] [--research live|replay]]\n" +
18865
18894
  " --instructions appends to a stage's prompt (a light tweak); a full prompt rewrite means editing the stage's skill file.\n" +
18866
18895
  " --axis also names the ORDINAL for report-card (its dispatch label is report-card:<ordinal>).\n" +
18867
18896
  " --dispatch-trigger picks WHICH production pass the arm reproduces (default \"fresh\"); a corrective pass carries prompt blocks a fresh one does not.";
@@ -18976,7 +19005,7 @@ if (isEntrypoint(import.meta.url)) void (async () => {
18976
19005
  // hangs `__runRef` on the caller's own opts the moment the run context is built, which is how pipeline()'s
18977
19006
  // rate-limit catch already finds the run to postpone; the same hook lets a THROWN exit name the run.
18978
19007
  const opts = a.experiment
18979
- ? { ...base, codename: a.codename, experiment: a.experiment, model: a.model, instructions: a.instructions, axis: a.axis, label: a.label, dispatchTrigger: a.dispatchTrigger }
19008
+ ? { ...base, codename: a.codename, experiment: a.experiment, model: a.model, instructions: a.instructions, axis: a.axis, label: a.label, dispatchTrigger: a.dispatchTrigger, research: a.research }
18980
19009
  : { ...base, codename: a.codename, fromStage: a.fromStage, ...(a.job ? {} : { jobRebuiltFromStatus: true }) };
18981
19010
  const script = resolve(fileURLToPath(import.meta.url));
18982
19011
  // `jobPath` is null on a rebuilt resume, so the advice composes a command that does not name a file
@@ -15,7 +15,7 @@ import { dirname } from "node:path";
15
15
  import { createHash } from "node:crypto";
16
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; } };
@@ -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) => {
@@ -132,7 +143,7 @@ export function standingPlacementDecisions(P) { // @internal — driven by its
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 { return []; }
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
- for (const d of standingPlacementDecisions(P)) {
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
  }
@@ -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 = [...report.matchAll(/^##\s+([^\n]+)$/gm)].some(([, h]) => {
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
  });
@@ -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 a disagreement resolution naming one, is its ending instead. An entry whose record the band does not
150
- * hold gets none. PURE.
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 named = accountedUris(model, adopt.flatMap((e) => e.uris));
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: an earlier attempt of this stage ${asked}. They are in ${RESEARCH_ANSWERS_MD}, `
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 asked about that owner and mark for the same field and country, use its answer as "
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
+ }
@@ -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
+ }
@@ -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
- Before synthesis, 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.
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
@@ -177,6 +177,7 @@ export const TOOL_GROUP_EDGES = {
177
177
  // the policy asks on the question's own depth instead, so the arm would measure a different tier.
178
178
  perplexity: (P) => [
179
179
  { path: driverDir(P.runDir, "search-policy.json"), why: "perplexity_research sends every question on the tier the run's frozen policy pins (web.questionPreset); without it a question goes out on its own depth" },
180
+ { path: driverDir(P.runDir, "research-answers.jsonl"), why: "under --research replay, perplexity_research answers a question the run already asked from the answer the run kept here (research-answers.mjs); without it a replay has nothing to answer from" },
180
181
  ],
181
182
  declination: (P) => [
182
183
  { path: driverDir(P.runDir, "declination-spec.json"), why: "record_declination resolves every row_index against this spec; without it the tool refuses the call and the seat cannot decline at all" },
@@ -191,6 +192,21 @@ export const TOOL_GROUP_EDGES = {
191
192
  };
192
193
 
193
194
  // ── the edges a declaration cannot carry ─────────────────────────────────────────────────────────────
195
+ // PLACEMENT'S SET-ASIDES, as the reading step and the writer are told them when their message is built
196
+ // (placement-decisions.mjs, `standingPlacementDecisions`): the form's driver copy, judged against the shape, the
197
+ // positions, the scope, what the band tools served and the goods evidence, under the era stamp that says a form is
198
+ // required. An arm without them was told of no set-aside at all.
199
+ const placementSetAsideReads = (P) => [
200
+ { path: driverDir(P.runDir, "placement-form.form.json"), kind: "driver-side", why: "standingPlacementDecisions reads placement's set-asides from the form's driver copy" },
201
+ { path: driverDir(P.runDir, "stage-contracts.json"), kind: "driver-side", why: "the era stamp that says this run's placement records a form; without it a missing form reads as none set aside" },
202
+ { path: P.placementModel, kind: "driver-side", why: "the placed records a set-aside is checked against (placedUriRows)" },
203
+ { path: P.bandShape, kind: "driver-side", why: "readPlacementFormInput: the floors and groups a set-aside is judged against" },
204
+ { path: P.registerPositions, kind: "driver-side", why: "readPlacementFormInput: the positions a named set-aside resolves to" },
205
+ { path: driverDir(P.runDir, "instructed-scope.json"), kind: "driver-side", why: "readPlacementFormInput: the applied-for marks a ground is refused on" },
206
+ { path: driverDir(P.runDir, "reading-log.jsonl"), kind: "driver-side", why: "readServed: the lines a group row may cover are the lines placement was shown" },
207
+ { path: driverDir(P.runDir, "goods-evidence.json"), kind: "driver-side", why: "readGoodsStates: a ground on a record's goods is checked against what its goods rest on" },
208
+ ];
209
+
194
210
  const UNDECLARED = {
195
211
  // The verbatim forward is named in the prompt only when job.rawRequest exists — and matter-frame,
196
212
  // unlike blind-frame, never declared it at all (stages.mjs "matter-frame": []).
@@ -224,6 +240,7 @@ const UNDECLARED = {
224
240
  // and one that built it and lost it in the copy refuses by name. Listing it costs nothing on the first and
225
241
  // is the whole point on the second.
226
242
  "register-digest": (P) => [
243
+ ...placementSetAsideReads(P),
227
244
  { path: P.registerHitList,
228
245
  why: "the digest's own message names this file and asks the model to mark lines on it (hit-list.mjs reads the marks back); without it in the copy a marking lands nowhere and the stage passes having been given a different input than the real one" },
229
246
  ],
@@ -231,6 +248,7 @@ const UNDECLARED = {
231
248
  // non-zero count (stages.mjs synthesis message). Undeclared on purpose — it is aim-attention only and
232
249
  // must never move a freshness stamp — but a sandbox without it hands a different prompt.
233
250
  synthesis: (P) => [
251
+ ...placementSetAsideReads(P),
234
252
  { path: P.enforcerSignals, kind: "conditional",
235
253
  why: "named in the prompt only when ctx.enforcerSignals > 0; aim-attention telemetry, deliberately outside the freshness map" },
236
254
  // The job's own scope. The prompt never names it, so nothing declared it, and the sandbox rebuilt the
package/driver/stages.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { join, basename } from "node:path";
18
18
  import { driverRel } from "../shared/driver-dir.mjs"; //
19
- import { researchAnsweredBlock } from "./research-answers.mjs"; // the research an earlier attempt got, handed to a later one
19
+ import { researchAnsweredBlock } from "./research-answers.mjs"; import { savedFindingsBlock } from "./saved-findings-block.mjs"; // the research an earlier attempt got, and the findings it saved, handed to a later one
20
20
  import { awaitsReadingTurn } from "../providers/_shared/plan-guards.mjs"; // — a guard the dictation misreads is a question the model never knows it may ask
21
21
  import { isNonLatinTerm } from "../providers/_shared/script-form.mjs";
22
22
  import { latinQuestionsOfRomanisedForm } from "./register-plan.mjs"; // sentence 3 — the Latin question a script question repeats
@@ -3099,7 +3099,7 @@ export const STAGES = {
3099
3099
  why: "THE SECOND frame_scope_missing. The driver holds the value in _driver/framework.json, writes it into the dispatch, asks the model to retype it, and fails the file when the retyping does not match its own copy. The audit table groups it under the tool-written envelope row; pre-bound is the sharper discharge and either removes it from the model",
3100
3100
  },
3101
3101
  },
3102
- message: ({ paths: P, job, customerUnknown, profile, intakeAsks, enforcerSignals, framework, frameworkMethod, jxAim, registerOnly, crowdContext, dispatchBlocks, findingsSurface, depth, placementSetAsides, researchAnswered }) => lines(
3102
+ message: ({ paths: P, job, customerUnknown, profile, intakeAsks, enforcerSignals, framework, frameworkMethod, jxAim, registerOnly, crowdContext, dispatchBlocks, findingsSurface, depth, placementSetAsides, researchAnswered, savedSynthesis }) => lines(
3103
3103
  // C4 — pharma matters (class 5 / pharma-shaped goods) FORCE-READ the field module: the
3104
3104
  // therapeutic-goods discipline, no-use ≠ safety (pipelines run 5-10y), practitioner/pharmacist
3105
3105
  // confusion. Conditional read (frameworkFor pattern); doctrine TABLES live elsewhere, untouched.
@@ -3122,7 +3122,7 @@ export const STAGES = {
3122
3122
  // WS2 (spec 11) — ENFORCED, not advisory: validators.narrative HARD-REJECTS this file if a
3123
3123
  // use-dependent finding above the framework's lowest band asserts a use-negative without a searched
3124
3124
  // source line. So actually run the query.
3125
- `USE-CHECK (enforced): run the scoped use check (phase2-execution.md Step 3.5) on EVERY finding banded ${(framework && materialBandPhrase(framework)) || "medium risk or higher"} — the rungs this framework rates medium risk or higher; a lower rung is not checked — and ask in the SAME query for the owner's enforcement record (oppositions, disputes or court actions over this mark or a similar one). After the search, use is "confirmed" or "not-confirmed", never "unknown". What the query finds about enforcement is a LEAD under ENFORCEMENT AUTHORITY below: enforcer basis "inferred-from-signal", its URL cited inline in that finding's prose; where it finds none, the enforcer is "low" with basis "inferred-from-signal" and the finding says the search found no enforcement record. ALSO, for EVERY finding whose mitigant/verdict turns on the ABSENCE of use (regardless of its band) ("not in actual use", "unused", "no marketplace use found", non-use revocation/cancellation as the mitigant, "owner's use unknown"), run ONE scoped \`perplexity_research\` query (owner + mark + the goods/field) and END that finding's actual-use line with a literal "- **Use-check source:** <result URL | "perplexity_research — no result">" line. Mirror the result into the matching # Actions item (the deferred "confirm the owner's use" becomes a stated result). NEVER assert a use-negative you did not search — an inference from the owner's profile is not a searched result. On a perplexity outage write "- **Use-check source:** perplexity_research — no result" and proceed (the run still delivers). ALSO populate that finding's use_check field in findings.json: {"source": "<the result URL | perplexity_research — no result>"} — the structured field is the authoritative cite the report renders from (the prose line is for narrative readability).`,
3125
+ `USE-CHECK (enforced): run the scoped use check (phase2-execution.md Step 3.5) on EVERY finding banded ${(framework && materialBandPhrase(framework)) || "medium risk or higher"} — the rungs this framework rates medium risk or higher; a lower rung is not checked — and ask in the SAME query for the owner's enforcement record (oppositions, disputes or court actions over this mark or a similar one). ${USE_CHECK_ANSWERS_HELD} After the search, use is "confirmed" or "not-confirmed", never "unknown". What the query finds about enforcement is a LEAD under ENFORCEMENT AUTHORITY below: enforcer basis "inferred-from-signal", its URL cited inline in that finding's prose; where it finds none, the enforcer is "low" with basis "inferred-from-signal" and the finding says the search found no enforcement record. ALSO, for EVERY finding whose mitigant/verdict turns on the ABSENCE of use (regardless of its band) ("not in actual use", "unused", "no marketplace use found", non-use revocation/cancellation as the mitigant, "owner's use unknown"), run ONE scoped \`perplexity_research\` query (owner + mark + the goods/field) unless an answer this run already holds answers that question, and END that finding's actual-use line with a literal "- **Use-check source:** <result URL | "perplexity_research — no result">" line. Mirror the result into the matching # Actions item (the deferred "confirm the owner's use" becomes a stated result). NEVER assert a use-negative you did not search — an inference from the owner's profile is not a searched result. On a perplexity outage write "- **Use-check source:** perplexity_research — no result" and proceed (the run still delivers). ALSO populate that finding's use_check field in findings.json: {"source": "<the result URL | perplexity_research — no result>"} — the structured field is the authoritative cite the report renders from (the prose line is for narrative readability).`,
3126
3126
  // Spec-v3 A4 — ENFORCED the same way (validators.narrative): own-rights reliance needs its evidence.
3127
3127
  // PR-8 review fix: synthesis holds the FROZEN band, not the live register — and the band is class-
3128
3128
  // and mark-text-scoped to this matter's dispatched slices, so the client's OWN house-mark
@@ -3150,7 +3150,7 @@ export const STAGES = {
3150
3150
  // (house-brand attachment / seasonality / scale). Context, never a rule that decides a finding.
3151
3151
  job?.campaignShape ? `Stated campaign shape (verbatim from intake — how the mark will be deployed; facts, not judgment): ${job.campaignShape}` : "",
3152
3152
  `Inputs: register findings ${P.registerFindings}${registerOnly ? "" : `; common-law ${P.commonLaw}`}; placements ${P.placement} (structured tiers + reasons: ${P.placementModel}); matter frame ${P.matterContext}; manifest ${P.variantManifest}.`,
3153
- placementSetAsideBlock(placementSetAsides),
3153
+ placementSetAsideBlock(placementSetAsides), WRITER_STARTS_FROM_THE_WORK, WRITER_RECORDS_AS_IT_GOES,
3154
3154
  // The skeptic's shipped sentence, word for word: a granted tool is named, or the seat cannot know it holds it.
3155
3155
  `Re-read any of this run's own artifacts with the \`search_run_artifacts\` tool — ONE file per call, named relative to the run directory (e.g. "register-findings.md"), with \`terms\` as literal substrings OR-matched per line.`,
3156
3156
  PLACEMENT_BORDERLINE_NOTE,
@@ -3208,8 +3208,8 @@ export const STAGES = {
3208
3208
  // judgment-relocation (2026-06-23): you are Layer B — the ONE judgment layer. The funnel ENUMERATED the
3209
3209
  // dangerous named band or reported HONEST INCOMPLETENESS; you read the complete material and decide
3210
3210
  // relevance, risk, sufficiency and prioritisation. PR-8: the reading goes through the band tools
3211
- // (shape whole + logged lookups) — nothing was sampled away upstream, and nothing is sliced by hand.
3212
- BAND_READING_CONTRACT_AFTER_PLACEMENT,
3211
+ // (logged lookups, as an assessment needs them) — nothing was sampled away upstream, nothing sliced by hand.
3212
+ BAND_READING_CONTRACT_FOR_THE_WRITER,
3213
3213
  // PR-8: synthesis holds NO live register tools — by design, never an outage. The funnel owns
3214
3214
  // enumeration; a register check this stage still wants is either answerable from the frozen
3215
3215
  // material (the band tools) or it is NEW SEARCH WORK, which enters only through the supplemental
@@ -3438,13 +3438,13 @@ export const STAGES = {
3438
3438
  // MEANS — the net contract, the band contract, the meters, the disposition logic, the voice
3439
3439
  // rules. What goes is the seat formatting them into a file for the driver to read back.
3440
3440
  `RECORD YOUR WORK WITH \`record_synthesis\`. THE DISPATCH NAMES NO FILE FOR YOU TO WRITE, deliberately — the driver renders both the narrative and the findings record from what you send. You hold no Write or Edit tool for either and nothing you write by hand is read.`,
3441
- `WHAT THE CALL TAKES. \`narrative\`: your CROSS-FINDING read as sections — \`spine\` (the dominant-element spine), \`verdict\` (what the findings together mean for this client), \`coverage.read\` (the honest coverage prose) and \`calibration\` ([{challenge, answer}]). \`findings\`: the findings document exactly as specified above — schema_version, rated_under_framework, findings[], coverage[], ask_answers[] and the top-level registers. It is the same values it always was; it arrives on the call instead of as a file you write.`,
3441
+ `WHAT THE CALL TAKES. \`narrative\`: your CROSS-FINDING read as sections — \`spine\` (the dominant-element spine), \`verdict\` (what the findings together mean for this client), \`coverage.read\` (the honest coverage prose) and \`calibration\` ([{challenge, answer}]). \`findings\`: the findings document exactly as specified above — schema_version, rated_under_framework, findings[], coverage[], ask_answers[] and the top-level registers. It is the same values it always was; it arrives on the call instead of as a file you write. \`save_findings\`: a finding you have completed, alone or a few at a time, saved before the whole record exists; each is checked as the record checks a finding and kept as soon as it passes, and the call carrying \`narrative\` with \`findings\` takes every saved finding for a position it does not carry.`,
3442
3442
  `THE PER-FINDING WRITE-UPS ARE NOT A FIELD, and that is a ruling rather than an omission: the report card holds the single authored wording for a finding and the typed fields carry the reads it is written from. Do not restate a finding's reasoning in the narrative sections — the spine is what the findings mean TOGETHER, which is the read no other artifact on this run holds.`,
3443
3443
  `COVERAGE ROWS AND ASK ANSWERS ARE SENT ONCE, ON THE RECORD, AND THE DRIVER RENDERS THEM INTO THE NARRATIVE TOO. Do not repeat them as narrative sections — a second authored copy is refused, because two copies of one statement can disagree and it is the record's copy that ships to the report, the workbook and the portal.`,
3444
3444
  `YOUR COVERAGE ROWS ARE JOINED TO THIS RUN'S PLAN-EXECUTION RECEIPT AS YOU SEND THEM. \`confirmed-clean\` asserts a search ran and came back empty, so a confirmed-clean row naming a slice the receipt does not record as executed is REFUSED — in this turn, where restating it costs nothing, rather than at a gate whose only repair is re-asking this whole stage. Name the area as the receipt names the slice. The honest alternatives are \`coverage-limited\` and \`not-searched\`, and neither is a worse answer than a clean claim that is not true.`,
3445
3445
  // THE RESEARCH AN EARLIER ATTEMPT GOT, handed on as a file (research-answers.mjs). Last, and absent on a first
3446
3446
  // attempt, whose message is unchanged.
3447
- researchAnsweredBlock(researchAnswered),
3447
+ researchAnsweredBlock(researchAnswered), savedFindingsBlock(savedSynthesis),
3448
3448
  ),
3449
3449
  // A FRESH RETRY IS REBUILT, so it is handed the research its earlier attempts got. Nothing else in this stage's
3450
3450
  // context changes between attempts: the findings surface and its positions are frozen for the stage, and placement's
@@ -4831,3 +4831,31 @@ export const REFUTATION_RECORDING_LINES = Object.freeze([
4831
4831
  `TYPE EACH CORRECTION: \`kind\` is ONE of coverage-disposition | fact | rating | narrative — pick the one your own legal read says the correction IS: coverage-disposition (a coverage row / disposition placement is wrong or dishonest), fact (a factual defect — record values, owners, statuses, dates, grounding), rating (a band/rating you challenge), narrative (prose, structure or voice of the narrative). DECLARE the kind from what the correction is about, never from how severe it is.`,
4832
4832
  `A BLOCKING verdict REQUIRES at least one flag. A refusal to sign that names nothing is refused by the call itself, in this turn, rather than at the gate — where its only repair is one forced re-ask of this whole stage.`,
4833
4833
  ]);
4834
+
4835
+ // ── THE WRITER WRITES FROM THE WORK IT IS HANDED (ruled 2026-10-27) ─────────────────────────────────────────────────
4836
+ //
4837
+ // On a test run of a crowded matter the writer's second try made 1,095 record reads of 707 distinct records and 117
4838
+ // web research calls, and recorded nothing before its time ran out. It was re-reading the matter from the start: the
4839
+ // band's shape, then each position's records one by one, then the owners. The steps before it had already settled
4840
+ // relevance and kept the evidence. So the writer starts from that work: it inherits supported decisions, completes the
4841
+ // assessment that is its own, and reads or searches further only where the evidence it holds does not answer a
4842
+ // question. Its duties do not change: the ratings, the cross-finding read and the required checks stay with it.
4843
+ export const WRITER_STARTS_FROM_THE_WORK = lines(
4844
+ "Start from the retained entries, the recorded decisions, and the evidence this run already holds. Use completed work in your judgment and write what the findings mean together for this client. Complete the legal and practical assessment that remains yours under the framework.",
4845
+ "Reopen a settled decision only for identified contrary evidence or an identified error in its reasoning. Complete an outstanding required question from the evidence already held; read or search further only where that evidence does not answer it. Do not repeat the investigation because a new stage or attempt has started.",
4846
+ "Assess each retained position on its relevant evidence. A list of its constituent records does not require you to open every record. Open an additional record when a difference in its goods, status, priority, territory, identity, or other material evidence could change the assessment and the supplied evidence does not resolve it. Missing or incomplete evidence does not establish an exclusion or a clean result.",
4847
+ );
4848
+ // The band as the writer is told of it: what the tools are for, never a read it owes. The contract the steps before it
4849
+ // read begins "call `band_shape` FIRST" and says each part of a large shape names the next call. The standards that
4850
+ // contract sets for any reading are kept word for word, cut from it so the two cannot drift apart. The name keeps
4851
+ // "BAND_READING_CONTRACT" in it: the stages that read the band are found by it (BAND_READING_STAGES, band-size.mjs).
4852
+ export const BAND_READING_STANDARDS = BAND_READING_CONTRACT.slice(BAND_READING_CONTRACT.indexOf("A crowd descriptor is an open slice"));
4853
+ export const BAND_READING_CONTRACT_FOR_THE_WRITER = "THE REGISTER BAND: Use the band tools (`band_shape`, `band_lookup`, `band_record`) when a specific assessment needs evidence beyond the material handed to you. The available groups and pages are ways to find that evidence, not a list you must read to completion. "
4854
+ + BAND_READING_STANDARDS;
4855
+ // An answer the run already holds can be a finding's check (ruled 2026-10-27): a use-check source needs evidence, not
4856
+ // a fresh call. The triggers are unchanged. Step 3.5 of phase2-execution.md carries the same words.
4857
+ export const USE_CHECK_ANSWERS_HELD = "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.";
4858
+ // Saving as it goes (ruled 2026-10-27): a try that is stopped keeps the findings and exclusions it accepted, and the
4859
+ // next try continues from them. Exclusions already go through record_declination in batches; record_synthesis takes
4860
+ // a part of the findings before the whole document exists.
4861
+ export const WRITER_RECORDS_AS_IT_GOES = "Record completed findings (`record_synthesis`) and exclusions (`record_declination`) as you complete them, individually or in small groups; do not wait for the whole report. On a resumed attempt, continue from this run's accepted work and complete what remains. Revise accepted work only for identified contrary evidence, a reasoning error, or an applicable change in the instructions. Finish the overall conclusions against the complete set before declaring the assessment complete.";