clearotron 0.2.1 → 0.3.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/INSTALL.md +5 -4
  2. package/bin/example.mjs +9 -5
  3. package/bin/onboard.mjs +19 -19
  4. package/bin/stop.mjs +65 -3
  5. package/build-info.json +2 -2
  6. package/driver/CHANGELOG.md +21 -0
  7. package/driver/declination-call.mjs +32 -0
  8. package/driver/driver.config.mjs +20 -0
  9. package/driver/engine/mcp/recording-server.mjs +4 -0
  10. package/driver/gateway.mjs +8 -3
  11. package/driver/knockout-assess-record.mjs +5 -1
  12. package/driver/package.json +1 -1
  13. package/driver/pipeline.mjs +83 -2
  14. package/driver/predelivery-lint.mjs +22 -4
  15. package/driver/publish/knockout.mjs +12 -6
  16. package/driver/publish/render-knockout.mjs +133 -23
  17. package/driver/publish/report-data.mjs +13 -3
  18. package/driver/record-carry.mjs +2 -2
  19. package/driver/reference-score.mjs +1 -1
  20. package/driver/result-noun-fields.mjs +7 -0
  21. package/driver/skills/knockout-assess/SKILL.md +10 -4
  22. package/driver/stages-knockout.mjs +1 -1
  23. package/driver/stages.mjs +1 -1
  24. package/driver/suite-census.json +66 -18
  25. package/driver/unit-inventory.mjs +47 -0
  26. package/driver/unit-state-verdict.mjs +8 -8
  27. package/driver/verify-knockout.mjs +9 -1
  28. package/driver/whatif-memo-run.mjs +45 -4
  29. package/mcp-server/CHANGELOG.md +2 -0
  30. package/mcp-server/lib/brief.mjs +15 -0
  31. package/mcp-server/lib/driver.mjs +6 -0
  32. package/mcp-server/lib/knockout.mjs +435 -0
  33. package/mcp-server/lib/scrub.mjs +1 -1
  34. package/mcp-server/package.json +1 -1
  35. package/mcp-server/server.mjs +69 -4
  36. package/package.json +1 -1
  37. package/portal-ui/package.json +1 -1
  38. package/providers/oauth-mcp-bridge/CHANGELOG.md +2 -0
  39. package/providers/oauth-mcp-bridge/package.json +1 -1
  40. package/scripts/drain-preflight.mjs +2 -2
  41. package/scripts/freeze-example-run.mjs +3 -3
  42. package/scripts/headless-page.mjs +51 -2
  43. package/scripts/live-surface-check.mjs +86 -17
  44. package/scripts/render-check.mjs +61 -2
  45. package/scripts/deploy-test.sh +0 -309
