clearotron 0.3.0-beta.9 → 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.
package/INSTALL.md CHANGED
@@ -295,6 +295,71 @@ keeps working throughout.
295
295
  provider, so it is safe to run on any machine that has the repository. "Safe anywhere" is about spend,
296
296
  not about where it will run — on a packaged tree it refuses, as above.
297
297
 
298
+ ## 2a. Removing it
299
+
300
+ An install is not one directory, so removing it is not one command. Everything it writes is listed here.
301
+ Read the list before you delete anything: one of these directories holds your reports.
302
+
303
+ **Stop it first.** If the product is running in a terminal, Ctrl-C in that terminal. If you installed the
304
+ background services, `clearotron stop` stops them and removes their unit files. `clearotron status` says
305
+ which of the two you have.
306
+
307
+ **The program.** Installed globally, `npm rm -g clearotron` removes it. Installed by `clearotron install`
308
+ (which is also what a demo run from `npx` leaves behind), it lives in two places under `~/.local`:
309
+
310
+ ```
311
+ ~/.local/lib/node_modules/clearotron
312
+ ~/.local/bin/clearotron
313
+ ```
314
+
315
+ **The settings, and the keys.** `~/.config/clearotron/.env` holds this install's signing secrets, so
316
+ deleting it invalidates every key it ever issued. Beside it: `~/.config/clearotron/token-denylist`, the
317
+ list of keys you revoked, and `~/.config/clearotron/running`, which records a product running in a
318
+ terminal and is rewritten on every start.
319
+
320
+ **Your work.** The install keeps it all under one directory, `~/trademark` by default — wherever
321
+ `CLEAROTRON_REPORTS_DIR`'s parent points if you moved it:
322
+
323
+ ```
324
+ ~/trademark/pool THE REPORTS. Every clearance this install delivered.
325
+ ~/trademark/workspace the working files of each run, including unfinished ones
326
+ ~/trademark/queue work ordered and not yet run
327
+ ~/trademark/outbox what the product prepared to send
328
+ ~/trademark/locks which runs are in flight
329
+ ~/trademark/config your companies, their projects and your saved searches
330
+ ~/trademark/config/recipes the saved searches themselves
331
+ ~/trademark/config/profiles the company profiles
332
+ ~/trademark/grants.json who may use this install
333
+ ~/trademark/portal-audit.log what the portal was asked to do
334
+ ~/trademark/portal-local-credential.json the sign-in this install minted for you
335
+ ```
336
+
337
+ **Keep the pool if you want the reports.** They are finished documents and nothing else reads them: move
338
+ `~/trademark/pool` somewhere of your own and delete the rest. Deleting the pool deletes the clearances.
339
+
340
+ **The demo, if you ran one.** `~/trademark-demo` holds the demo's own copy of everything above, including
341
+ its own copy of the program under `~/trademark-demo/program`. It is one directory to remove and it shares
342
+ nothing with an install.
343
+
344
+ **The background services, if you installed them.** `clearotron stop` removes the four unit files; if you
345
+ would rather see them, they are in `~/.config/systemd/user`:
346
+
347
+ ```
348
+ clearotron-portal.service
349
+ clearotron-mcp-face.service
350
+ clearotron-worker.service
351
+ clearotron-client-mcp.service
352
+ ```
353
+
354
+ **And `~/.env`, which only a background install writes.** A service inherits nothing from the terminal
355
+ that installed it, so `clearotron start --background` writes everything those services need into `~/.env`,
356
+ mode 600 — your register credential, your research key and the engine's settings among it. It is not the
357
+ same file as `~/.config/clearotron/.env`, which configures the product when you run it yourself. Delete
358
+ both, or you leave a file of credentials in your home for services that no longer exist.
359
+
360
+ Nothing else on the machine is ours: no system-wide files, no services outside your own user, and nothing
361
+ in `/etc`, `/usr` or `/var`.
362
+
298
363
  ## 3. Configuration (environment)
299
364
 
300
365
  Runtime configuration is by environment variable, and there are two ways to supply them.
package/README.md CHANGED
@@ -59,6 +59,9 @@ npx clearotron@beta install
59
59
  What each channel promises, and when a stable is cut: [docs/RELEASES.md](docs/RELEASES.md). If you are not
60
60
  sure, the first command is the one you want.
61
61
 
