clearotron 0.3.0 → 0.3.1-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.
@@ -1142,10 +1142,10 @@ export async function knockoutInner(ctx, job, opts = {}) {
1142
1142
  writeRunStatus(ctx, { state: "failed", failedStage, reason: shortReason, reasonTruncated, reasonFull, terminalKind });
1143
1143
  recordRunConsumption(ctx, { phase: "failed", tokens: stampTokenRollup(run.runDir, "failed") });
1144
1144
  rollupStatus(run.studioRoot);
1145
- // The two failure-notice lanes are MUTUALLY EXCLUSIVE, mirroring pipelineInner's failPingSent gate:
1146
- // the outbox run-failed event is the primary; sendPending + the wake marker are the BACKSTOP only
1147
- // when the event write failed (sendPending has no failure-side clear in the ack_event loop — arming
1148
- // both double-notifies and leaves a permanent SEND PENDING; review 2026-07-17).
1145
+ // OWED EITHER WAY, mirroring pipelineInner: the flag is armed whichever lane wrote the packet,
1146
+ // because mark_sent is the only clear and it settles a failure.json on the same evidence a delivery
1147
+ // needs. The marker is NOT duplicated — the primary lane's own packet lands `<runId>.failed.pending`
1148
+ // and the *.pending watch matches it; a second `<runId>.pending` would outlive every settle.
1149
1149
  let failPingSent = false;
1150
1150
  try {
1151
1151
  const rich = buildFailurePacket({
@@ -1160,14 +1160,14 @@ export async function knockoutInner(ctx, job, opts = {}) {
1160
1160
  writeFileSync(driverDir(run.runDir, "failure.json"), JSON.stringify(packet, null, 2) + "\n");
1161
1161
  failPingSent = Boolean(writeOutboxPacket(`${runId}.failed`, packet));
1162
1162
  if (!failPingSent) {
1163
- // a fresh notice supersedes an older send's skip-guards (a resumed-then-failed-again run must
1164
- // still notify — the .sent/receipts invariant, clearance parity)
1165
- try { rmSync(join(run.runDir, ".sent")); } catch { /* none */ }
1166
- try { rmSync(driverDir(run.runDir, "send-receipts.json"), { force: true }); } catch { /* none */ }
1167
- writeRunStatus(ctx, { sendPending: true });
1168
1163
  mkdirSync(config.outboxDir, { recursive: true });
1169
1164
  writeFileSync(join(config.outboxDir, `${runId}.pending`), `${agent}\n`);
1170
1165
  }
1166
+ // a fresh notice supersedes an older send's skip-guards (a resumed-then-failed-again run must
1167
+ // still notify — the .sent/receipts invariant, clearance parity)
1168
+ try { rmSync(join(run.runDir, ".sent")); } catch { /* none */ }
1169
+ try { rmSync(driverDir(run.runDir, "send-receipts.json"), { force: true }); } catch { /* none */ }
1170
+ writeRunStatus(ctx, { sendPending: true });
1171
1171
  } catch (nfErr) { note(`knockout failure-notice write skipped (${String(nfErr?.message ?? nfErr).slice(0, 100)})`); }
1172
1172
  note(`=== KNOCKOUT ${terminalKind === REFUSAL_TERMINAL_KIND ? "REFUSED" : "FAILED"} ${run.codename} at ${failedStage}: ${shortReason} ===\n`);
1173
1173
  // `codename`: this lane's terminals reach the SAME CLI exit as the clearance lane's, and the
@@ -31,14 +31,14 @@ import { buildRunContext, deriveSlug, kebab } from "./phase0.mjs";
31
31
  import { paths, STAGES, axisTier, decideAxes, assertTierSanity, assertEffectiveTier, lines, AGENT_WHATSAPP, whatsappRouting,
32
32
  chainEntries, stageOrdinal, stageInputs, stageOutputs, dependencyOrder, REGISTER_AXES, REGISTER_ENUMERATE_TOOL,
33
33
  buildEscalationFollowup, buildEnvelopeCloseFollowup, buildFrameReopenFollowup,
34
- buildFrameReopenRetryMessage, thinkingFor, composeFollowup, stampDispatchBlocks, recordEmptyReturn, nothingFound, nothingToRead, PROVIDER_META, proseRungDirective, inquiryRungDirective } from "./stages.mjs";
34
+ buildFrameReopenRetryMessage, thinkingFor, composeFollowup, stampDispatchBlocks, recordEmptyReturn, nothingFound, nothingToRead, PROVIDER_META, proseRungDirective, inquiryRungDirective } from "./stages.mjs"; import { bandSizeForStage } from "./band-size.mjs";
35
35
  import { IDENTITY_FILE as REPORT_IDENTITY_FILE } from "./report-overview-record.mjs";
36
36
  import { dispatchRows, clearedSignatures } from "./seat-attempts.mjs";
37
37
  import { CONTEXT_DERIVATIONS, DISPATCH_EXTRAS, INLINE_CONTEXT, sandboxManifest, sandboxGaps, derivationsFor } from "./stage-context.mjs"; // — what a stage is actually handed
38
38
  import { parseVerdict, countCitedDefects, parseCorrectionKinds, parseCorrections, validators, findReviewerCoherenceFlags, verdictHardenedTo } from "./verify.mjs";
39
39
  import { readAcceptedFlags } from "./narrative-refutation-record.mjs"; // T3b — the typed flags, not the re-parse
40
40
  import { evidenceClaimViolations, evidenceClaimTable } from "./evidence-claim-invariant.mjs"; //
41
- import { buildCorrectionsApplied, correctionsWorklist, correctionsAppliedTable, correctionScope, scopeDrift, unresolvedFlags } from "./corrections-feedforward.mjs";
41
+ import { buildCorrectionsApplied, correctionsWorklist, correctionsAppliedTable, correctionScope, scopeDrift, unresolvedFlags, reportLines, linesOf, REPORT_LINE_KEY, REPORT_LINE_LABEL } from "./corrections-feedforward.mjs";
42
42
  import { parseCoverageLedgerJson, parseCoverageLedgerFull, deriveCoverageStatus, classTokensFromScopeText, coerceToolAbsenceDeferred, applyTaintDeferred, decideRegisterGap, splitDeferredByCloseability, coverageLedgerTableRows, coverageUnitLabel, NON_MATERIAL_AXES, COVERAGE_STATUSES } from "./coverage-ledger.mjs";
43
43
  import { receiptSettled, readEnvelopeDecision, settleReceipt, settledDeferralsSection } from "./envelope-settle.mjs";
44
44
  import { readRegisterTaint, readActiveTaintAxes } from "./register-taint.mjs";
@@ -4321,7 +4321,7 @@ async function stageOnce(name, ctx, opts = {}) {
4321
4321
  stallSec: def.stallSec, // per-stage stall override (heavy stages); undefined → global CLEAROTRON_STALL_MS
4322
4322
  expectFile: out,
4323
4323
  validate: def.validate,
4324
- runDir: P.runDir,
4324
+ runDir: P.runDir, bandSize: bandSizeForStage(name, P),
4325
4325
  // ── — THE PRESENTING SIDE, RECOMPOSED PER ATTEMPT ────────────────────────────────────────
4326
4326
  //
4327
4327
  // A stage that declares `refreshCtx` is saying its dispatch text depends on state the run CHANGES
@@ -5741,9 +5741,9 @@ export function rollbackCorrectivePass(P, runDir, pre, fail) { // @internal
5741
5741
  * event so e2e can assert exactly that, and a non-zero count is a report of the fix not holding rather
5742
5742
  * than of this backstop working.
5743
5743
  *
5744
- * @returns {null | {restoredFindings: {ordinal: *, mark: *}[], restoredKeys: string[], leftRemoved: {ordinal: *, mark: *}[]}}
5744
+ * @returns {null | {restoredFindings: {ordinal: *, mark: *}[], restoredKeys: string[], restoredRows: {register: string, key: string}[], leftRemoved: {ordinal: *, mark: *}[]}}
5745
5745
  */
5746
- export function repairUnnamedRemovals(P, runDir, pre, namedOrdinals, namedMarks) { // @internal
5746
+ export function repairUnnamedRemovals(P, runDir, pre, namedOrdinals, namedMarks, namedLines = []) { // @internal
5747
5747
  if (!pre?.raw) return null; // nothing held — nothing to compare against
5748
5748
  let preDoc = null, postDoc = null, postRaw = null;
5749
5749
  try { preDoc = JSON.parse(pre.raw); } catch { return null; }
@@ -5769,10 +5769,38 @@ export function repairUnnamedRemovals(P, runDir, pre, namedOrdinals, namedMarks)
5769
5769
  // covered the day it exists — a list of key names here would be a second place to remember.
5770
5770
  const restoredKeys = Object.keys(preDoc).filter((k) => !(k in postDoc));
5771
5771
 
5772
- if (!restoredFindings.length && !restoredKeys.length) return null;
5772
+ // ROWS OF A REGISTER, NOT ONLY WHOLE REGISTERS. A pass rewriting a coverage note or an action sends the
5773
+ // complete array in its PATCH, and a patch REPLACES the stored register — so a row the seat left out is
5774
+ // a row deleted, and nothing downstream objects unless the slice was a limited one. A row no flag named
5775
+ // is restored whole, keyed as `reportLines` keys it; a row the reviewer named stays out, as a named
5776
+ // finding does.
5777
+ const lineKeys = new Set(namedLines ?? []);
5778
+ const restoredRows = [];
5779
+ const rowsBack = {};
5780
+ for (const [reg, keyOf] of Object.entries(REPORT_LINE_KEY)) {
5781
+ if (!Array.isArray(preDoc[reg]) || !Array.isArray(postDoc[reg])) continue;
5782
+ const have = new Set(postDoc[reg].map(keyOf));
5783
+ const back = preDoc[reg].filter((r) => !have.has(keyOf(r)) && !lineKeys.has(keyOf(r)));
5784
+ if (!back.length) continue;
5785
+ rowsBack[reg] = back;
5786
+ for (const r of back) restoredRows.push({ register: reg, key: keyOf(r), label: REPORT_LINE_LABEL[reg](r) });
5787
+ }
5788
+
5789
+ if (!restoredFindings.length && !restoredKeys.length && !restoredRows.length) return null;
5773
5790
 
5774
5791
  const merged = { ...postDoc };
5775
5792
  for (const k of restoredKeys) merged[k] = preDoc[k];
5793
+ for (const [reg, back] of Object.entries(rowsBack)) {
5794
+ // In the snapshot's order, the post-pass version of every row it kept, then any row the pass added.
5795
+ const keyOf = REPORT_LINE_KEY[reg];
5796
+ const post = new Map(postDoc[reg].map((r) => [keyOf(r), r]));
5797
+ const restored = new Set(back);
5798
+ const inPre = new Set(preDoc[reg].map(keyOf));
5799
+ merged[reg] = [
5800
+ ...preDoc[reg].flatMap((r) => restored.has(r) ? [r] : post.has(keyOf(r)) ? [post.get(keyOf(r))] : []),
5801
+ ...postDoc[reg].filter((r) => !inPre.has(keyOf(r))),
5802
+ ];
5803
+ }
5776
5804
  if (restoredFindings.length) {
5777
5805
  // Back in the order the reviewer read them, by ordinal — a restored finding appended to the end
5778
5806
  // would be a different document from the one that was correct.
@@ -5785,6 +5813,7 @@ export function repairUnnamedRemovals(P, runDir, pre, namedOrdinals, namedMarks)
5785
5813
  ts: new Date().toISOString(),
5786
5814
  restoredFindings: restoredFindings.map(brief),
5787
5815
  restoredKeys,
5816
+ restoredRows,
5788
5817
  leftRemoved: leftRemoved.map(brief),
5789
5818
  };
5790
5819
  atomicWrite(P.findings, JSON.stringify(merged, null, 2) + "\n");
@@ -5811,7 +5840,22 @@ export function repairUnnamedRemovals(P, runDir, pre, namedOrdinals, namedMarks)
5811
5840
  */
5812
5841
  export function restoredFindingsTable(repair) { // @internal
5813
5842
  const rows = repair?.restoredFindings ?? [];
5814
- if (!rows.length) return "";
5843
+ // A RESTORED ROW IS THE DRIVER'S DOING TOO, and this read only findings — so a coverage note or an
5844
+ // action the driver put back reached the reviewer as the pass's own work, which is the one reading
5845
+ // this section exists to prevent. Same repair, same snapshot, same sentence (found in review).
5846
+ const lines = repair?.restoredRows ?? [];
5847
+ if (!rows.length && !lines.length) return "";
5848
+ if (!rows.length) return [
5849
+ "RESTORED BY THE DRIVER, NOT BY THE AUTHOR — read these before anything else.",
5850
+ "The corrective pass removed the line(s) below and no flag of yours named them, so the driver put them",
5851
+ "back exactly as you last read them. They carry NONE of this round's corrections, and they are not the",
5852
+ "author's judgment about anything. If a removal here was in fact right, say so in your review and name",
5853
+ "the ground — that is the statement the pass failed to make.",
5854
+ "",
5855
+ ...lines.map((l) => ` ${l.label} — restored: removed by the corrective pass with no flag naming it`),
5856
+ "",
5857
+ "These lines MOVED, whatever the scope note above says about what did not.",
5858
+ ].join("\n");
5815
5859
  return [
5816
5860
  "RESTORED BY THE DRIVER, NOT BY THE AUTHOR — read these before anything else.",
5817
5861
  "The corrective pass removed the finding(s) below and no flag of yours named them, so the driver put",
@@ -5820,6 +5864,7 @@ export function restoredFindingsTable(repair) { // @internal
5820
5864
  "and name the ground — that is the statement the pass failed to make.",
5821
5865
  "",
5822
5866
  ...rows.map((f) => ` #${f.ordinal} ${f.mark} — restored: removed by the corrective pass with no flag naming it`),
5867
+ ...lines.map((l) => ` ${l.label} — restored: removed by the corrective pass with no flag naming it`),
5823
5868
  "",
5824
5869
  "These rows MOVED, whatever the scope note above says about findings that did not.",
5825
5870
  ].join("\n");
@@ -5838,6 +5883,35 @@ function correctionNamedOrdinals(P) {
5838
5883
  return out;
5839
5884
  }
5840
5885
 
5886
+ /**
5887
+ * The non-finding lines the review's flags name or quote, as `reportLines` keys — matched against the
5888
+ * PRE-corrective document, for the reason the names are: a removed row is absent from the post file.
5889
+ *
5890
+ * ── THIS JOIN DECIDES A RESTORATION, AND ITS TWO FAILURE DIRECTIONS ARE OPPOSITE ─────────────────────
5891
+ *
5892
+ * `linesOf` applies no correction anywhere — but these keys become `namedLines`, and `repairUnnamedRemovals`
5893
+ * keeps a removed row OUT when its key is among them. So a prose match decides whether a row the pass
5894
+ * deleted comes back:
5895
+ *
5896
+ * a MISS → the row is restored although the reviewer asked for it to go: the driver overrides a
5897
+ * judgment that was made, and the reviewer sees it named as restored and can say so again.
5898
+ * a FALSE HIT → an accidental deletion stays deleted: the row leaves the client's report and the
5899
+ * restored-lines section says nothing, because the driver believed it was asked for.
5900
+ *
5901
+ * The second is the worse one and it is the same bet the backstop already makes by mark for findings.
5902
+ * What keeps it bounded is that the reviewer is told: every restored row prints in the re-read section,
5903
+ * so an override is visible, and a flag whose wording missed still leaves its own row in the observation
5904
+ * table as unresolved. `namesLine` wants the label's shape — "the coverage line for <area>" — so a flag
5905
+ * writing "the US coverage note" misses unless it also quoted eight running words of the line.
5906
+ */
5907
+ function correctionNamedLines(P, doc0) {
5908
+ try {
5909
+ const review = existsSync(P.seniorEyeReview) ? readFileSync(P.seniorEyeReview, "utf8") : "";
5910
+ const lines = reportLines(doc0);
5911
+ return [...new Set(parseCorrections(review).flatMap((r) => linesOf(r?.text, lines).map((l) => l.key)))];
5912
+ } catch { return []; } // no review read — every row removal is then unnamed, and restored
5913
+ }
5914
+
5841
5915
  /**
5842
5916
  * @param {*} doc0 the findings document to match the review's names against. DEFAULTS to the file on
5843
5917
  * disk, which is right for the staleness check — but WRONG for judging a removal: a finding that was
@@ -6082,12 +6156,9 @@ function plainRegisterExtra(ctx) {
6082
6156
  const visible = [];
6083
6157
  const add = (where, v) => { const t = String(v ?? "").trim(); if (t) visible.push({ where, text: t }); };
6084
6158
  for (const f of doc.findings ?? []) add(`conflict ${f?.ordinal ?? "?"}'s one sentence`, f?.net);
6085
- for (const c of doc.coverage ?? []) add(`the coverage line for "${c?.area ?? "an area"}"`, c?.note);
6086
- for (const a of doc.actions ?? []) add(`the action "${a?.id ?? a?.kind ?? ""}"`.replace(/ ""$/, ""), a?.text);
6087
- for (const k of ["distinctiveness", "connotation"]) {
6088
- const v = doc.markAssessment?.[k];
6089
- add(`the mark assessment's ${k}`, typeof v === "string" ? v : v?.read);
6090
- }
6159
+ // The lines that are not a finding, labelled by `reportLines` — the labels the corrective cycle's
6160
+ // observation joins a flag back to, so what the reviewer is handed and what the driver reads agree.
6161
+ for (const l of reportLines(doc)) add(l.label, l.text);
6091
6162
 
6092
6163
  const hits = [];
6093
6164
  for (const { where, text } of visible) {
@@ -7534,6 +7605,8 @@ export function buildReviewerOpenPointsSection(reviewMd, appliedRows = null) {
7534
7605
  "findings-removed": "the finding this named was removed rather than corrected",
7535
7606
  "not-entity-scoped": "the run could not check whether this was addressed",
7536
7607
  "not-checkable": "the run could not check whether this was addressed",
7608
+ "line-unchanged": "the line this named was left as written",
7609
+ "line-removed": "the line this named was removed rather than corrected",
7537
7610
  };
7538
7611
  const items = rows.map((r) => {
7539
7612
  const bits = [];
@@ -12186,7 +12259,7 @@ async function pipelineInner(job, opts = {}) {
12186
12259
  let preDocForNames = null;
12187
12260
  try { preDocForNames = preCorrective ? parseFindingsJsonLenient(preCorrective.raw) : null; } catch { /* fall back to the file */ }
12188
12261
  const repaired = repairUnnamedRemovals(P, run.runDir, preCorrective,
12189
- correctionNamedOrdinals(P), correctionNamedSet(P, preDocForNames));
12262
+ correctionNamedOrdinals(P), correctionNamedSet(P, preDocForNames), correctionNamedLines(P, preDocForNames));
12190
12263
  correctiveRepair = repaired; // carried to the reviewer's re-read, which must know these are the DRIVER's
12191
12264
  if (repaired) {
12192
12265
  // A DEFECT SIGNAL, not a success. After the schema fix a corrective pass sends a targeted edit
@@ -12194,12 +12267,14 @@ async function pipelineInner(job, opts = {}) {
12194
12267
  // primary fix not holding, never this backstop working.
12195
12268
  runLog(run.runDir, { event: "corrective-unnamed-removal-repaired", defect: true,
12196
12269
  restored: repaired.restoredFindings.length, keys: repaired.restoredKeys.length,
12270
+ rows: repaired.restoredRows.length,
12197
12271
  leftRemoved: repaired.leftRemoved.length,
12198
12272
  findings: repaired.restoredFindings.map((f) => `${f.ordinal}:${f.mark}`) });
12199
12273
  note(`[corrections] the corrective pass removed ${repaired.restoredFindings.length} finding(s) `
12200
12274
  + `no flag named — restored whole from the pre-corrective snapshot: `
12201
12275
  + `${repaired.restoredFindings.map((f) => `#${f.ordinal} ${f.mark}`).join(", ")}`
12202
12276
  + (repaired.restoredKeys.length ? `; and ${repaired.restoredKeys.length} top-level register(s): ${repaired.restoredKeys.join(", ")}` : "")
12277
+ + (repaired.restoredRows.length ? `; and ${repaired.restoredRows.length} row(s): ${repaired.restoredRows.map((r) => r.key).join(", ")}` : "")
12203
12278
  + (repaired.leftRemoved.length ? `. ${repaired.leftRemoved.length} removal(s) the reviewer DID name stay removed.` : "")
12204
12279
  + " The reviewer re-reads the repaired document before it ships.");
12205
12280
  }
@@ -15247,9 +15322,9 @@ async function pipelineInner(job, opts = {}) {
15247
15322
  // rides the outbox like every other event (the engine has no message tool; a chat-ping stage on it
15248
15323
  // would burn a turn and fail) — the never-silent guarantee, headless form (docs/DELIVERY.md
15249
15324
  // run-failed packet; the level-triggered *.pending watch keeps it alive until ack_event).
15250
- // failPingSent records whether the notice is already on its way; the T5 packet lane below
15251
- // fires ONLY when it is not (its sendPending flag has no failure-side clear in the ack_event loop,
15252
- // so it must stay a backstop, never the primary).
15325
+ // failPingSent records which lane WROTE the packet. It no longer decides whether the notice is
15326
+ // owed: a failure arms sendPending either way (below), because mark_sent is the only clear and it
15327
+ // settles a failure.json on the same evidence a delivery needs. ONE SEND PATH, ruled 2026-09-12.
15253
15328
  let failPingSent = false;
15254
15329
  {
15255
15330
  try {
@@ -15289,27 +15364,37 @@ async function pipelineInner(job, opts = {}) {
15289
15364
  // no longer silence it ( F9: teal-keystone died silently on exactly that). Best-effort —
15290
15365
  // packet-write trouble must never mask the original failure; the .failed sentinel + status.json
15291
15366
  // remain the durable record either way.
15292
- if (!failPingSent) {
15293
- try {
15367
+ //
15368
+ // OWED EITHER WAY, AND EXACTLY ONE MARKER. A failed run used to read sendPending:false whenever the
15369
+ // packet write SUCCEEDED, so the owed-run scan could not see it and the notice waited on a reader of
15370
+ // the outbox instead. Now the flag is armed on both lanes. What is NOT duplicated is the marker: the
15371
+ // primary lane's packet IS one, because writeOutboxPacket("<runId>.failed") lands
15372
+ // `<runId>.failed.pending` and the *.pending watch already matches it. Arming a second
15373
+ // `<runId>.pending` beside it would leave a file mark_sent does not clear — it clears `<id>.pending`
15374
+ // for each id the run was known by, not this suffixed form — and the sweep re-arms off whatever is
15375
+ // on disk, which is how a permanent SEND PENDING was born. So: the flag always, the packet and the
15376
+ // marker only when nothing wrote them.
15377
+ try {
15378
+ if (!failPingSent) {
15294
15379
  const packet = buildFailurePacket({
15295
15380
  runId: `${run.slug}-${run.date}-${run.codename}`, agent, job, failedStage, shortReason, // canonical runId form (charter P1 §3)
15296
15381
  reasonVerbatim: String(reason).slice(0, 1000), sig: failSig.sig, failClass, terminalKind,
15297
15382
  repairs: attemptedRepairs ?? [], priorAttempts, whatsappTo: AGENT_WHATSAPP[agent] ?? null,
15298
15383
  reasonDetail: reasonDetailField, reasonQuantity: quantity, // — the BACKSTOP notice carries it too
15299
15384
  });
15300
- // same per-send invariant as the delivery handoff: a fresh notice supersedes an older send's
15301
- // .sent marker AND its per-channel receipts (e.g. a resumed run that fails again must still
15302
- // notify on every channel, not be skip-guarded by the earlier send's receipts).
15303
- try { rmSync(join(run.runDir, ".sent")); } catch { /* none */ }
15304
- try { rmSync(driverDir(run.runDir, "send-receipts.json"), { force: true }); } catch { /* none */ }
15305
15385
  writeFileSync(driverDir(run.runDir, "failure.json"), JSON.stringify(packet, null, 2) + "\n");
15306
- writeRunStatus(ctx, { sendPending: true });
15307
15386
  mkdirSync(config.outboxDir, { recursive: true });
15308
15387
  writeFileSync(join(config.outboxDir, `${packet.runId}.pending`), `${agent}\n`);
15309
- note(`failure notice: handoff → _driver/failure.json + outbox marker (completion-watch sends it)`);
15310
- } catch (nfErr) {
15311
- note(`failure-notice packet write skipped (${String(nfErr?.message ?? nfErr).slice(0, 100)}) — .failed + status.json remain the record`);
15312
15388
  }
15389
+ // same per-send invariant as the delivery handoff: a fresh notice supersedes an older send's
15390
+ // .sent marker AND its per-channel receipts (e.g. a resumed run that fails again must still
15391
+ // notify on every channel, not be skip-guarded by the earlier send's receipts).
15392
+ try { rmSync(join(run.runDir, ".sent")); } catch { /* none */ }
15393
+ try { rmSync(driverDir(run.runDir, "send-receipts.json"), { force: true }); } catch { /* none */ }
15394
+ writeRunStatus(ctx, { sendPending: true });
15395
+ note(`failure notice OWED (${failPingSent ? "event lane wrote the packet" : "handoff wrote the packet"}) — mark_sent is the only clear`);
15396
+ } catch (nfErr) {
15397
+ note(`failure-notice arming skipped (${String(nfErr?.message ?? nfErr).slice(0, 100)}) — .failed + status.json remain the record`);
15313
15398
  }
15314
15399
  note(`=== FAILED ${run.codename} at ${failedStage}: ${reason} ===\n`);
15315
15400
  // `codename` matches the three sibling terminals (postpone, recovery park, cancelled). Without it
@@ -4337,8 +4337,15 @@ const PORT = PORT_CHOICE.port;
4337
4337
  try {
4338
4338
  const res = await fetch(new URL("/mcp", MCP_URL), { method: "GET", redirect: "manual",
4339
4339
  signal: AbortSignal.timeout(2500) });
4340
+ // THE BODY IS READ BECAUSE THE HEADER DOES NOT SEPARATE THE TWO DOORS. A proxy-fronted door and
4341
+ // a key door both refuse with 401 and no `www-authenticate`; each names the credential it wants
4342
+ // in the body, and that is the only thing on the wire that tells them apart. Bounded and
4343
+ // best-effort: a door that sends nothing readable leaves this null and the verdict reports
4344
+ // rather than judges.
4345
+ let body = null;
4346
+ if (res.status === 401) { try { body = (await res.text()).slice(0, 400); } catch { body = null; } }
4340
4347
  probe = { ok: res.status < 500, status: res.status, error: null,
4341
- challenge: res.headers.get("www-authenticate") };
4348
+ challenge: res.headers.get("www-authenticate"), body };
4342
4349
  } catch (e) { probe = { ok: false, status: null, error: String(e?.cause?.code ?? e?.name ?? e?.message ?? e) }; }
4343
4350
  // `verbs` IS DELIBERATELY NOT PASSED. That check short-circuits ahead of the probe, so handing it
4344
4351
  // in would answer a question about the TOKEN where a question about the DOOR was asked — and the
@@ -207,6 +207,14 @@ export const REPAIR_COMPOSERS = [
207
207
  `IF YOU REMOVE THE SUPPORT FOR A CLAIM, THE CLAIM GOES WITH IT. Deleting the evidence and leaving the sentence standing does not make it safer, it makes it unsupported and therefore stronger than the record allows. A run was refused for exactly this.`,
208
208
  `1. Correct the flagged narrative sections — send back ONLY the sections a flag names, and every section the flags do not name is left out and comes back byte-identical;`,
209
209
  `2. mirror every correction into the finding objects the flags name (owner names, levels/composites, meters). A finding the review KILLS is not deleted: keep its object, set "disposition": "withdrawn" and add "withdrawn_reason": "<the review flag that killed it>". Add a top-level "corrections": {"applied": true, "note": "<one line per flagged entity: corrected / withdrawn / no-change-because-…>"}.`,
210
+ // THE LINES THAT ARE NOT A FINDING. A coverage note, an action and the mark assessment carry no
211
+ // ordinal, so step 2 cannot reach them, and nothing said where a rewrite of one goes: seven such
212
+ // flags survived one clearance on the test box, each recorded as naming nothing (2026-09-11). The
213
+ // channel already existed — a PATCH copies a top-level register of the findings document through
214
+ // — and this names it. The coverage-account rule above forbids NEW claims about what was searched;
215
+ // rewording a note keeps its claims, and the text says so, or the seat reads the two as a conflict
216
+ // and does nothing, which is what it did.
217
+ `3. A flag about a line the reader sees that is NOT a finding — a coverage row's "note", an action's "text", or the mark assessment's "distinctiveness" or "connotation" — is applied in the same PATCH: send the complete top-level \`coverage\` array (every row, the unflagged ones exactly as you last sent them), the complete \`actions\` array, or the complete \`mark_assessment\`, with the flagged line rewritten. A row left out of the array is a row deleted, so send every row. Rewording a coverage note is NOT reopening the coverage account: keep its area, its state and every fact it states, and change only how it says them. A flagged line you leave as written is named in the "corrections" note with the reason, as the flag names it — for example: the coverage line for "<area>": no-change-because …`,
210
218
  // ── CONVERTED. `editRepairTail` stood here and ordered targeted Edits of
211
219
  // both files. The seat holds no Edit for either any more — but the ECONOMICS that argued for a
212
220
  // targeted edit are preserved rather than lost, which is why the call has a patch shape at all:
package/driver/stages.mjs CHANGED
@@ -1276,7 +1276,7 @@ export const STAGES = {
1276
1276
  },
1277
1277
  "Campaign shape (stated) — the intake's campaign facts retyped from the `Stated campaign shape` line the dispatch already carries": {
1278
1278
  class: "mechanical:code-rendered", tokens: [],
1279
- why: "CLASS ALIGNED WITH `## Instructed scope` in this same stage — same mechanism, same job object, same retyping of a driver-held value, so the same discharge: code stamps the line rather than the model retyping it. Not pre-bound: pre-bound means the driver writes the value into a FORM before dispatch, and nothing here does that. The driver holds job.campaignShape and puts it in the dispatch; the model retypes it into the frame. The dispatch itself calls it \"facts, not judgment\". Same shape as `## Instructed scope` eight elements above, which is already mechanical. NOTE FOR E7: this is derivable-and-not-derived, and it is NOT covered by frame_scope_missing — instructed-scope.json carries marks/classes/jurisdictions/goods/customer (pipeline.mjs:6199) and no campaign field, so nothing joins the frame's copy back to the intake value. [citation unverified]",
1279
+ why: "CLASS ALIGNED WITH `## Instructed scope` in this same stage — same mechanism, same job object, same retyping of a driver-held value, so the same discharge: code stamps the line rather than the model retyping it. Not pre-bound: pre-bound means the driver writes the value into a FORM before dispatch, and nothing here does that. The driver holds job.campaignShape and puts it in the dispatch; the model retypes it into the frame. The dispatch itself calls it \"facts, not judgment\". Same shape as `## Instructed scope` eight elements above, which is already mechanical. NOTE FOR E7: this is derivable-and-not-derived, and it is NOT covered by frame_scope_missing — instructed-scope.json carries marks/classes/jurisdictions/goods/customer (written at intake by `pipelineInner()` in `pipeline.mjs`) and no campaign field, so nothing joins the frame's copy back to the intake value.",
1280
1280
  },
1281
1281
  "Campaign shape (inferred) — the inference where intake stated none, and the decision to label the line `(inferred — not stated in the request)`": {
1282
1282
  class: "judgment", tokens: [],
@@ -4664,3 +4664,15 @@ export function recordEmptyReturn(built, id, { parts, ids, empty }) {
4664
4664
  export function dispatchBlockWhy(ctx, stage, id) {
4665
4665
  return ctx?.dispatchBlocks?.[stage]?.empty?.find((x) => x.id === id)?.why ?? null;
4666
4666
  }
4667
+
4668
+ // The stages whose dispatch hands the seat the band reading layer. Declared as data so the driver can
4669
+ // ask "does this stage read the band" without composing its message, and pinned by a test that DERIVES
4670
+ // the same set from which stage functions reference `BAND_READING_CONTRACT` — the declaration is
4671
+ // convenience, the derivation is the authority.
4672
+ //
4673
+ // AT THE FOOT OF THIS FILE ON PURPOSE, not beside the contract it belongs to. Every line inserted above
4674
+ // a cited line silently mis-points every citation below it, and this file is cited by line in twenty
4675
+ // places; the citation check only fails when a cited span goes entirely blank, so a citation shifted
4676
+ // onto a different real line passes while describing the wrong code. Adding below the last cited line
4677
+ // is the one placement that cannot do that. See band-size.mjs for what reads this.
4678
+ export const BAND_READING_STAGES = new Set(["placement-inquiry", "register-digest", "synthesis"]);
@@ -69,6 +69,12 @@
69
69
  "skips": 0,
70
70
  "todos": 0
71
71
  },
72
+ "a-budget-is-recorded-beside-the-band-it-ran-against.test.mjs": {
73
+ "tests": 9,
74
+ "asserts": 30,
75
+ "skips": 0,
76
+ "todos": 0
77
+ },
72
78
  "a-cancel-marker-names-its-actor.test.mjs": {
73
79
  "tests": 14,
74
80
  "asserts": 34,
@@ -171,6 +177,12 @@
171
177
  "skips": 0,
172
178
  "todos": 0
173
179
  },
180
+ "a-correction-to-a-line-that-is-not-a-finding-is-observed.test.mjs": {
181
+ "tests": 7,
182
+ "asserts": 33,
183
+ "skips": 0,
184
+ "todos": 0
185
+ },
174
186
  "a-credential-file-is-written-one-way.test.mjs": {
175
187
  "tests": 4,
176
188
  "asserts": 13,
@@ -231,6 +243,12 @@
231
243
  "skips": 0,
232
244
  "todos": 0
233
245
  },
246
+ "a-demo-sample-that-cannot-be-read-is-named.test.mjs": {
247
+ "tests": 7,
248
+ "asserts": 35,
249
+ "skips": 4,
250
+ "todos": 0
251
+ },
234
252
  "a-demo-says-what-it-is-not-what-it-lacks.test.mjs": {
235
253
  "tests": 5,
236
254
  "asserts": 32,
@@ -2980,8 +2998,8 @@
2980
2998
  "todos": 0
2981
2999
  },
2982
3000
  "pipeline.mock.test.mjs": {
2983
- "tests": 93,
2984
- "asserts": 785,
3001
+ "tests": 94,
3002
+ "asserts": 792,
2985
3003
  "skips": 0,
2986
3004
  "todos": 0
2987
3005
  },
@@ -3335,7 +3353,7 @@
3335
3353
  },
3336
3354
  "reading-the-demo-does-not-edit-the-repository.test.mjs": {
3337
3355
  "tests": 2,
3338
- "asserts": 8,
3356
+ "asserts": 13,
3339
3357
  "skips": 1,
3340
3358
  "todos": 0
3341
3359
  },
@@ -3616,8 +3634,8 @@
3616
3634
  "todos": 0
3617
3635
  },
3618
3636
  "release-pipeline.test.mjs": {
3619
- "tests": 91,
3620
- "asserts": 353,
3637
+ "tests": 93,
3638
+ "asserts": 356,
3621
3639
  "skips": 0,
3622
3640
  "todos": 0
3623
3641
  },
@@ -3887,7 +3905,7 @@
3887
3905
  },
3888
3906
  "runner.failure-backstop.test.mjs": {
3889
3907
  "tests": 3,
3890
- "asserts": 23,
3908
+ "asserts": 25,
3891
3909
  "skips": 0,
3892
3910
  "todos": 0
3893
3911
  },
@@ -4235,7 +4253,7 @@
4235
4253
  },
4236
4254
  "start-command.test.mjs": {
4237
4255
  "tests": 27,
4238
- "asserts": 120,
4256
+ "asserts": 121,
4239
4257
  "skips": 0,
4240
4258
  "todos": 0
4241
4259
  },
@@ -4455,6 +4473,12 @@
4455
4473
  "skips": 0,
4456
4474
  "todos": 0
4457
4475
  },
4476
+ "the-boot-line-says-which-door-it-found.test.mjs": {
4477
+ "tests": 5,
4478
+ "asserts": 21,
4479
+ "skips": 0,
4480
+ "todos": 0
4481
+ },
4458
4482
  "the-bundle-ships-no-commentary.test.mjs": {
4459
4483
  "tests": 2,
4460
4484
  "asserts": 3,
@@ -4510,8 +4534,8 @@
4510
4534
  "todos": 0
4511
4535
  },
4512
4536
  "the-corrective-pass-names-what-it-removes.test.mjs": {
4513
- "tests": 8,
4514
- "asserts": 33,
4537
+ "tests": 12,
4538
+ "asserts": 48,
4515
4539
  "skips": 0,
4516
4540
  "todos": 0
4517
4541
  },
@@ -5146,8 +5170,8 @@
5146
5170
  "todos": 0
5147
5171
  },
5148
5172
  "under-wsl-the-windows-binary-on-path-is-not-the-engine.test.mjs": {
5149
- "tests": 8,
5150
- "asserts": 26,
5173
+ "tests": 9,
5174
+ "asserts": 31,
5151
5175
  "skips": 0,
5152
5176
  "todos": 0
5153
5177
  },
@@ -5505,8 +5529,8 @@
5505
5529
  "todos": 0
5506
5530
  },
5507
5531
  "delivery-state.test.mjs": {
5508
- "tests": 11,
5509
- "asserts": 28,
5532
+ "tests": 12,
5533
+ "asserts": 31,
5510
5534
  "skips": 0,
5511
5535
  "todos": 0
5512
5536
  },
@@ -5596,7 +5620,7 @@
5596
5620
  },
5597
5621
  "ops.test.mjs": {
5598
5622
  "tests": 38,
5599
- "asserts": 161,
5623
+ "asserts": 164,
5600
5624
  "skips": 0,
5601
5625
  "todos": 0
5602
5626
  },
@@ -1,5 +1,11 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.3.1-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - d9798db: Fixed: A clearance that stops now always records that its notice is still owed, so a failure cannot be passed over as already handled.
8
+
3
9
  ## 0.3.0
4
10
 
5
11
  No changes in this release.
@@ -643,8 +643,17 @@ export function markSent(args = {}) {
643
643
  const legacy = run.slug && run.codename ? `${run.slug}-${run.codename}` : null;
644
644
  const forms = [...new Set([packet.runId, run.runId, args.runId, legacy]
645
645
  .filter((id) => id != null && String(id).trim() !== "").map(sanitize))];
646
+ // BOTH SUFFIXES, because a failure's marker IS its packet. The delivery lane drops `<id>.pending`;
647
+ // the failure lane writes its packet through the outbox as `<id>.failed.pending`, which the same
648
+ // *.pending watch matches. Clearing only the first left the failure form on disk after a settle, the
649
+ // sweep re-armed off it, and the run stayed owed forever — the permanent SEND PENDING the pipelines'
650
+ // old comment feared, arriving by the one route nobody had named. A missing form is a no-op, as
651
+ // before. (How many were stranded when this was found is a fact about one box on one day; it lives on
652
+ // the issue, not here, because a count in a comment rots and the mechanism does not.)
646
653
  for (const id of forms) {
647
- try { rmSync(join(config.outboxDir, `${id}.pending`), { force: true }); } catch { /* best-effort */ }
654
+ for (const suffix of [".pending", ".failed.pending"]) {
655
+ try { rmSync(join(config.outboxDir, `${id}${suffix}`), { force: true }); } catch { /* best-effort */ }
656
+ }
648
657
  }
649
658
  };
650
659
  const sentPath = join(run.runDir, ".sent");
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.3.1-beta.0",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
@@ -205,10 +205,21 @@ const tools = {
205
205
  brief({ runId }) {
206
206
  return { _note: BRIEFING_NOTE, ...buildBrief(mustRun(runId)) };
207
207
  },
208
- list_runs({ agent, state, slug, mark, sendPending, limit = 50 } = {}) {
208
+ // ── ASKING FOR OWED RUNS IS A COMPLETENESS QUESTION, NOT A BROWSING ONE ──────────────────────────
209
+ //
210
+ // The default cap of 50 is right for "show me the recent runs" and wrong for "which runs still owe
211
+ // their requester a notice". An integrator polls the second to decide what to send; a cap silently
212
+ // drops the fifty-first, and the run it dropped is indistinguishable from a run that owes nothing.
213
+ // Nothing in the reply says a page was cut, so the caller cannot tell the difference either.
214
+ //
215
+ // So `sendPending: true` returns ALL of them unless the caller names a limit itself. Every other call
216
+ // keeps the cap it has always had: an explicit limit is still obeyed, and an unfiltered list is still
217
+ // 50, so no existing caller changes.
218
+ list_runs({ agent, state, slug, mark, sendPending, limit } = {}) {
209
219
  let out = enumerateRuns({ agent, state, slug, mark }).map(runSummary);
210
220
  if (sendPending === true || sendPending === false) out = out.filter((r) => r.sendPending === sendPending);
211
- return out.slice(0, limit);
221
+ const cap = limit ?? (sendPending === true ? out.length : 50);
222
+ return out.slice(0, cap);
212
223
  },
213
224
  list_profiles() {
214
225
  // Each customer carries its PROJECTS (engagements) so intake can resolve a projectKey too. A bad
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clearotron",
3
3
  "type": "module",
4
- "version": "0.3.0",
4
+ "version": "0.3.1-beta.0",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",
@@ -2,7 +2,7 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.0",
5
+ "version": "0.3.1-beta.0",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
8
8
  "engines": {
@@ -1,5 +1,9 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.3.1-beta.0
4
+
5
+ No changes in this release.
6
+
3
7
  ## 0.3.0
4
8
 
5
9
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.3.0",
3
+ "version": "0.3.1-beta.0",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "OAuth 2.1 MCP stdio bridge used by the engine's case-law gather stage (courtlistener / legaldatahunter).",