@@ -0,0 +1,435 @@
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
+ // lib/knockout.mjs — the KNOCKOUT lane, projected into the audit tools' own shapes (tracker issue 275).
4
+ //
5
+ // THE DEFECT THIS CLOSES, and it is worth stating plainly because the failure mode was a confident wrong
6
+ // answer rather than an error. Every read-only tool whose job is to show HOW a search reached its answer
7
+ // returned empty on a delivered Knockout search: `get_run` listed eleven clearance documents and reported
8
+ // each missing, `list_searches` returned zero, `read_artifact report` said the report did not exist while
9
+ // it sat on disk in the pool. An assistant session asked those tools whether a delivered knockout had
10
+ // checked a particular use, was shown a wall of empties, correctly applied "an absence is a finding", and
11
+ // told the account owner the report was ungrounded prose. Every part of that was false, and it was the
12
+ // only conclusion the tools supported.
13
+ //
14
+ // TWO CAUSES, BOTH MECHANICAL:
15
+ // 1. WRONG DIRECTORY. A knockout writes report.md/report.html/report-data.json to the POOL, never into
16
+ // the run dir. `resolveRun` has returned `poolDir` beside `P` for exactly this reason since brief.mjs
17
+ // needed it — and brief was the only reader that used it, which is precisely why brief was the only
18
+ // tool that worked.
19
+ // 2. WRONG SCHEMA. The projections read clearance artifacts (findings.json, audit.md, the coverage
20
+ // ledger) that this product never writes. The data they want is in `knockout-findings.json`.
21
+ //
22
+ // THE RULE THIS MODULE FOLLOWS, from the issue: the reader adapts to the product, never the product to the
23
+ // reader. Nothing here asks the knockout lane to emit a clearance-shaped artifact it has no use for.
24
+ //
25
+ // AND THE RULE THAT MATTERS MOST: a projection with no knockout equivalent returns a STATED refusal, never
26
+ // an empty list. `[]` reads as "we looked and found nothing" — that reading is the whole incident above.
27
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
28
+ import { basename, join } from "node:path";
29
+
30
+ import { koPaths } from "./driver.mjs";
31
+ import { driverDir } from "../../shared/driver-dir.mjs";
32
+
33
+ const readJson = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
34
+
35
+ /**
36
+ * Is this run a knockout? Answered from DISK, never from a stored lane string.
37
+ *
38
+ * `knockout-plan.json` is the frame stage's output and lands before any assessment exists, so a run that
39
+ * failed mid-flight is still recognised as the product it is — which is the case where an audit tool being
40
+ * wrong about the lane is least recoverable and most likely to be asked about.
41
+ */
42
+ export function isKnockoutRun(run) {
43
+ if (!run?.runDir) return false;
44
+ const K = koPaths(run.runDir);
45
+ return existsSync(K.findings) || existsSync(K.plan) || existsSync(K.frame);
46
+ }
47
+
48
+ /** The assessment artifact, parsed. `null` when the run has not reached the assess stage (or it failed). */
49
+ export function knockoutDoc(run) {
50
+ return run?.runDir ? readJson(koPaths(run.runDir).findings) : null;
51
+ }
52
+
53
+ const marksOf = (doc) => (Array.isArray(doc?.marks) ? doc.marks : []);
54
+
55
+ /**
56
+ * A stated non-answer. The shape carries `available: false` AND a sentence, so a caller that reads only
57
+ * the flag and a caller that reads only the prose both get the same fact — and neither can mistake it for
58
+ * a search that came back empty.
59
+ */
60
+ export function notProducedOnThisProduct(what, insteadSee) {
61
+ return {
62
+ available: false,
63
+ product: "knockout",
64
+ note: `A Knockout search does not produce ${what}. This is a statement about the product, not a result: `
65
+ + `nothing was searched and found empty here.${insteadSee ? ` ${insteadSee}` : ""}`,
66
+ };
67
+ }
68
+
69
+ // ── artifacts ────────────────────────────────────────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * What this lane ACTUALLY writes, with its real presence — run directory and pool together.
73
+ *
74
+ * The old list was `artifactStatus(P)` plus every REGISTER_AXES entry appended unconditionally: eleven
75
+ * clearance documents, every one reported `exists: false` on a product that never writes them. That is not
76
+ * an empty result, it is eleven false negatives, and it is what a reader was shown before concluding the
77
+ * pipeline had a data-integrity gap.
78
+ */
79
+ export function knockoutArtifacts(run) {
80
+ const K = koPaths(run.runDir);
81
+ const out = [
82
+ { name: "frame", file: basename(K.frame), path: K.frame },
83
+ { name: "plan", file: basename(K.plan), path: K.plan },
84
+ { name: "findings", file: basename(K.findings), path: K.findings },
85
+ { name: "assessment", file: basename(K.assessment), path: K.assessment },
86
+ { name: "registerCounts", file: basename(K.registerCounts), path: K.registerCounts },
87
+ { name: "registerRecords", file: basename(K.registerRecords), path: K.registerRecords },
88
+ { name: "sweepLedger", file: basename(K.sweepLedger), path: K.sweepLedger },
89
+ { name: "runLog", file: "run.jsonl", path: driverDir(run.runDir, "run.jsonl") },
90
+ ].map((a) => ({ name: a.name, file: a.file, exists: existsSync(a.path) }));
91
+
92
+ // The published half. A knockout's report lives in the pool and NOWHERE in the run dir, so a list built
93
+ // from the run dir alone reports a delivered report as missing — the exact reading that made
94
+ // `read_artifact report` say `exists: false` about a file on disk.
95
+ for (const [name, file] of publishedFiles(run)) out.push({ name, file, exists: true });
96
+
97
+ // The research payloads, one per mark, named so a reader can ask for one by name.
98
+ for (const f of listDir(join(run.runDir, "research"))) {
99
+ if (f.endsWith(".md")) out.push({ name: `research:${f.replace(/\.md$/, "")}`, file: f, exists: true });
100
+ }
101
+ return out;
102
+ }
103
+
104
+ const listDir = (d) => { try { return readdirSync(d); } catch { return []; } };
105
+
106
+ /** The pool-side documents this run actually published, as [name, file] pairs. Empty before delivery. */
107
+ function publishedFiles(run) {
108
+ const out = [];
109
+ if (!run?.poolDir) return out;
110
+ for (const f of listDir(run.poolDir)) {
111
+ if (f === "report.md") out.push(["report", f]);
112
+ else if (f === "report.html") out.push(["reportHtml", f]);
113
+ else if (/^report-data(-.+)?\.json$/.test(f)) out.push([f === "report-data.json" ? "reportData" : `reportData:${f.slice(12, -5)}`, f]);
114
+ else if (/^report-.+\.html$/.test(f)) out.push([`reportHtml:${f.slice(7, -5)}`, f]);
115
+ else if (/^knockout-audit-.+\.xlsx$/.test(f)) out.push(["auditWorkbook", f]);
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * Resolve a knockout artifact name to a path. Pool first for the published names, because those are the
122
+ * ones a reader asks for by name and the ones that are NOT in the run dir.
123
+ */
124
+ export function knockoutArtifactPath(run, name) {
125
+ const K = koPaths(run.runDir);
126
+ const direct = {
127
+ frame: K.frame, plan: K.plan, findings: K.findings, assessment: K.assessment,
128
+ registerCounts: K.registerCounts, registerRecords: K.registerRecords,
129
+ sweepLedger: K.sweepLedger, "run.jsonl": driverDir(run.runDir, "run.jsonl"),
130
+ }[name];
131
+ if (direct) return direct;
132
+ if (name?.startsWith("research:")) return K.research(name.slice("research:".length));
133
+ const published = publishedFiles(run).find(([n]) => n === name);
134
+ if (published && run.poolDir) return join(run.poolDir, published[1]);
135
+ return null;
136
+ }
137
+
138
+ // ── findings ─────────────────────────────────────────────────────────────────────────────────────────
139
+
140
+ /**
141
+ * The run's findings, in `filterFindings`' shape ({ source, kind, items }).
142
+ *
143
+ * `kind: "audit"` has NO knockout equivalent and says so. The clearance lane's audit trail is a per-finding
144
+ * rationale spine; this lane's deeper record is the audit workbook, which is a spreadsheet and not a thing
145
+ * this tool can hand back as rows. Returning `[]` there would report "the audit trail is empty" about a
146
+ * product that keeps its audit trail somewhere else.
147
+ */
148
+ export function knockoutFindings(run, { kind, sourceLayer } = {}) {
149
+ const doc = knockoutDoc(run);
150
+ if (!doc) return { ...notProducedOnThisProduct("an assessment artifact yet", "This run has not completed its assess stage."), kind: kind ?? "findings", items: [] };
151
+
152
+ if (kind === "audit") {
153
+ return {
154
+ ...notProducedOnThisProduct("a per-finding audit-trail spine",
155
+ "Its deeper record is the audit workbook published beside the report (read_artifact auditWorkbook names the file); the reasoning behind each band is on the finding itself, in `basis`."),
156
+ kind: "audit", items: [],
157
+ };
158
+ }
159
+ if (kind === "negatives") return { source: "knockout-findings.json", kind: "negatives", items: knockoutNegatives(doc) };
160
+
161
+ const items = [];
162
+ for (const m of marksOf(doc)) {
163
+ for (const f of (Array.isArray(m.findings) ? m.findings : [])) {
164
+ items.push({
165
+ id: `${m.name} #${f.ordinal}`, mark: m.name, ordinal: f.ordinal,
166
+ name: f.name, owner: f.owner ?? null, band: f.band ?? null, type: f.type ?? null,
167
+ net: f.net ?? null, basis: f.basis ?? null, evidence: f.evidence ?? [],
168
+ source_layer: sourceLayerOf(f),
169
+ });
170
+ }
171
+ // The promoted register filings the rater READ. They are findings a reader asks about by name, and
172
+ // omitting them here would make this tool disagree with the report, which prints them as cards.
173
+ for (const r of (Array.isArray(m.registerReads) ? m.registerReads : [])) {
174
+ items.push({
175
+ id: `${m.name} ${r.recordId}`, mark: m.name, ordinal: null,
176
+ name: null, owner: null, band: r.band ?? null, type: "Register filing",
177
+ net: r.read ?? null, basis: r.read ?? null, evidence: [], recordId: r.recordId ?? null,
178
+ source_layer: "Register",
179
+ });
180
+ }
181
+ }
182
+ const filtered = sourceLayer
183
+ ? items.filter((f) => String(f.source_layer ?? "").toLowerCase() === String(sourceLayer).toLowerCase())
184
+ : items;
185
+ return { source: "knockout-findings.json", kind: kind ?? "findings", items: filtered };
186
+ }
187
+
188
+ // A finding that reasoned from a fetched filing carries `weighedFilings`; everything else on this lane
189
+ // came off the marketplace/common-law sweep. Derived from the record, never from a word the seat typed.
190
+ const sourceLayerOf = (f) =>
191
+ (Array.isArray(f?.weighedFilings) && f.weighedFilings.length) ? "Register" : "Common-law";
192
+
193
+ const knockoutNegatives = (doc) => marksOf(doc).flatMap((m) =>
194
+ (Array.isArray(m.negatives) ? m.negatives : []).map((n, i) => ({
195
+ id: `${m.name} NR#${i + 1}`, mark: m.name,
196
+ term: n?.term ?? null, source: n?.source ?? null, note: n?.note ?? null,
197
+ })));
198
+
199
+ // ── evidence ─────────────────────────────────────────────────────────────────────────────────────────
200
+
201
+ /**
202
+ * The records this search considered, in `evidenceRecords`' shape ({ source, records }).
203
+ *
204
+ * Two layers, from two stores: the register filings the run FETCHED (`_driver/register-records.json` — the
205
+ * driver's own measurement, not a model's) and the common-law uses the assessment cited.
206
+ */
207
+ export function knockoutEvidence(run) {
208
+ const doc = knockoutDoc(run);
209
+ const recordsDoc = readJson(koPaths(run.runDir).registerRecords);
210
+ const out = [];
211
+
212
+ for (const entry of (Array.isArray(recordsDoc?.marks) ? recordsDoc.marks : [])) {
213
+ for (const r of (Array.isArray(entry.records) ? entry.records : [])) {
214
+ out.push({
215
+ layer: "register", mark: r.mark ?? entry.name ?? null, owner: r.owner ?? null,
216
+ country: r.territory ?? null, classes: r.classes ?? [], status: r.status ?? null,
217
+ url: r.url ?? null, recordId: r.recordId ?? null,
218
+ matchedForm: r.matchedForm ?? null, matchedBasis: r.matchedBasis ?? null,
219
+ // `retrieved` is what the provider actually returned for this row, and it is a FACT about the
220
+ // fetch rather than about the filing. A reader deciding how much weight to give a row needs it.
221
+ retrieved: true, superseded: false, source: "register-records.json",
222
+ });
223
+ }
224
+ }
225
+ for (const m of marksOf(doc)) {
226
+ for (const f of (Array.isArray(m.findings) ? m.findings : [])) {
227
+ for (const u of (Array.isArray(f.evidence) ? f.evidence : [])) {
228
+ out.push({
229
+ layer: "common-law", mark: m.name, owner: f.owner ?? null, country: null, classes: [],
230
+ status: null, url: u, name: f.name ?? null,
231
+ retrieved: true, superseded: false, source: "knockout-findings.json",
232
+ });
233
+ }
234
+ }
235
+ }
236
+ const source = [recordsDoc ? "register-records.json" : null, doc ? "knockout-findings.json" : null]
237
+ .filter(Boolean).join(" + ") || "none";
238
+ return { source, records: out };
239
+ }
240
+
241
+ // ── the search log ───────────────────────────────────────────────────────────────────────────────────
242
+
243
+ /**
244
+ * The defensibility record, in `searchLog`'s shape ({ count, searches }).
245
+ *
246
+ * `list_searches` is documented as "proof of where the search looked and found nothing", and on every
247
+ * knockout we sell it returned zero — while the proof sat in `negatives` inside knockout-findings.json.
248
+ * That is the single most quotable line of this issue: if a client challenged a knockout result, the
249
+ * product could not produce its own proof of search.
250
+ */
251
+ export function knockoutSearches(run) {
252
+ const doc = knockoutDoc(run);
253
+ const searches = [];
254
+
255
+ for (const m of marksOf(doc)) {
256
+ // The term the sweep actually ran for this mark. One row per mark, always — a mark that was swept and
257
+ // surfaced nothing is the row a defensibility question is ABOUT.
258
+ searches.push({
259
+ id: `${m.name} S#1`, term: m.name, mark: m.name,
260
+ classes: m.classesSearched ?? [], jurisdictions: [], office: null,
261
+ matchShapes: ["exact"],
262
+ outcome: (Array.isArray(m.findings) && m.findings.length) ? "found" : "no-hit",
263
+ note: m.degraded ? "Research payload unavailable for this mark — rated as degraded." : null,
264
+ source: "knockout-findings.json",
265
+ });
266
+ for (const [i, n] of (Array.isArray(m.negatives) ? m.negatives : []).entries()) {
267
+ searches.push({
268
+ id: `${m.name} NR#${i + 1}`, term: n?.term ?? null, mark: m.name,
269
+ classes: m.classesSearched ?? [], jurisdictions: [], office: null, matchShapes: ["exact"],
270
+ outcome: "no-hit", note: n?.note ?? null, source: n?.source ?? "knockout-findings.json",
271
+ });
272
+ }
273
+ }
274
+ // The register lane's own asked-and-unanswered terms, when it ran. `terms[].ok === false` is a search
275
+ // that did NOT answer — reporting it as a clean no-hit would be the un-run check dressed as a negative.
276
+ const recordsDoc = readJson(koPaths(run.runDir).registerRecords);
277
+ for (const entry of (Array.isArray(recordsDoc?.marks) ? recordsDoc.marks : [])) {
278
+ for (const t of (Array.isArray(entry.terms) ? entry.terms : [])) {
279
+ searches.push({
280
+ id: `${entry.name} REG:${t.term}`, term: t.term, mark: entry.name,
281
+ classes: entry.classes ?? [], jurisdictions: [], office: recordsDoc.providerLabel ?? recordsDoc.provider ?? null,
282
+ matchShapes: [t.basis ?? "exact"],
283
+ outcome: t.ok === false ? "recorded" : "found",
284
+ note: t.ok === false ? `This search did not answer: ${t.reason ?? "no reason recorded"}` : null,
285
+ source: "register-records.json",
286
+ });
287
+ }
288
+ }
289
+ return { count: searches.length, searches, source: doc ? "knockout-findings.json" : "none" };
290
+ }
291
+
292
+ // ── coverage ─────────────────────────────────────────────────────────────────────────────────────────
293
+
294
+ /**
295
+ * What the search covered, in `coverageStatement`'s shape ({ areas, note }).
296
+ *
297
+ * A knockout keeps no coverage LEDGER — that artifact is the clearance lane's, and saying "this run
298
+ * records no coverage ledger" was true and useless. What it does hold is a per-mark record of what was
299
+ * swept and what the register lane did, and that is what an area is here.
300
+ */
301
+ export function knockoutCoverage(run) {
302
+ const doc = knockoutDoc(run);
303
+ if (!doc) {
304
+ return {
305
+ ...notProducedOnThisProduct("a coverage statement before its assess stage completes"),
306
+ areas: [],
307
+ };
308
+ }
309
+ const areas = marksOf(doc).map((m) => ({
310
+ area: `${m.name} — common-law and marketplace sweep`,
311
+ state: m.degraded ? "Partially covered" : "Searched",
312
+ // NEVER "clean". This lane's own doctrine: a mark this screen did not knock out is not knocked out at
313
+ // the configured depth — a result about the SCREEN, not about the mark.
314
+ detail: m.degraded
315
+ ? "Research could not be completed for this name; the rating reflects an analytical assessment pending fuller data."
316
+ : `Screened at the configured depth${(m.classesSearched ?? []).length ? ` in classes ${m.classesSearched.join(", ")}` : ""}. `
317
+ + `${(m.findings ?? []).length} conflict(s) recorded, ${(m.negatives ?? []).length} search(es) recorded as returning nothing.`,
318
+ }));
319
+
320
+ const recordsDoc = readJson(koPaths(run.runDir).registerRecords);
321
+ const countsDoc = readJson(koPaths(run.runDir).registerCounts);
322
+ if (recordsDoc || countsDoc) {
323
+ areas.push({
324
+ area: "Register",
325
+ state: recordsDoc?.unavailable ? "Open item" : "Searched",
326
+ detail: recordsDoc?.unavailable
327
+ ? String(recordsDoc.unavailable)
328
+ : "Filings were fetched and counted for the searched names; the filings themselves are in list_evidence.",
329
+ });
330
+ } else {
331
+ areas.push({
332
+ area: "Register",
333
+ state: "Not run this run",
334
+ detail: "This run has no register component — the assessment rests on the common-law and marketplace sweep.",
335
+ });
336
+ }
337
+ return {
338
+ areas,
339
+ note: "A Knockout search screens; it does not enumerate. An area marked Searched means it was screened "
340
+ + "at the configured depth and nothing blocking surfaced there — never that the name is clear.",
341
+ source: "knockout-findings.json",
342
+ };
343
+ }
344
+
345
+ // ── trace ────────────────────────────────────────────────────────────────────────────────────────────
346
+
347
+ /**
348
+ * The stages this lane runs, in pipeline order. `trace`'s table is the clearance STAGE_ORDER, so its error
349
+ * enumerated fifteen stages and none of them was a knockout stage — the tool could resolve nothing on this
350
+ * product, including the word "verdict".
351
+ *
352
+ * `knockout-assess` is chunked (`knockout-assess#N`), so a caller may name the bare stage or one chunk.
353
+ */
354
+ export const KNOCKOUT_STAGES = ["knockout-frame", "knockout-sweep", "knockout-register", "knockout-assess"];
355
+
356
+ export function knockoutStageFor(target) {
357
+ const t = String(target ?? "").trim();
358
+ const bare = t.split("#")[0];
359
+ return KNOCKOUT_STAGES.includes(bare) ? t : null;
360
+ }
361
+
362
+ /**
363
+ * `trace` for this lane — HOW the run reached its answer, from the run's own event log.
364
+ *
365
+ * Deliberately NOT threaded through the clearance trace's walk. That walk is built on `paths()`, the
366
+ * register axes and the clearance findings spine; teaching it a second lane would put a knockout branch in
367
+ * every one of its enrichments, and the clearance trace is the surface that currently works. This answers
368
+ * the same question off the artifacts this product actually writes.
369
+ *
370
+ * `events` is the caller's already-read run log, passed in so this module never re-reads it.
371
+ */
372
+ export function traceKnockout(run, target, events = []) {
373
+ const t = String(target ?? "").trim();
374
+ const doc = knockoutDoc(run);
375
+ const verdict = () => ({
376
+ kind: "verdict",
377
+ verdict: doc?.batch?.overall ?? runVerdictFromEvents(events) ?? null,
378
+ marks: marksOf(doc).map((m) => ({ mark: m.name, band: m.rating ?? null, basis: m.basis ?? null })),
379
+ note: "A Knockout verdict is the batch's worst band across its marks; each mark's own band and the "
380
+ + "one-sentence ground for it are listed here.",
381
+ });
382
+
383
+ if (!t || /^verdict$/i.test(t)) return { runId: run.runId, target: t || "verdict", ...verdict() };
384
+
385
+ const stage = knockoutStageFor(t);
386
+ if (stage) {
387
+ const bare = stage.split("#")[0];
388
+ const rows = events.filter((e) => String(e.stage ?? "").split("#")[0] === bare
389
+ || String(e.event ?? "").startsWith(bare));
390
+ return {
391
+ runId: run.runId, target: t, kind: "stage", stage,
392
+ events: rows,
393
+ // What the stage wrote, by presence — the honest answer to "did this stage produce anything".
394
+ produced: knockoutArtifacts(run).filter((a) => a.exists && STAGE_OUTPUTS[bare]?.includes(a.name)),
395
+ note: STAGE_NOTES[bare] ?? null,
396
+ };
397
+ }
398
+
399
+ // A mark or an owner named directly — the fuzzy target the clearance trace also supports.
400
+ const hit = marksOf(doc).find((m) => String(m.name ?? "").toLowerCase().includes(t.toLowerCase()));
401
+ if (hit) {
402
+ return {
403
+ runId: run.runId, target: t, kind: "mark", mark: hit.name, band: hit.rating ?? null,
404
+ basis: hit.basis ?? null, factors: hit.factors ?? [], counterFactors: hit.counterFactors ?? [],
405
+ findings: (hit.findings ?? []).map((f) => ({ ordinal: f.ordinal, name: f.name, band: f.band, evidence: f.evidence ?? [] })),
406
+ registerReads: hit.registerReads ?? [],
407
+ note: "The band, the one-sentence ground for it, and the observations it rests on.",
408
+ };
409
+ }
410
+
411
+ return {
412
+ runId: run.runId, target: t,
413
+ error: `Could not resolve target "${t}" on this Knockout search. Try a stage (${KNOCKOUT_STAGES.join(", ")}), `
414
+ + `a mark name, an artifact (${knockoutArtifacts(run).filter((a) => a.exists).map((a) => a.name).join(", ")}), or "verdict".`,
415
+ };
416
+ }
417
+
418
+ // Which artifacts each stage is responsible for — used to answer "did it produce anything" by presence
419
+ // rather than by trusting a completion event, which is written before the file is fsynced.
420
+ const STAGE_OUTPUTS = {
421
+ "knockout-frame": ["frame", "plan"],
422
+ "knockout-sweep": ["sweepLedger"],
423
+ "knockout-register": ["registerCounts", "registerRecords"],
424
+ "knockout-assess": ["findings", "assessment"],
425
+ };
426
+
427
+ const STAGE_NOTES = {
428
+ "knockout-frame": "Reads the order and writes the plan: which names, which classes, at what depth.",
429
+ "knockout-sweep": "One research call per mark against the marketplace/common-law provider; the payloads land in research/.",
430
+ "knockout-register": "Fetches and counts register filings for the searched names. Absent on a run with no register component.",
431
+ "knockout-assess": "Rates each mark from its own payload and the fetched filings, and writes knockout-findings.json.",
432
+ };
433
+
434
+ const runVerdictFromEvents = (events) =>
435
+ [...events].reverse().find((e) => e.event === "verdict")?.verdict ?? null;
@@ -158,7 +158,7 @@ export function scrubMarkdown(text) {
158
158
  // at least announced itself as internal.
159
159
  //
160
160
  // The ruling needed no new product decision, because the product had already made it:
161
- // `publish/report-data.mjs:64` filters to live findings, "a withdrawn finding renders nowhere — it does
161
+ // `publish/report-data.mjs:74` (`const live`) filters to live findings, "a withdrawn finding renders nowhere — it does
162
162
  // not exist here either". Two client surfaces, one question, two answers. So the block is DROPPED, and
163
163
  // the two agree by construction rather than key by key — which is the thing asked not to repeat.
164
164
  //
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.3.0-beta.0",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "MCP server to interrogate prelim 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 prelim-driver read-only; touches no driver/template/deploy files.",
@@ -54,6 +54,13 @@ import { accountRun, accountTrace, accountTimeline, accountFinding, accountFindi
54
54
  accountWhatIfPlan, accountWhatIfQueued, accountWhatIfResult, CLIENT_FAILURE_NOTE as clientFailureNote } from "./lib/audit-view.mjs";
55
55
  import { scrubMarkdown, scrubBody, scrubFrontMatter, scrubCards } from "./lib/scrub.mjs";
56
56
  import { evidenceRecords, searchLog, coverageStatement } from "./lib/evidence.mjs";
57
+ // The knockout lane's projections. Every audit tool below branches on `isKnockoutRun` because the
58
+ // clearance projections read artifacts this product does not write, and returned empty rather than saying
59
+ // so (tracker issue 275).
60
+ import {
61
+ isKnockoutRun, knockoutDoc, knockoutArtifacts, knockoutArtifactPath, knockoutFindings,
62
+ knockoutEvidence, knockoutSearches, knockoutCoverage, traceKnockout, notProducedOnThisProduct,
63
+ } from "./lib/knockout.mjs";
57
64
  import { instructionsFor } from "./lib/instructions.mjs";
58
65
  import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-point test, all spellings
59
66
  import { BRAND } from "../shared/brand.mjs"; // — the operator name a client is told to expect, from the tenant seam
@@ -82,6 +89,18 @@ function mustRun(runId) {
82
89
  function artifactPath(run, name) {
83
90
  const { P, runDir } = run;
84
91
  if (name === "status.json") return join(runDir, "status.json");
92
+ // THE KNOCKOUT TABLE IS TERMINAL ON A KNOCKOUT, and both halves of that matter (tracker issue 275).
93
+ //
94
+ // Resolving FIRST is what fixes `report`: a knockout's report.md is written to the POOL and never into
95
+ // the run dir, so the clearance table returned the run dir's own `report` slot — a path that does not
96
+ // exist — and the tool answered `exists: false` about a file sitting on disk.
97
+ //
98
+ // Not FALLING THROUGH is the other half, and it was caught by an arm rather than by design. With a
99
+ // fall-through, `read_artifact narrative` on a knockout resolves against the clearance table, finds the
100
+ // slot, and returns `exists: false` — reporting a document this product never writes as a missing one.
101
+ // That is the defect this issue is about, reappearing one layer down. Returning null instead makes the
102
+ // tool refuse the name and name the artifacts this run actually has.
103
+ if (isKnockoutRun(run)) return knockoutArtifactPath(run, name);
85
104
  if (name === "run.jsonl" || name === "telemetry/run.jsonl") return driverDir(runDir, "run.jsonl");
86
105
  if (REGISTER_AXES.includes(name)) return P.registerUnit(name);
87
106
  // validate the axis against the known set — never let a "registerUnit:../../x" escape the run-dir
@@ -93,6 +112,10 @@ function artifactPath(run, name) {
93
112
  }
94
113
 
95
114
  function listArtifacts(run) {
115
+ // The error message a caller sees when a name does not resolve is built from this list, so on a knockout
116
+ // it has to name the knockout's own artifacts — otherwise the tool refuses a name and then suggests
117
+ // eleven documents this product does not write.
118
+ if (isKnockoutRun(run)) return knockoutArtifacts(run).filter((a) => a.exists).map(({ name, file }) => ({ name, file }));
96
119
  const { P } = run; const out = [];
97
120
  for (const [k, v] of Object.entries(P)) { if (k === "runDir" || typeof v === "function") continue; if (existsSync(v)) out.push({ name: k, file: basename(v) }); }
98
121
  for (const ax of REGISTER_AXES) { const p = P.registerUnit(ax); if (existsSync(p)) out.push({ name: `registerUnit:${ax}`, file: basename(p) }); }
@@ -227,6 +250,28 @@ const tools = {
227
250
  get_run({ runId }) {
228
251
  const run = mustRun(runId);
229
252
  const { stages, failover } = getStages(run.runDir);
253
+ // THE LANE DECIDES THE ARTIFACT LIST. Appending REGISTER_AXES unconditionally is what manufactured
254
+ // eleven `exists: false` rows on a product that writes none of those documents — a wall of false
255
+ // negatives, which a reader is entitled to read as a run with nothing on disk.
256
+ if (isKnockoutRun(run)) {
257
+ const doc = knockoutDoc(run);
258
+ const negatives = knockoutFindings(run, { kind: "negatives" }).items;
259
+ return {
260
+ run: runSummary(run),
261
+ product: "knockout",
262
+ stages, failover,
263
+ artifacts: knockoutArtifacts(run),
264
+ coverageSummary: {
265
+ // No ledger EXISTS on this lane, and saying so as a fact beats reporting it as a missing file.
266
+ coverageLedgerPresent: false,
267
+ coverageLedgerNote: "A Knockout search keeps no coverage ledger; get_search_coverage answers "
268
+ + "from the run's own per-mark record instead.",
269
+ complete: Boolean(doc),
270
+ findings: knockoutFindings(run).items.length,
271
+ negativeResults: negatives.length,
272
+ },
273
+ };
274
+ }
230
275
  return {
231
276
  run: runSummary(run),
232
277
  stages, failover,
@@ -249,6 +294,18 @@ const tools = {
249
294
  },
250
295
  list_findings({ runId, kind, sourceLayer, group }) {
251
296
  const run = mustRun(runId);
297
+ if (isKnockoutRun(run)) {
298
+ // `group` is the clearance report's on-field/off-field/out-of-scope curation. A knockout report has
299
+ // no such sectioning, so the honest answer names that rather than filtering to nothing.
300
+ if (group) {
301
+ return {
302
+ _note: BRIEFING_NOTE, kind: "cards", group, items: [],
303
+ ...notProducedOnThisProduct("on-field/off-field/out-of-scope card groups",
304
+ "Call list_findings without `group` for this run's findings, each with its band."),
305
+ };
306
+ }
307
+ return { _note: BRIEFING_NOTE, ...knockoutFindings(run, { kind, sourceLayer }) };
308
+ }
252
309
  if (group) { const cards = loadCards(run.P); return { _note: BRIEFING_NOTE, kind: "cards", group, items: cards.cards.filter((c) => c.group === group), note: cards.note }; }
253
310
  return { _note: BRIEFING_NOTE, ...filterFindings(run.P, { kind, sourceLayer }) };
254
311
  },
@@ -256,17 +313,20 @@ const tools = {
256
313
  // Data, not narrative: no BRIEFING_NOTE rides on these. The projections — and the reasoning about what
257
314
  // is evidence and what is method — live in lib/evidence.mjs; nothing is decided here.
258
315
  list_evidence({ runId, layer }) {
259
- const out = evidenceRecords(mustRun(runId));
316
+ const run = mustRun(runId);
317
+ const out = isKnockoutRun(run) ? knockoutEvidence(run) : evidenceRecords(run);
260
318
  return layer ? { ...out, records: out.records.filter((r) => r.layer === layer) } : out;
261
319
  },
262
320
  list_searches({ runId, outcome }) {
263
- const out = searchLog(mustRun(runId));
321
+ const run = mustRun(runId);
322
+ const out = isKnockoutRun(run) ? knockoutSearches(run) : searchLog(run);
264
323
  if (!outcome) return out;
265
324
  const searches = out.searches.filter((s) => s.outcome === outcome);
266
325
  return { ...out, count: searches.length, searches, totalCount: out.count };
267
326
  },
268
327
  get_search_coverage({ runId }) {
269
- return coverageStatement(mustRun(runId));
328
+ const run = mustRun(runId);
329
+ return isKnockoutRun(run) ? knockoutCoverage(run) : coverageStatement(run);
270
330
  },
271
331
  get_finding({ runId, id }) {
272
332
  const run = mustRun(runId);
@@ -275,7 +335,12 @@ const tools = {
275
335
  return f;
276
336
  },
277
337
  trace({ runId, target, depth, shallow }) {
278
- return trace(mustRun(runId), target, { depth: depth ?? 2, shallow: shallow === true });
338
+ const run = mustRun(runId);
339
+ // The clearance trace's target table is built from STAGE_ORDER, the register axes and the clearance
340
+ // findings spine, so on a knockout it resolved nothing at all — including "verdict" — and its error
341
+ // enumerated fifteen stages, none of them from this lane.
342
+ if (isKnockoutRun(run)) return traceKnockout(run, target, readEvents(run.runDir));
343
+ return trace(run, target, { depth: depth ?? 2, shallow: shallow === true });
279
344
  },
280
345
  get_telemetry({ runId, stage, axis }) {
281
346
  const run = mustRun(runId);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clearotron",
3
3
  "type": "module",
4
- "version": "0.2.1",
4
+ "version": "0.3.0-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.2.1",
5
+ "version": "0.3.0-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,7 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.3.0-beta.0
4
+
3
5
  ## 0.2.1
4
6
 
5
7
  ## 0.2.1-beta.2
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.2.1",
3
+ "version": "0.3.0-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).",
@@ -29,7 +29,7 @@
29
29
 
30
30
  import { spawnSync } from "node:child_process";
31
31
  import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
32
- import { fileURLToPath } from "node:url";
32
+ import { fileURLToPath, pathToFileURL } from "node:url";
33
33
  import { join, dirname } from "node:path";
34
34
  import { homedir, userInfo } from "node:os";
35
35
  import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-point test, all spellings
@@ -244,7 +244,7 @@ export async function preflight({ home = homedir(), root = ROOT } = {}) {
244
244
  const unitPath = join(root, "driver", "systemd", "prelim-driver.path");
245
245
  const unitText = existsSync(unitPath) ? readFileSync(unitPath, "utf8") : null;
246
246
 
247
- const { config } = await import(join(root, "driver", "driver.config.mjs"));
247
+ const { config } = await import(pathToFileURL(join(root, "driver", "driver.config.mjs")).href);
248
248
  const queueDirs = config.queueDirs ?? [];
249
249
  const watched = unitText == null ? null : watchedQueueDirs(unitText, home);
250
250