62
+ Removing it later: **[INSTALL.md §2a](INSTALL.md#2a-removing-it)** lists every path an install writes, and
63
+ says which one holds your reports so you can keep them deliberately.
64
+
62
65
  ## Quick start
63
66
 
64
67
  With it installed, check what it found before it does anything. `doctor` only reads — it writes nothing,
package/bin/example.mjs CHANGED
@@ -46,7 +46,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
46
46
  import { spawn } from "node:child_process";
47
47
  import { BRAND } from "../shared/brand.mjs"; // — the installer's own name, from the tenant seam
48
48
  import { envFrom } from "../shared/env-aliases.mjs"; // — resolves EITHER spelling; names the retired one because that is the live-writable half
49
- import { isFrozen, demoChildren, publishSource } from "../driver/demo-container.mjs"; // — one definition of what a frozen demo is, for the player AND the gate
49
+ import { isFrozen, demoChildren, demoInventory, prepareSample } from "../driver/demo-container.mjs"; // — one definition of what a frozen demo is, for the player AND the gate
50
50
  import { ensureDemoProgram, demoProgramEnv } from "../shared/permanent-install.mjs";
51
51
 
52
52
  const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
@@ -109,7 +109,10 @@ const DEMO_ROOT = join(REPO, "demo");
109
109
  // --run-dir takes a directory outright. --product names a child. Neither given: EVERY child, and the
110
110
  // names are PRINTED below rather than assumed, because "the demo" means several.
111
111
  const wanted = flag("--product");
112
- const children = demoChildren(DEMO_ROOT);
112
+ // EVERY SAMPLE THE CONTAINER HOLDS, the unusable ones named (driver/demo-container.mjs says why).
113
+ const inventory = demoInventory(DEMO_ROOT);
114
+ const children = inventory.children;
115
+ const ALL = !flag("--run-dir") && !wanted;
113
116
 
114
117
  // ── ALL OF THEM, UNLESS THE CALLER NARROWED IT ──────────────────────────────────────────────────────
115
118
  //
@@ -129,6 +132,9 @@ const sampleDirs = flag("--run-dir")
129
132
  // The refusal below is about ONE directory, and with nothing shipped there is no directory to name — so
130
133
  // the container itself is what it looks at, which is what it always did when `demo/` was empty.
131
134
  const sampleDir = sampleDirs[0] ?? resolve(DEMO_ROOT);
135
+ // EVERY SAMPLE UNUSABLE is the absence below with its reasons, not "no frozen demo" over a full container.
136
+ if (ALL && !sampleDirs.length && inventory.unusable.length)
137
+ die("demo: no demo could be replayed.", "", ...inventory.unusable.map((u) => ` ${u.name}: ${u.why}`));
132
138
  if (!sampleDirs.length || !isFrozen(sampleDir)) {
133
139
  // AN ABSENCE IS A FINDING, AND IT NAMES WHAT IT LOOKED AT. This exits 1 and always has; a report of
134
140
  // it exiting 0 did not reproduce at v0.1.0 or at main's tip. An arm pins it.
@@ -153,15 +159,22 @@ if (!sampleDirs.length || !isFrozen(sampleDir)) {
153
159
  // PUBLISHING WRITES A RECEIPT INTO THE RUN DIRECTORY, and `demo/` is tracked — so a reader who only READ
154
160
  // the demo came back to a dirty checkout. `publishSource` is the one definition of that rule, shared with
155
161
  // the launcher, which seeds the pool from the same container on every `--demo` start.
156
- // EVERY ONE THAT WAS ASKED FOR IS READ BEFORE ANY IS PUBLISHED, so a manifest missing a runId is a
157
- // refusal about that demo by name rather than a partial pool nobody can account for.
158
- const samples = sampleDirs.map((dir) => {
159
- const manifest = join(dir, "meta.json");
160
- if (!isFrozen(dir)) die(`demo: ${dir} is not a frozen demo it holds no meta.json and lane entry file.`);
161
- const m = JSON.parse(readFileSync(manifest, "utf8"));
162
- if (!m?.runId) die(`example: ${manifest} names no runId — it is not a frozen example manifest.`);
163
- return { dir, meta: m, publishFrom: publishSource(dir, { repoRoot: REPO }), name: basename(dir) };
164
- });
162
+ // EVERY ONE THAT WAS ASKED FOR IS READ BEFORE ANY IS PUBLISHED. One named by --product or --run-dir that
163
+ // cannot be used is a refusal about it by name. With every sample asked for, ONE THAT CANNOT BE USED MUST
164
+ // NOT COST THE OTHERS: it joins the failures the replay below collects, named with its reason, and the
165
+ // rest are published. An unreadable file inside one sample used to throw out of the copy here and take
166
+ // every demo down with a stack trace (measured on a published beta, 2026-09-11).
167
+ const failures = ALL ? inventory.unusable.map((u) => ({ name: u.name, why: u.why })) : [];
168
+ const samples = [];
169
+ for (const dir of sampleDirs) {
170
+ const r = prepareSample(dir, { repoRoot: REPO });
171
+ if (r.sample) samples.push(r.sample);
172
+ else if (ALL) failures.push({ name: r.name, why: r.why });
173
+ else die(`demo: ${dir} cannot be replayed — ${r.why}.`);
174
+ }
175
+ if (!samples.length) die("demo: no demo could be replayed.", "", ...failures.map((f) => ` ${f.name}: ${f.why}`));
176
+ // How many samples this tree ships: the ones replayed and the ones named as failures, never the readable ones.
177
+ const shipped = samples.length + failures.length;
165
178
  const publishFrom = samples[0].publishFrom;
166
179
  const meta = samples[0].meta;
167
180
 
@@ -226,7 +239,7 @@ if (existsSync(poolRoot) && !statSync(poolRoot).isDirectory()) die(`demo: ${pool
226
239
  console.log(`\n ${BRAND.name} ${BRAND.product.toLowerCase()} — demo\n`);
227
240
  console.log(samples.length === 1
228
241
  ? ` sample: ${samples[0].dir}`
229
- : ` samples: ${samples.length} — ${samples.map((x) => x.name).join(", ")}`);
242
+ : ` samples: ${samples.length}${failures.length ? ` of ${shipped}` : ""} — ${samples.map((x) => x.name).join(", ")}`);
230
243
  console.log(` reports folder: ${poolRoot}\n`);
231
244
 
232
245
  mkdirSync(poolRoot, { recursive: true });
@@ -242,7 +255,6 @@ const { republishRun } = await import(pathToFileURL(join(REPO, "driver", "publis
242
255
  // The failures are collected and reported together at the end, and the process exits non-zero, because a
243
256
  // demo that came up missing a quarter of itself is not a success however good the three look.
244
257
  const results = [];
245
- const failures = [];
246
258
  for (const s0 of samples) {
247
259
  try {
248
260
  // poolUrl "" on purpose: the report's own link block is for a deployment that serves the pool at a
@@ -291,11 +303,12 @@ const spineOf = (pub) =>
291
303
  evidence, on ${pub.receipts.citing}/${pub.receipts.marks} mark(s)`
292
304
  : `this lane's publisher reported no finding count — the report itself is the record`;
293
305
  for (const r of results) console.log(` published: ${r.published.runId}\n ${r.name} — ${spineOf(r.published)}`);
294
- if (results.length > 1) console.log(`\n ${results.length} demo reports are published and listed one per product.`);
306
+ // "ONE PER PRODUCT" ONLY WHEN IT IS TRUE: with a sample missing, the count below says how many of how many.
307
+ if (results.length > 1 && !failures.length) console.log(`\n ${results.length} demo reports are published and listed — one per product.`);
295
308
  // LOUD, AND ON STDERR, AND NON-ZERO. Said after the successes so a reader sees what they DID get first,
296
309
  // and cannot mistake the run for a clean one.
297
310
  if (failures.length) {
298
- console.error(`\n ${failures.length} of ${samples.length} demo(s) could NOT be replayed:`);
311
+ console.error(`\n ${failures.length} of ${shipped} demo(s) could NOT be replayed:`);
299
312
  for (const f of failures) console.error(` ${f.name}: ${f.why}`);
300
313
  console.error(` The portal below lists the ${results.length} that published. This exits non-zero.`);
301
314
  process.exitCode = 1;
package/bin/onboard.mjs CHANGED
@@ -116,7 +116,7 @@ import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-p
116
116
  // one synopsis reader for every verb that prints one.
117
117
  import { usageBlock } from "../shared/usage-block.mjs";
118
118
  import { invoke } from "../shared/invocation.mjs"; // — name a command the reader can actually type
119
- import { parseEnvFile } from "../driver/systemd/render-units.mjs"; // — ONE KEY=value reader; a second copy would drift from what systemd actually reads
119
+ import { parseEnvFile } from "../shared/env-file-merge.mjs"; // — ONE KEY=value reader, taken from a leaf: the unit renderer re-exports this same function, and it is a COMMAND, so importing it from here would put a command in this binary's graph
120
120
  import { unitEnvironment, unitValue, couldNotDetermine } from "../driver/unit-environment.mjs"; // — F34: claim about the UNITS only from the units' own environment
121
121
 
122
122
  /**
@@ -2579,8 +2579,14 @@ export async function runCheck() {
2579
2579
  // returns null when the header is absent — a looked-and-none answer, not a did-not-look — and
2580
2580
  // the readers separate those, so a probe that omits the field reads as never-looked rather
2581
2581
  // than silently as "no challenge".
2582
+ // AND SO DOES THE REFUSAL'S OWN SENTENCE, for the same reason one line up: absence and
2583
+ // did-not-look are different answers. A proxy-fronted door and a key door both refuse with 401
2584
+ // and no challenge header, so the body is what separates them — doctor must read it too, or it
2585
+ // would answer this question differently from the portal off the same shared verdict.
2586
+ let body = null;
2587
+ if (res.status === 401) { try { body = (await res.text()).slice(0, 400); } catch { body = null; } }
2582
2588
  probe = { ok: res.status < 500, status: res.status, error: null,
2583
- challenge: res.headers.get("www-authenticate") };
2589
+ challenge: res.headers.get("www-authenticate"), body };
2584
2590
  } catch (e) { probe = { ok: false, status: null, error: String(e?.cause?.code ?? e?.name ?? e?.message ?? e) }; }
2585
2591
  }
2586
2592
  // THE PREFIX TRAVELS WITH THE MESSAGE. Doctor's own guard runs every command
package/bin/start.mjs CHANGED
@@ -1435,8 +1435,11 @@ if (isMain) {
1435
1435
  // SEEDED FROM A COPY, for the reason the player publishes from one: republishing writes a receipt
1436
1436
  // into the run directory it reads, and `demo/` is tracked. This is the path a reader actually takes
1437
1437
  // — `clearotron demo` hands over to this — so fixing the player alone left the defect where it was.
1438
- const { publishSource, seedDemoRuns } = await import("../driver/demo-container.mjs");
1439
- const seed = await seedPool({ pool: paths.pool, examplesDir: publishSource(join(REPO, "demo"), { repoRoot: REPO }), republish: republishRun });
1438
+ // ONE SAMPLE AT A TIME: one whose files cannot be read is left out and named below, and the others
1439
+ // seed. Copied in one call, a single unreadable file emptied the whole archive.
1440
+ const { publishContainer, seedDemoRuns } = await import("../driver/demo-container.mjs");
1441
+ const container = publishContainer(join(REPO, "demo"), { repoRoot: REPO });
1442
+ const seed = await seedPool({ pool: paths.pool, examplesDir: container.dir, republish: republishRun });
1440
1443
  // AND AS RUNS, so the assistant this demo's connect line wires has them to list, brief and open. Under
1441
1444
  // the demo's own workspace only: nothing of it reaches an install started afterwards. Their report
1442
1445
  // links are stamped with this portal's address, the one the Open line prints.
@@ -1461,10 +1464,16 @@ if (isMain) {
1461
1464
  }
1462
1465
  // Never a silent nothing. "The archive is empty" and "the archive is empty and nobody noticed why"
1463
1466
  // look identical in the browser, so both other outcomes are said out loud.
1464
- if (seed.skipped) say(` archive ${seed.skipped}`);
1467
+ // A SAMPLE LEFT OUT IS NAMED ONCE, by the first copy that could not take it (its report or its run).
1468
+ const leftOut = new Map();
1469
+ for (const u of [...container.unusable, ...runs.failed]) if (!leftOut.has(u.name)) leftOut.set(u.name, u.why);
1470
+ // The seeder counts "every example this package ships" off the copy it was handed, so with a sample
1471
+ // left out that sentence would name the copy's count as the package's; the warnings below say instead.
1472
+ if (seed.skipped && !leftOut.size) say(` archive ${seed.skipped}`);
1465
1473
  for (const p of seed.problems) err(` WARNING: sample seeding — ${p}`);
1474
+ for (const [name, why] of leftOut) err(` WARNING: demo sample ${name} left out — ${why}`);
1466
1475
  } catch (e) {
1467
- err(` WARNING: the example report could not be seeded (${String(e?.message ?? e)}) — the archive will come up empty. Everything else works; \`npm run example\` shows a sample without touching this install.`);
1476
+ err(` WARNING: the example report could not be seeded (${String(e?.message ?? e)}) — the archive will come up empty. Everything else works; \`${invoke("demo")}\` shows a sample without touching this install.`);
1468
1477
  }
1469
1478
 
1470
1479
  let roster = [];
package/bin/status.mjs CHANGED
@@ -15,7 +15,7 @@ import { join } from "node:path";
15
15
  import { homedir } from "node:os";
16
16
  import { execFileSync } from "node:child_process";
17
17
  import { BACKGROUND_UNITS, resolvePorts } from "./start.mjs";
18
- import { parseEnvFile } from "../driver/systemd/render-units.mjs"; // ONE KEY=value reader — what systemd actually reads
18
+ import { parseEnvFile } from "../shared/env-file-merge.mjs"; // ONE KEY=value reader — what systemd actually reads, taken from the leaf the unit renderer re-exports it from
19
19
  import { CLIENT_DOOR_UNIT, clientDoorPort } from "../shared/client-door.mjs";
20
20
  import { invoke } from "../shared/invocation.mjs";
21
21
  import { readRunning, probe } from "../shared/running-start.mjs";
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "8f26558701f6930799f7d8f724ab2ea91e302103",
3
- "version": "0.3.0-beta.9"
2
+ "commit": "ee3ad6c3a561265588b8db72f153e3eb6694d9fe",
3
+ "version": "0.3.1-beta.0"
4
4
  }
package/docs/DELIVERY.md CHANGED
@@ -136,8 +136,26 @@ drives the whole delivery side over the ops MCP face — mint its token verb-sco
136
136
  `sendPending`, clears the marker — idempotent). **A settle is a receipt, not an intention:** with
137
137
  neither `messageId` nor an `attestation` it REFUSES, and if the send was blocked or
138
138
  failed you do not call it at all — the marker stays and the send stays owed.
139
- 3. For every other kind: route the packet's ready-made `text` **`ack_event(file)`** (idempotent;
140
- validated as a bare `*.pending` name).
139
+ 3. For `run-failed`: the requester is **owed** this notice exactly as they are owed a report. Route the
140
+ packet's ready-made `text`, then settle it with **`mark_sent`**, not `ack_event`. A failed run carries
141
+ `sendPending` until a send is confirmed, whichever lane wrote the packet, and `mark_sent` is the only
142
+ thing that clears it. Acknowledging the event instead removes the marker and leaves the run owed, so
143
+ the backstop scan re-arms it and the requester is told again on the next sweep.
144
+ 4. For the remaining kinds — `intake-rejected`, `duplicate-skipped`, `late-bind-ack` — route the
145
+ packet's `text` → **`ack_event(file)`** (idempotent; validated as a bare `*.pending` name). These
146
+ describe something that did not become a run, so there is no run to settle.
147
+
148
+ **Poll at any hour.** Owed work does not keep office hours: a run that fails at 03:00 owes its requester
149
+ a notice at 03:00. Ask the door directly with **`list_runs({ sendPending: true })`** — the no-filesystem
150
+ equivalent of the backstop scan below, returning every run still owed a send, live or archived, with no
151
+ cap. An integrator that only runs during the day leaves a failure unreported until it next wakes, and
152
+ nothing in the product can compensate for that: the product composes the notice and records that it is
153
+ owed, and sending is yours.
154
+
155
+ **Two things that make that list incomplete, both silent.** A token scoped to named accounts sees only
156
+ those accounts' runs, so a run for an account the token does not carry is invisible rather than absent —
157
+ mint the integrator's token to cover every account it delivers for, and re-mint it when one is added.
158
+ And a `limit` you pass yourself is obeyed as given: for this query, do not pass one.
141
159
 
142
160
  The filesystem loop below remains equivalent for integrators that do have data-plane access.
143
161
 
package/docs/RELEASES.md CHANGED
@@ -54,5 +54,5 @@ Downgrading is an ordinary install of the older version. Your configuration and
54
54
  by either — they live outside the package — so moving between channels is not a migration.
55
55
 
56
56
  Every version's notes are on the [releases page](https://github.com/CordilleraSarl/clearotron/releases)
57
- and in `CHANGELOG.md`, grouped New / Fixed / For operators. A beta's notes are the same notes; the stable
57
+ and in `CHANGELOG.md`, grouped Before you upgrade / New / Fixed / For operators. A beta's notes are the same notes; the stable
58
58
  that follows aggregates all of them into one entry.
@@ -1,5 +1,49 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.3.1-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - a2c4b94: Fixed: A correction to a coverage note or an action is now applied, or the run records why it was not.
8
+ - b320c65: Fixed: A demo sample that cannot be read is named in the demo's output, and the other demos still publish.
9
+ - 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.
10
+ - ae2cc82: For operators: an assistant asking which searches still owe someone a notice now gets all of them, not just the fifty most recent. Asking for recent searches is unchanged.
11
+ - e390417: For operators: The portal's start-up check now says when its engine address is behind a sign-in it cannot pass, instead of reporting that address as reachable.
12
+
13
+ ## 0.3.0
14
+
15
+ ### Minor Changes
16
+
17
+ - d2afa5e: Before you upgrade: Name every person who uses your installation in the file that lists who may sign in, or the portal will not start. That file is in your install folder.
18
+ - d2afa5e: New: Switch between companies from any screen, and set a new one up in the browser.
19
+ - d2afa5e: New: Add people from the portal, and give each the right to run clearances, to manage people and companies, or both.
20
+ - d2afa5e: New: Every register finding in a report links to the trade mark office's own page for that record.
21
+ - d2afa5e: New: A clearance records any territory it could not cover, so a partial search never reads as a complete one.
22
+ - d2afa5e: New: Connect Clearotron to your own AI assistant — Claude Code, Codex, or ChatGPT — on your computer or a shared server.
23
+ - d2afa5e: New: A saved search is available in every company, in the portal and in your AI assistant.
24
+ - d2afa5e: New: Check a risk framework for errors before it rates a clearance, and point a company at its own.
25
+ - d2afa5e: New: Group companies under an organisation, each with its own daily limit on clearances.
26
+ - d2afa5e: New: Clearotron installs to a fixed location, so the commands it prints and your AI assistant's connection keep working.
27
+ - d2afa5e: New: `clearotron update` moves an installation, beta included, to the current release.
28
+
29
+ ### Patch Changes
30
+
31
+ - d2afa5e: Fixed: You sign in, open your companies, and run your first clearance on a new installation.
32
+ - d2afa5e: Fixed: The demo reads and writes only its own companies, reports, and saved searches.
33
+ - d2afa5e: Fixed: `clearotron doctor` checks what a clearance needs, so an installation it clears can run one.
34
+ - d2afa5e: Fixed: A key you paste at a yes-or-no question stays off the screen and out of your command history.
35
+ - d2afa5e: Fixed: Clearotron stays signed in to a paid Codex plan when Codex renews the sign-in.
36
+ - d2afa5e: Fixed: The dashboard shows only the chosen company's clearances, and keeps the filters on screen.
37
+ - d2afa5e: Fixed: The Stop button ends a run before its report goes out, and its dialog says what stopping does.
38
+ - d2afa5e: Fixed: A knockout report names the filings behind each finding, and marks the reviewer's notes as reference.
39
+
40
+ ## 0.3.0-beta.10
41
+
42
+ ### Patch Changes
43
+
44
+ - fd3a4f7: Fixed: A portal address that does not exist now says so wherever it is. Addresses under the admin path used to show the Global config screen.
45
+ - fd3a4f7: New: The install guide now says how to remove Clearotron, naming every path it writes and which one holds your reports.
46
+
3
47
  ## 0.3.0-beta.9
4
48
 
5
49
  ### Patch Changes
@@ -0,0 +1,66 @@
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
+ // band-size.mjs — how big the register band was when a stage was dispatched against it.
5
+ //
6
+ // WHY A BUDGET NEEDS THIS BESIDE IT. Two judgment stages carry hand-set walls — 2700s and 2400s — and
7
+ // each was raised after a kill on one crowded matter. The numbers are in the stage table with their
8
+ // post-mortems, and not one of them records the BAND SIZE it was sized against, so the next person
9
+ // asking "is 2400 enough" has no denominator and re-derives it from an archived run by hand. That
10
+ // happened; it is why this exists.
11
+ //
12
+ // IT READS THE SHAPE, NOT THE BAND. `band-shape.json` is derived once per run and already carries the
13
+ // record and crowd totals, so the cost here is one small read rather than parsing the merged band on
14
+ // every dispatch. The band's own file supplies bytes, which is a stat.
15
+ //
16
+ // AN ABSENT BAND IS A NAMED REASON, NEVER A ZERO. A register-only run with nothing retrieved, a matter
17
+ // with no Nice classes (which compiles no register plan at all), and a replay of an archived run that
18
+ // predates the shape all reach a dispatch with no band on disk. Written as `0` those become a
19
+ // measurement saying the band was empty, which is a different and false claim — and every rollup that
20
+ // averages this field would silently take it. The same distinction `toolWaitByTool` draws: an object is
21
+ // a measurement, and the absence says which absence it is.
22
+ import { existsSync, readFileSync, statSync } from "node:fs";
23
+ import { BAND_READING_STAGES } from "./stages.mjs";
24
+
25
+ /**
26
+ * The band this dispatch is about to be judged against, as a measurement or a named absence.
27
+ *
28
+ * @param {{bandShape: string, registerNamedBand: string}} paths the run's own paths (`ctx.paths`)
29
+ * @param {object} [io] injectable for tests — the real fs by default
30
+ * @returns {{records:number, crowds:number, bytes:number}|{absent:string}}
31
+ */
32
+ export function bandSizeAtDispatch(paths, {
33
+ exists = existsSync, read = readFileSync, stat = statSync,
34
+ } = {}) {
35
+ const shapePath = paths?.bandShape;
36
+ const bandPath = paths?.registerNamedBand;
37
+ if (!shapePath || !bandPath) return { absent: "no run paths" };
38
+ if (!exists(bandPath)) return { absent: "no merged register band on disk" };
39
+ // Bytes first: it is a stat, it cannot fail the way a parse can, and a band whose shape has not been
40
+ // derived yet is still a band whose size is worth recording.
41
+ let bytes = null;
42
+ try { bytes = stat(bandPath).size; } catch (e) { return { absent: `band unstatable (${e?.code ?? "unknown"})` }; }
43
+ if (!exists(shapePath)) return { absent: "no band shape derived yet", bytes };
44
+ let shape;
45
+ try { shape = JSON.parse(read(shapePath, "utf8")); }
46
+ catch (e) { return { absent: `band shape unreadable (${e?.code ?? "parse"})`, bytes }; }
47
+ const t = shape?.totals;
48
+ // A shape whose totals are not numbers is not a measurement of anything — say so rather than coercing.
49
+ if (!t || !Number.isFinite(t.records) || !Number.isFinite(t.crowds)) {
50
+ return { absent: "band shape carries no totals", bytes };
51
+ }
52
+ return { records: t.records, crowds: t.crowds, bytes };
53
+ }
54
+
55
+ /**
56
+ * The band size to record for `stage`, or undefined for a stage that does not read the band.
57
+ *
58
+ * THE DECISION LIVES HERE RATHER THAN AT THE CALL SITE, and that is the lesson of a plant that did not
59
+ * red: with the branch written inline in the dispatch, disabling the measurement outright changed
60
+ * nothing any test could see. A stage outside the set gets `undefined` and not a named absence, because
61
+ * "this stage does not read the band" is not a fact about the band.
62
+ */
63
+ export function bandSizeForStage(stage, paths, io) {
64
+ if (!BAND_READING_STAGES.has(stage)) return undefined;
65
+ return bandSizeAtDispatch(paths, io);
66
+ }
@@ -55,6 +55,100 @@ export function targetsOf(flagText, known) {
55
55
  return hits;
56
56
  }
57
57
 
58
+ /**
59
+ * The identity a non-finding line keeps across a rewrite, per register. One implementation, because the
60
+ * observation below and the removal backstop in pipeline.mjs must agree on which row is which. PURE.
61
+ */
62
+ export const REPORT_LINE_KEY = Object.freeze({
63
+ coverage: (c) => `coverage:${norm(c?.area)}`,
64
+ actions: (a) => `action:${a?.id ?? a?.kind ?? ""}`,
65
+ });
66
+
67
+ /**
68
+ * The reader's name for such a row. One implementation, because three surfaces print it: the plain-words
69
+ * pre-check that hands the reviewer its labels, the observation below, and the driver's own account of
70
+ * what it restored — and a reviewer comparing two of those must not be reading two vocabularies. PURE.
71
+ */
72
+ export const REPORT_LINE_LABEL = Object.freeze({
73
+ coverage: (c) => `the coverage line for "${c?.area ?? "an area"}"`,
74
+ actions: (a) => `the action "${a?.id ?? a?.kind ?? ""}"`.replace(/ ""$/, ""),
75
+ });
76
+
77
+ /**
78
+ * THE LINES A CLEARANCE READER MEETS FIRST THAT ARE NOT A FINDING — each coverage row's note, each
79
+ * action's text and the mark assessment's two reads — with the identity each keeps across a rewrite.
80
+ *
81
+ * A finding carries an ordinal and a name, and a flag joins to it by either. These lines carry neither,
82
+ * so a flag rewriting one came out `not-entity-scoped` whatever the corrective pass did with it: seven
83
+ * such flags survived one clearance on the test box, and a production matter showed the same split
84
+ * (2026-09-11). The label is the one the plain-words pre-check hands the reviewer, so a flag repeating it
85
+ * joins on the driver's own words.
86
+ *
87
+ * Reads the parsed record (`markAssessment`) and the raw one (`mark_assessment`) alike. PURE.
88
+ */
89
+ export function reportLines(doc) {
90
+ const out = [];
91
+ const add = (key, label, v) => {
92
+ const text = typeof v === "string" ? v : (typeof v?.read === "string" ? v.read : "");
93
+ if (text.trim()) out.push({ key, label, text });
94
+ };
95
+ for (const c of Array.isArray(doc?.coverage) ? doc.coverage : [])
96
+ add(REPORT_LINE_KEY.coverage(c), REPORT_LINE_LABEL.coverage(c), c?.note);
97
+ for (const a of Array.isArray(doc?.actions) ? doc.actions : [])
98
+ add(REPORT_LINE_KEY.actions(a), REPORT_LINE_LABEL.actions(a), a?.text);
99
+ const ma = doc?.markAssessment ?? doc?.mark_assessment;
100
+ for (const k of ["distinctiveness", "connotation"]) add(`mark-assessment:${k}`, `the mark assessment's ${k}`, ma?.[k]);
101
+ return out;
102
+ }
103
+
104
+ const QUOTE_WORDS = 8;
105
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
106
+
107
+ /** A flag that carries the line's identity: its label, or the plain ways of saying it. PURE. */
108
+ function namesLine(hay, l) {
109
+ const at = l.key.indexOf(":");
110
+ const kind = l.key.slice(0, at), id = norm(l.key.slice(at + 1));
111
+ if (!id) return false;
112
+ if (kind === "coverage") return new RegExp(` coverage (?:line|note|row|entry) (?:for |on |about )?(?:the )?${escapeRe(id)} `).test(hay);
113
+ if (kind === "action") return hay.includes(` action ${id} `);
114
+ return new RegExp(` mark assessment (?:s )?(?:[a-z]+ )?${escapeRe(id)} `).test(hay);
115
+ }
116
+
117
+ /** A flag that quotes the line: eight running words of it, or all of a shorter one of four or more. PURE. */
118
+ function quotesLine(hay, text) {
119
+ const words = norm(text).split(" ").filter(Boolean);
120
+ if (words.length < QUOTE_WORDS) return words.length >= 4 && hay.includes(` ${words.join(" ")} `);
121
+ for (let i = 0; i + QUOTE_WORDS <= words.length; i++)
122
+ if (hay.includes(` ${words.slice(i, i + QUOTE_WORDS).join(" ")} `)) return true;
123
+ return false;
124
+ }
125
+
126
+ /**
127
+ * Which of `lines` a flag is about — named by its label, or quoted, which is what the reviewer is told
128
+ * to do with a sentence it rewrites.
129
+ *
130
+ * THIS OBSERVES; IT APPLIES NOTHING. Matching prose is how a rewrite lands on the wrong thing, which is
131
+ * why no correction is ever routed by it. Here a miss leaves the row where it was, `not-entity-scoped`,
132
+ * and a hit prints the line's label beside the flag, where the reviewer reading the table can see it. PURE.
133
+ */
134
+ export function linesOf(flagText, lines) {
135
+ const hay = ` ${norm(flagText)} `;
136
+ return (lines ?? []).filter((l) => namesLine(hay, l) || quotesLine(hay, l.text));
137
+ }
138
+
139
+ /** The corrective pass's own words about a line it was flagged on, from the `corrections` register. PURE. */
140
+ function reasonFor(hit, doc) {
141
+ const c = doc?.corrections;
142
+ const said = [
143
+ ...(Array.isArray(c?.entries) ? c.entries.map((e) => [e?.entity, e?.disposition, e?.note].filter(Boolean).join(": ")) : []),
144
+ ...String(c?.note ?? "").split(/\n|;\s+/),
145
+ ].map((s) => s.trim()).filter(Boolean);
146
+ const hitSays = said.find((s) => linesOf(s, hit).length);
147
+ return hitSays ? hitSays.slice(0, 300) : null;
148
+ }
149
+
150
+ const squash = (s) => String(s ?? "").replace(/\s+/g, " ").trim();
151
+
58
152
  /** A finding's comparable state — the fields a correction can move. PURE. */
59
153
  const stateOf = (f) => JSON.stringify({
60
154
  disposition: f?.disposition ?? null,
@@ -82,7 +176,10 @@ function byName(doc) {
82
176
  * findings-removed — a finding this flag names was present before the pass and is GONE after it;
83
177
  * findings-changed — at least one finding this flag names has a different comparable state;
84
178
  * findings-unchanged — it named findings and none of them moved;
85
- * not-entity-scoped the flag names no finding (a prose/structure correction);
179
+ * line-removed a non-finding line this flag names or quotes (`reportLines`) is GONE after it;
180
+ * line-changed — that line's text is different after the pass;
181
+ * line-unchanged — it reads the same; the row carries the pass's reason, or null for none;
182
+ * not-entity-scoped — the flag names no finding and no such line (a prose/structure correction);
86
183
  * not-checkable — the pre-corrective snapshot is missing, so nothing can be compared.
87
184
  *
88
185
  * "findings-unchanged" is NOT a failure and must never be rendered as one: correcting a narrative
@@ -162,6 +259,7 @@ export function buildCorrectionsApplied(rows, preDoc, postDoc) {
162
259
  const known = knownEntities(preDoc, postDoc);
163
260
  const pre = byName(preDoc), post = byName(postDoc);
164
261
  const preOrd = byOrdinal(preDoc), postOrd = byOrdinal(postDoc);
262
+ const preLines = reportLines(preDoc), postLine = new Map(reportLines(postDoc).map((l) => [l.key, l]));
165
263
  return (rows ?? []).map((r) => {
166
264
  // — a DECLARED ordinal wins over the name match. `targetsOf` is a normalised prose join and it
167
265
  // is why six of nine flags on a delivered run resolved to nothing; the declaration is the reviewer's
@@ -185,10 +283,22 @@ export function buildCorrectionsApplied(rows, preDoc, postDoc) {
185
283
  const declaredOrds = (Array.isArray(r.ordinals) ? r.ordinals : [])
186
284
  .filter((o) => preOrd.has(o) || postOrd.has(o));
187
285
  const label = (o) => { const f = preOrd.get(o) ?? postOrd.get(o); return f?.mark ?? f?.owner?.name ?? `finding ${o}`; };
188
- const targets = declaredOrds.length ? declaredOrds.map(label) : targetsOf(r.text, known);
286
+ // A LINE THAT IS NOT A FINDING, asked before the name match. A flag that names or quotes a coverage
287
+ // note, an action or the mark assessment is about that line, and a mark or owner named inside the
288
+ // line it quotes is incidental. Asked only where no ordinal was declared: a declaration still wins.
289
+ const hit = preDoc && !declaredOrds.length ? linesOf(r.text, preLines) : [];
290
+ const targets = declaredOrds.length ? declaredOrds.map(label)
291
+ : hit.length ? hit.map((l) => l.label) : targetsOf(r.text, known);
189
292
  let outcome;
190
293
  let removed = [];
191
294
  if (!preDoc) outcome = "not-checkable";
295
+ else if (hit.length) {
296
+ // The same three answers a finding gets, asked of the line's own text. REMOVAL WINS, as it does
297
+ // for a finding: a line gone after the pass was answered by deletion, and that is the question.
298
+ const gone = hit.filter((l) => !postLine.has(l.key));
299
+ if (gone.length) { outcome = "line-removed"; removed = gone.map((l) => l.label); }
300
+ else outcome = hit.some((l) => squash(postLine.get(l.key).text) !== squash(l.text)) ? "line-changed" : "line-unchanged";
301
+ }
192
302
  else if (declaredOrds.length) {
193
303
  // The certain path. Every question below is asked of the finding the reviewer NAMED, by ordinal.
194
304
  removed = declaredOrds.filter((o) => preOrd.has(o) && !postOrd.has(o)).map(label);
@@ -229,7 +339,10 @@ export function buildCorrectionsApplied(rows, preDoc, postDoc) {
229
339
  // unrecoverable; one field makes it answerable from the next run on. It also feeds the report's
230
340
  // open-points section, which prints `(finding N)` beside a point and had no ordinals to print.
231
341
  return { n: r.n, kind: r.kind, typed: r.typed, text: r.text,
232
- ordinals: Array.isArray(r.ordinals) ? r.ordinals : null, targets, outcome, removed };
342
+ ordinals: Array.isArray(r.ordinals) ? r.ordinals : null, targets, outcome, removed,
343
+ // A line's row carries the pass's own reason for it, or null when the pass gave none: "declined
344
+ // with a reason" is then a fact the run recorded, and "neither applied nor declined" is too.
345
+ ...(hit.length && preDoc ? { reason: reasonFor(hit, postDoc) } : {}) };
233
346
  });
234
347
  }
235
348
 
@@ -256,9 +369,12 @@ export function buildCorrectionsApplied(rows, preDoc, postDoc) {
256
369
  * belongs on the printed side. Deciding it here rather than at the report keeps one implementation of a
257
370
  * question this file already answered once.
258
371
  *
372
+ * `line-changed` is the same fact about a line that is not a finding, so it is resolved too; its
373
+ * `line-removed` and `line-unchanged` stay printed for the reasons their finding twins do.
374
+ *
259
375
  * PURE.
260
376
  */
261
- export const RESOLVED_OUTCOMES = Object.freeze(["findings-changed"]);
377
+ export const RESOLVED_OUTCOMES = Object.freeze(["findings-changed", "line-changed"]);
262
378
 
263
379
  /** The rows to print. Anything not positively resolved, including a row of an outcome nobody has met. */
264
380
  export const unresolvedFlags = (rows) =>
@@ -307,8 +423,9 @@ export function correctionsAppliedTable(applied) {
307
423
  for (const r of applied) {
308
424
  // — a removal NAMES what left. `findings-removed` alone would tell the recheck that something
309
425
  // was deleted and not which fact, which is the half it needs to decide whether the deletion was legitimate.
310
- const what = r.outcome === "findings-removed" && r.removed?.length
426
+ const what = (r.outcome === "findings-removed" || r.outcome === "line-removed") && r.removed?.length
311
427
  ? `${r.outcome}: ${r.removed.join(", ")}`
428
+ : r.reason ? `${r.outcome} — the pass said: ${r.reason.slice(0, 120)}${r.reason.length > 120 ? "…" : ""}`
312
429
  : r.outcome;
313
430
  out.push(`| ${r.n} | ${r.kind} | ${r.text.slice(0, 90)}${r.text.length > 90 ? "…" : ""} | ${r.targets.join(", ") || "—"} | ${what} |`);
314
431
  }
@@ -322,5 +439,11 @@ export function correctionsAppliedTable(applied) {
322
439
  + "so it reads as resolved while the report is no truer than before. Check each named finding: a "
323
440
  + "withdrawal the evidence supports is legitimate and belongs in the record AS a withdrawal with its "
324
441
  + "reason; a fact removed because it was inconvenient to correct is the defect this row exists to show you.");
442
+ if (applied.some((r) => String(r.outcome).startsWith("line-")))
443
+ out.push("",
444
+ "`line-*` rows compare a line the reader sees that is not a finding — a coverage note, an action, the mark "
445
+ + "assessment — as the flag named or quoted it, before and after the pass. A `line-unchanged` row with no "
446
+ + "reason beside it is a flag the pass neither applied nor declined; `line-removed` means the line is gone "
447
+ + "rather than rewritten.");
325
448
  return out.join("\n");
326
449
  }