clearotron 0.3.2-beta.11 → 0.3.2-beta.12

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 (46) hide show
  1. package/.env.example +1 -1
  2. package/bin/clearotron.mjs +14 -0
  3. package/bin/connect.mjs +68 -3
  4. package/bin/key.mjs +6 -1
  5. package/bin/onboard.mjs +7 -1
  6. package/bin/passphrase.mjs +4 -2
  7. package/bin/start.mjs +12 -3
  8. package/build-info.json +2 -2
  9. package/driver/CHANGELOG.md +16 -0
  10. package/driver/contract-vocabulary.mjs +5 -5
  11. package/driver/engine/mcp/recording-server.mjs +1 -1
  12. package/driver/engine/mcp/supplemental.mjs +22 -5
  13. package/driver/knockout-next-step.mjs +72 -0
  14. package/driver/named-band.mjs +1 -1
  15. package/driver/package.json +1 -1
  16. package/driver/pipeline-knockout.mjs +26 -0
  17. package/driver/portal-report.mjs +21 -2
  18. package/driver/portal-service.mjs +8 -8
  19. package/driver/publish/render-knockout.mjs +14 -9
  20. package/driver/register-availability.mjs +1 -1
  21. package/driver/register-plan.mjs +81 -11
  22. package/driver/result-noun-fields.mjs +3 -1
  23. package/driver/stages-knockout.mjs +1 -1
  24. package/driver/suite-census.json +47 -17
  25. package/driver/verify-knockout.mjs +0 -27
  26. package/mcp-server/CHANGELOG.md +4 -0
  27. package/mcp-server/package.json +1 -1
  28. package/package.json +1 -1
  29. package/portal-ui/dist/assets/{index-CtvwLCti.css → index-7Lq-dXDV.css} +12 -9
  30. package/portal-ui/dist/assets/{index-DXSRxPV_.js → index-w8GFZftk.js} +110 -69
  31. package/portal-ui/dist/index.html +2 -2
  32. package/portal-ui/package.json +1 -1
  33. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  34. package/providers/oauth-mcp-bridge/package.json +1 -1
  35. package/scripts/ask-ai-render-check.mjs +26 -1
  36. package/scripts/dead-names.mjs +17 -16
  37. package/scripts/env-audit.mjs +6 -0
  38. package/scripts/repo-writes.mjs +42 -0
  39. package/scripts/report-sections-render-check.mjs +245 -0
  40. package/scripts/report-theme-render-check.mjs +31 -3
  41. package/scripts/settings-render-check.mjs +10 -7
  42. package/scripts/test-run.mjs +63 -4
  43. package/shared/client-door.mjs +20 -0
  44. package/shared/parent-watch.mjs +33 -0
  45. package/shared/running-start.mjs +14 -3
  46. package/shared/scope.mjs +22 -3
package/.env.example CHANGED
@@ -578,7 +578,7 @@ TRADEMARK_MCP_KEY_SOCKET=
578
578
  # names, read by code that ships.
579
579
 
580
580
  # WHO the chat notice is addressed to: a JSON object of requester email or handle → number, e.g.
581
- # {"lisa@tenant.example":"+41...","jordan":"+41..."}. Before this, the notice went to a map keyed by
581
+ # {"robin@tenant.example":"+41...","jordan":"+41..."}. Before this, the notice went to a map keyed by
582
582
  # AGENT id, and every user of a deployment shares one agent — so the operator was notified about work
583
583
  # other people ordered and the requester was told nothing.
584
584
  #
@@ -24,6 +24,7 @@ import { constants as SIG } from "node:os";
24
24
  import { isEntrypoint } from "../shared/is-entrypoint.mjs";
25
25
  import { nodeFloorVerdict, nodeFloorRefusal } from "../shared/node-floor.mjs"; // — one floor, read from package.json
26
26
  import { invocationPrefix } from "../shared/invocation.mjs"; // — print a command the reader can type
27
+ import { watchParent } from "../shared/parent-watch.mjs";
27
28
 
28
29
  export const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
29
30
 
@@ -229,6 +230,19 @@ const [verb, ...rest] = process.argv.slice(2);
229
230
  };
230
231
  process.on(sig, forward);
231
232
  }
233
+
234
+ // ── AND THE DEMO STOPS WHEN WHATEVER STARTED IT IS GONE ─────────────────────────────────────────────
235
+ //
236
+ // The forwarding above covers a signal sent to THIS pid. Through npx it is not this pid a reader holds:
237
+ // npm → `sh -c` → this launcher, and a TERM to npm dies at that `sh` (shared/parent-watch.mjs). This
238
+ // launcher is then reparented, and the demo runs on holding its three ports while the reader believes
239
+ // it stopped. So the demo takes its parent's death as a TERM, and stops everything it started.
240
+ //
241
+ // THE DEMO ONLY. A foreground `start` or a `run` outliving the shell that started it can be what the
242
+ // reader meant: `nohup clearotron start &` on a server they are about to log out of, or an hours-long
243
+ // clearance they will not babysit. The demo is a replay nobody leaves running on purpose, and its own
244
+ // banner already says it lasts only as long as the command that started it.
245
+ if (verb === "demo") watchParent(() => { try { child.kill("SIGTERM"); } catch { /* already gone */ } });
232
246
  }
233
247
 
234
248
  // RESOLVE BOTH SIDES THROUGH SYMLINKS. `npm install` puts a symlink at node_modules/.bin/clearotron, so
package/bin/connect.mjs CHANGED
@@ -40,7 +40,7 @@ import { createInterface } from "node:readline/promises";
40
40
  import { requireInteractive } from "../shared/invocation.mjs"; // — a prompt with nobody to answer it
41
41
  import { stdin, stdout } from "node:process";
42
42
  import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from "node:fs";
43
- import { join, dirname } from "node:path";
43
+ import { join, dirname, resolve } from "node:path";
44
44
  import { homedir, userInfo } from "node:os";
45
45
  import { fileURLToPath } from "node:url";
46
46
  import { execFileSync } from "node:child_process";
@@ -48,7 +48,11 @@ import { createServer } from "node:net";
48
48
  import { CONNECT_CLIENTS, WHERE_FLAG, clientById, leadRouteFor, plainStep, whatItNeeds } from "../shared/connect-clients.mjs";
49
49
  import { stdioConnectFor, STDIO_SHAPES } from "../shared/stdio-connect.mjs";
50
50
  import { isWsl, wslTarget } from "../shared/wsl.mjs"; // — and which distribution a row should start the server in
51
- import { defaultDenylistPath, clientDoorAddress, clientDoorPort, clientDoorState, enablePlan, applyEnablePlan, describeChange, recordConnectKey, CLIENT_DOOR_UNIT } from "../shared/client-door.mjs";
51
+ import { defaultDenylistPath, clientDoorAddress, clientDoorPort, clientDoorState, enablePlan, applyEnablePlan, describeChange, recordConnectKey, CLIENT_DOOR_UNIT, demoTokenSecretPath, keyPurposeLine, demoConnectCommand } from "../shared/client-door.mjs";
52
+ import { readRunning } from "../shared/running-start.mjs";
53
+ import { readLocalCredential, passphraseResetCommand, INSTALL_CREDENTIAL_FILE } from "../driver/portal-local-auth.mjs";
54
+ import { invocationPrefix } from "../shared/invocation.mjs";
55
+ import { BRAND } from "../shared/brand.mjs";
52
56
  import { mintToken, tokenId, resolvePerson, loadGrants } from "../shared/scope.mjs";
53
57
  import { envFrom } from "../shared/env-aliases.mjs";
54
58
  import { atomicWrite } from "../driver/progress.mjs";
@@ -654,6 +658,59 @@ function printSteps(offer, key) {
654
658
  }
655
659
  }
656
660
 
661
+ /**
662
+ * ── A RUNNING DEMO, NAMED BY ITS FOLDER (owner, 2026-09-19) ─────────────────────────────────────────
663
+ *
664
+ * The demo is how a newcomer meets the product, and the next thing they ask an assistant is to connect.
665
+ * The route that works is the demo's own client door with an account key, and reaching it took a key
666
+ * issued by hand and an address read out of the startup log. This does both: it finds the demo that is
667
+ * running from that folder, mints a key with the demo's own secret for the demo's one user, and prints
668
+ * the address and the key, and nothing else is changed. No unit is written and no setting touched: the
669
+ * demo's door is already open, and everything of the demo goes when it stops.
670
+ *
671
+ * The address route's steps are not printed. They are written for a door behind a sign-in service,
672
+ * reached over the web, and a demo's door is on this machine and takes the key instead.
673
+ */
674
+ async function connectDemo({ base, dryRun, clientName = null, running = readRunning() } = {}) {
675
+ const at = resolve(base);
676
+ const prefix = invocationPrefix();
677
+ say("");
678
+ if (clientName) { say(` ${clientName}`); say(""); }
679
+ const demo = running.find((r) => r.demo && resolve(String(r.base ?? "")) === at);
680
+ if (!demo) {
681
+ say(` Not available here — no demo is running from ${at}.`);
682
+ say(` What would change it: start it with \`${prefix}clearotron demo --base ${/\s/.test(at) ? `"${at}"` : at}\`, then run this again.`);
683
+ return 1;
684
+ }
685
+ if (!demo.ports?.client) {
686
+ say(` Not available here — the demo running from ${at} has no client door open.`);
687
+ say(" What would change it: stop the demo and start it again; its output says why the door did not start.");
688
+ return 1;
689
+ }
690
+ const address = `http://${!demo.host || demo.host === "0.0.0.0" ? "127.0.0.1" : demo.host}:${demo.ports.client}/mcp`;
691
+ const credentialPath = join(at, INSTALL_CREDENTIAL_FILE);
692
+ let email = "demo@localhost";
693
+ try { email = readLocalCredential(credentialPath)?.email || email; } catch { /* the demo's own user stands */ }
694
+ if (dryRun) {
695
+ say(" (dry run — nothing was changed)");
696
+ say(` Address: ${address}`);
697
+ return 0;
698
+ }
699
+ let secret = "";
700
+ try { secret = readFileSync(demoTokenSecretPath(at), "utf8").trim(); } catch { /* said below */ }
701
+ if (!secret) {
702
+ say(` Not available here — the demo running from ${at} keeps no key secret this command can read.`);
703
+ say(" What would change it: run this as the user who started the demo.");
704
+ return 1;
705
+ }
706
+ const key = withSecret(secret, () => mintToken({ scope: "account", sub: email, ttlSec: 90 * 24 * 3600 }));
707
+ say(` ${keyPurposeLine({ email, brand: BRAND.name, reset: passphraseResetCommand({ prefix, credentialPath }) })}`);
708
+ say("");
709
+ say(` Address: ${address}`);
710
+ say(` Key: ${key}`);
711
+ return 0;
712
+ }
713
+
657
714
  async function main() {
658
715
  const argv = process.argv.slice(2);
659
716
  // `--help` IS ANSWERED, not rejected. Running this bare drops the reader into a question, so "run it
@@ -674,6 +731,7 @@ async function main() {
674
731
  say(" internet, with a key made for you now");
675
732
  say(" --list the assistants this build knows");
676
733
  say(" --dry-run say what would change, change nothing");
734
+ say(" --base <dir> a running demo's folder: mint a key for its client door, and print both");
677
735
  say(" --allow-checkout-move");
678
736
  say(" write this checkout's path even though services are running from");
679
737
  say(" another one. Every unit's ExecStart follows that value, so the ones");
@@ -681,7 +739,7 @@ async function main() {
681
739
  say("");
682
740
  return 0;
683
741
  }
684
- const known = new Set(["--client", "--where", "--list", "--dry-run", "--allow-checkout-move", "--help", "-h"]);
742
+ const known = new Set(["--client", "--where", "--list", "--dry-run", "--base", "--allow-checkout-move", "--help", "-h"]);
685
743
  const unknown = argv.filter((a) => a.startsWith("--") && !known.has(a));
686
744
  if (unknown.length) {
687
745
  console.error(`connect: unrecognised flag(s): ${unknown.join(", ")}`);
@@ -689,6 +747,13 @@ async function main() {
689
747
  process.exit(2);
690
748
  }
691
749
  const dryRun = argv.includes("--dry-run");
750
+ const b = argv.indexOf("--base");
751
+ if (b >= 0) {
752
+ const base = argv[b + 1];
753
+ if (!base || base.startsWith("--")) { console.error("connect: --base needs the demo's folder."); process.exit(2); }
754
+ const c = argv.indexOf("--client");
755
+ return await connectDemo({ base, dryRun, clientName: c >= 0 ? clientById(argv[c + 1])?.name ?? null : null });
756
+ }
692
757
  const w = argv.indexOf("--where");
693
758
  if (w >= 0 && !Object.hasOwn(WHERE_FLAG, argv[w + 1] ?? "")) {
694
759
  console.error(`connect: --where takes one of: ${Object.keys(WHERE_FLAG).join(", ")}`);
package/bin/key.mjs CHANGED
@@ -28,9 +28,10 @@
28
28
  // question would be two answers, and the wrong one would be the one nobody read.
29
29
  import "../shared/env-local.mjs"; // side effect: apply the install's .env — FIRST, before anything reads process.env
30
30
  import { invocationPrefix } from "../shared/invocation.mjs";
31
+ import { BRAND } from "../shared/brand.mjs";
31
32
  import { existsSync, readFileSync } from "node:fs";
32
33
  import { defaultGrantsPath, installPaths } from "./start.mjs";
33
- import { demoTokenSecretPath } from "../shared/client-door.mjs";
34
+ import { demoTokenSecretPath, keyPurposeLine } from "../shared/client-door.mjs";
34
35
  import { resolvePerson } from "../shared/scope.mjs"; // the door's own reading of the guest list, so one answer serves both
35
36
  import { mintFromOptions } from "../mcp-server/mint-token.mjs";
36
37
 
@@ -96,6 +97,10 @@ try {
96
97
  die(e.message);
97
98
  }
98
99
 
100
+ // WHAT THIS KEY IS FOR, FIRST, in the owner's words (2026-09-19): a person who found this verb before the
101
+ // passphrase minted a key and could not sign in to the portal with it. Standard error, as every line here
102
+ // is, so the token stays alone on standard output.
103
+ console.error(keyPurposeLine({ email, brand: BRAND.name, reset: `${p}clearotron passphrase --reset` }));
99
104
  for (const line of minted.notes) console.error(line);
100
105
 
101
106
  // — found in review. A KEY FOR AN IDENTITY ON NO LIST IS A KEY THAT 403s. This command
package/bin/onboard.mjs CHANGED
@@ -2818,6 +2818,9 @@ export async function runCheck() {
2818
2818
  info(`the units are installed but their environment could not be read (${unitEnv?.why ?? "no reason given"}) — `
2819
2819
  + "the door verdicts below are withheld rather than guessed, because a failure to look is not a finding");
2820
2820
  if (door.shape === "local") {
2821
+ // WHO THAT ONE USER IS, in the owner's words (2026-09-19), resolved as `start` resolves it.
2822
+ const oneUser = String(effectiveForService("PORTAL_LOCAL_USER")?.v || `${userInfo().username}@localhost`).trim().toLowerCase();
2823
+ say(` · Portal sign-in: one user, ${oneUser}, by passphrase.`);
2821
2824
  say(` · local passphrase door (${typed}) — one operator, one passphrase, no identity provider`);
2822
2825
  info(`a lost passphrase is recoverable: ${invocationPrefix()}clearotron passphrase --reset`);
2823
2826
  } else if (door.shape === "fronted") {
@@ -3194,7 +3197,10 @@ export async function runCheck() {
3194
3197
  try { const s = statSync(p); if (s.size > 0) { wrote = p; break; } } catch { /* not this one */ }
3195
3198
  }
3196
3199
  if (wrote) {
3197
- ok(`the client door's access log is being written: ${wrote}`);
3200
+ // WHAT WAS READ, AND NOTHING MORE. This said "is being written" from any non-empty file — a claim
3201
+ // about activity made from a file's size, printed over a log whose only line was a test's
3202
+ // (measured 2026-09-19). Reworded as the owner ruled, 2026-09-19.
3203
+ ok(`the client door's access log has entries: ${wrote}`);
3198
3204
  } else {
3199
3205
  // NOT-YET-WRITTEN IS NOT UNWRITABLE. A door nobody has called through has no log, and saying it
3200
3206
  // is broken would be the same lie as calling an unreachable unit inactive.
@@ -32,6 +32,7 @@ import { defaultInstallBase, establishCredential, installCredential, readLocalCr
32
32
  // rather than hardcoded. A hardcoded `npx ` tells a global installer their install is somehow lesser;
33
33
  // a hardcoded bare name sends an npx reader to `command not found`.
34
34
  import { invocationPrefix } from "../shared/invocation.mjs";
35
+ import { BRAND } from "../shared/brand.mjs";
35
36
 
36
37
  const P = invocationPrefix();
37
38
  const USAGE = ` ${P}clearotron passphrase — report or reset the portal's local sign-in
@@ -117,8 +118,9 @@ catch (e) {
117
118
  process.exit(1);
118
119
  }
119
120
 
120
- console.log(`\n A NEW passphrase has been minted for ${email}.`);
121
- console.log(` The previous one no longer works.\n`);
121
+ // THE OWNER'S WORDS, 2026-09-19: what the value is for comes before it, and names the one user it signs in.
122
+ console.log(`\n New passphrase for this ${BRAND.name}'s one user, ${email}. Paste it on the portal's sign-in page:\n`);
122
123
  console.log(` PASSPHRASE: ${minted.passphrase}\n`);
124
+ console.log(` The previous one no longer works.`);
123
125
  console.log(` Write it down now. It is stored only as a digest, so this line is the only copy that will`);
124
126
  console.log(` ever exist — re-run this command if you lose it.\n`);
package/bin/start.mjs CHANGED
@@ -121,7 +121,7 @@ import { SERVER_INSTALL_SET, unitsToRestartOnRefresh, unitHealthVerdict } from "
121
121
  // — the door --background now INSTALLS, and the one authority for the settings
122
122
  // it refuses to start without. (Until 2026-09-03 this import read "the one unit --background may
123
123
  // tolerate and never manage"; settled point 2 superseded that.)
124
- import { defaultDenylistPath, denylistPathFor, denylistFor, ensureDenylistFile, CLIENT_DOOR_UNIT, enablePlan, clientDoorPort, demoTokenSecret, demoTokenSecretPath, keyIssueCommand } from "../shared/client-door.mjs"; // — one owner for the revocation list's path
124
+ import { defaultDenylistPath, denylistPathFor, denylistFor, ensureDenylistFile, CLIENT_DOOR_UNIT, enablePlan, clientDoorPort, demoTokenSecret, demoTokenSecretPath, keyIssueCommand, demoConnectCommand } from "../shared/client-door.mjs"; // — one owner for the revocation list's path
125
125
  import { createServer } from "node:net";
126
126
  import { listenErrorMessage, nextFreePort } from "../shared/listen.mjs";
127
127
  import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
@@ -805,6 +805,10 @@ export function childEnv({ ports, paths, user, portalSecret, tokenSecret, opsTok
805
805
  // DNS-rebinding protection is keyed off this list being non-empty, so it is derived from the same
806
806
  // port the listener is given rather than left to whoever remembers.
807
807
  TRADEMARK_MCP_ALLOWED_HOSTS: `${host}:${ports.mcp},localhost:${ports.mcp}`,
808
+ // WHERE THE OTHER DOOR IS, so a key refused here is told where it belongs (shared/scope.mjs
809
+ // otherDoor). Read by nothing else in this process.
810
+ CLIENT_MCP_HTTP_HOST: host,
811
+ CLIENT_MCP_HTTP_PORT: String(ports.client),
808
812
  },
809
813
  // The worker needs the install's PATHS and nothing else — no ports, no secrets, no door config. It
810
814
  // talks to the queue and the pool, not to either listener.
@@ -830,6 +834,9 @@ export function childEnv({ ports, paths, user, portalSecret, tokenSecret, opsTok
830
834
  // written in one place and an allow-list in another drift into a door that starts and turns every
831
835
  // request away, which reads as a dead door rather than as a misconfiguration.
832
836
  CLIENT_MCP_ALLOWED_HOSTS: `${host}:${ports.client},localhost:${ports.client}`,
837
+ // The staff door's place, for the mirror refusal (shared/scope.mjs otherDoor). Read by nothing else here.
838
+ TRADEMARK_MCP_HTTP_HOST: host,
839
+ TRADEMARK_MCP_HTTP_PORT: String(ports.mcp),
833
840
  CLIENT_MCP_ACCOUNT_ACCESS: String(clientFence ?? "1"),
834
841
  // TOKEN-ONLY, AND WITHOUT IT THE DOOR DOES NOT START. Found by driving it rather than by reading
835
842
  // the diff: with this unset the door demands an OIDC audience plus a Cloudflare Access team or
@@ -2421,7 +2428,8 @@ if (isMain) {
2421
2428
  // The output used to name neither as a door, so an owner watching one of them start concluded MCP had
2422
2429
  // not come up. Both are printed with who each is for, because "MCP is running" is ambiguous on a box
2423
2430
  // that has two of them and the ambiguity is what cost the leg.
2424
- say(` Engine door http://${HOST}:${ports.mcp}/mcp — the portal's Start button calls this. Staff.`);
2431
+ // THE CLIENT DOOR LEADS (owner, 2026-09-19). It is the one an assistant uses, and with the engine door
2432
+ // printed first a demo's reader handed the staff address to their assistant, which refused its key.
2425
2433
  // THE SYNCHRONOUS TRUTH, NOT THE ASYNC FLAG ( — F26, review finding).
2426
2434
  //
2427
2435
  // `rec.alive` is flipped by the child's exit handler, which is async: reading it here asks "has the
@@ -2440,11 +2448,12 @@ if (isMain) {
2440
2448
  if (adoptedClientDoor)
2441
2449
  say(` Already running as ${CLIENT_DOOR_UNIT}; this start kept it, so existing keys still work.`);
2442
2450
  else
2443
- say(` It refuses every caller until a key is issued: ${keyIssueCommand({ prefix: invocationPrefix(), demo: DEMO, user, base: paths.base, defaultBase: join(homedir(), "trademark") })}`);
2451
+ say(` It refuses every caller until a key is issued: ${DEMO ? demoConnectCommand({ prefix: invocationPrefix(), base: paths.base }) : keyIssueCommand({ prefix: invocationPrefix(), demo: DEMO, user, base: paths.base, defaultBase: join(homedir(), "trademark") })}`);
2444
2452
  } else {
2445
2453
  say(` Client door NOT RUNNING on ${HOST}:${ports.client} — its output above says why. The portal and`);
2446
2454
  say(" the engine door are unaffected; a client assistant cannot connect until it is up.");
2447
2455
  }
2456
+ say(` Engine door http://${HOST}:${ports.mcp}/mcp — the portal's Start button calls this. Staff.`);
2448
2457
  say("");
2449
2458
  // ── WHAT `status` AND `stop` READ ABOUT THIS START ───────────────────────────────────────────────
2450
2459
  //
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "aa58558b3b09fbf5b1c83010291d340167d438d8",
3
- "version": "0.3.2-beta.11"
2
+ "commit": "92dc979bd176adeedc0dbe2518fbf24388bc6072",
3
+ "version": "0.3.2-beta.12"
4
4
  }
@@ -1,5 +1,21 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.3.2-beta.12
4
+
5
+ ### Patch Changes
6
+
7
+ - Fixed: A demo started in the background with `npx` now stops completely when that `npx` is stopped, freeing its three ports and removing its folder.
8
+ - Fixed: The demo lists the door an assistant uses first, and a key refused at the wrong door is told the right door's address.
9
+ - Fixed: A knockout no longer asks the model what to do with each name, so each name's read keeps to the findings and the rating.
10
+ - Fixed: A knockout's per-name read no longer carries a next-step section the model wrote. It is removed before delivery instead of the batch being sent back to rewrite it, so knockouts finish sooner.
11
+ - Fixed: A report produced before this version no longer shows a second section menu inside the portal, unreadable in the dark theme.
12
+ - New: `clearotron connect --base <folder>` connects an assistant to a running demo, minting its key and naming its door in one step.
13
+ - Fixed: `doctor` now says the client door's access log has entries, instead of claiming it is being written.
14
+ - Fixed: The sign-in page names the install's one user, and the passphrase reset, key issue and doctor each say what they are for.
15
+ - Fixed: Once the client's own registered element is set aside, searches added later in the run no longer search it again.
16
+ - Fixed: Running the test suite on a clone whose portal build is out of date no longer rebuilds it and fails the first run.
17
+ - Fixed: Running the test suite no longer writes an access-log line under your home folder, and a stopped demo leaves no empty folders behind.
18
+
3
19
  ## 0.3.2-beta.11
4
20
 
5
21
  ### Patch Changes
@@ -21,7 +21,7 @@
21
21
  // D4 verify.mjs parseCoverageLedgerJson, same shape
22
22
  // D5 verify.mjs:1504 fail(`${unaccounted[0].token}:…`) — token minted in a DATA ROW
23
23
  // D6 verify.mjs:1567 fail(`${violations[0].token}…`) — validatePlanFeasibility in register-plan.mjs
24
- // D7 verify.mjs:1558 fail(`${v2[0].token}${detail}…`) — register-plan.mjs:2183 disclosureTextByAxis
24
+ // D7 verify.mjs:1558 fail(`${v2[0].token}${detail}…`) — register-plan.mjs:2270 disclosureTextByAxis
25
25
  // D8 verify.mjs:2470 caseLawLedgerFail fail(caseLawLedgerFail(…)) — token built in case-law-ledger.mjs:195 caseLawLedgerFail
26
26
  //
27
27
  // A partition built on the 60 tokens a regex CAN see would run green while blind to the rest, which is
@@ -139,10 +139,10 @@ export const VOCABULARY = [
139
139
  { token: "coverage_form_missing", stages: ["register-digest"], site: "driver/verify.mjs" },
140
140
  { token: "coverage_form_empty", stages: ["register-digest"], site: "driver/verify.mjs" },
141
141
  { token: "coverage_status_offenum", stages: ["register-digest"], site: "driver/verify.mjs:2050" },
142
- { token: "coverage_deferred_unaccounted", stages: ["register-digest"], site: "driver/verify.mjs coverageFormFail", family: "driver/register-plan.mjs:1860 PROVIDER_HARD_ERROR_PREFIX — token on a data row", dynamic: "D5" },
143
- { token: "coverage_clean_unexecuted", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:1594 validatePlanFeasibility", dynamic: "D6" },
144
- { token: "coverage_clean_skipped", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:1955 searchedJurisdictionsFromPlan", dynamic: "D6" },
145
- { token: "coverage_clean_unverified_incomplete", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:2183 disclosureTextByAxis", dynamic: "D7" },
142
+ { token: "coverage_deferred_unaccounted", stages: ["register-digest"], site: "driver/verify.mjs coverageFormFail", family: "driver/register-plan.mjs:1947 PROVIDER_HARD_ERROR_PREFIX — token on a data row", dynamic: "D5" },
143
+ { token: "coverage_clean_unexecuted", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:1681 validatePlanFeasibility", dynamic: "D6" },
144
+ { token: "coverage_clean_skipped", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:2042 searchedJurisdictionsFromPlan", dynamic: "D6" },
145
+ { token: "coverage_clean_unverified_incomplete", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:2270 disclosureTextByAxis", dynamic: "D7" },
146
146
  { token: "coverage_clean_tainted", stages: ["register-digest"], site: "driver/verify.mjs" },
147
147
  { token: "coverage_ledger_", stages: ["register-digest"], site: "driver/verify.mjs", family: "driver/coverage-ledger.mjs (parseCoverageLedgerJson token-first throws)", dynamic: "D4" },
148
148
  { token: "coverage_key_unknown", stages: ["register-digest"], site: "driver/verify.mjs", family: "driver/coverage-ledger.mjs", dynamic: "D4" },
@@ -1132,7 +1132,7 @@ serve({
1132
1132
  factors: { type: "array", minItems: 2, maxItems: 4, items: { type: "string" }, description: "2–4 one-line load-bearing observations behind the band." },
1133
1133
  counterFactors: { type: "array", minItems: 1, maxItems: 3, items: { type: "string" }, description: "1–3 one-line statements of what holds this name at this band rather than the next, either way." },
1134
1134
  mitigation: { type: "string", description: "May be \"\" when nothing would move the band — but SEND THE KEY, so a considered \"none\" is not confusable with an omission." },
1135
- assessment: { type: "string", description: "The opening paragraph a reader of THIS MARK'S own report sees first: what the name is, what the landscape looks like, what drives the rating, what to do with it." },
1135
+ assessment: { type: "string", description: "The opening paragraph a reader of THIS MARK'S own report sees first: what the name is, what the landscape looks like, what drives the rating." },
1136
1136
  contextFraming: { type: "string" },
1137
1137
  registerEstimate: { type: "string" },
1138
1138
  parodyNote: { type: ["string", "null"] },
@@ -30,7 +30,7 @@
30
30
  import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from "node:fs";
31
31
  import { dirname, join } from "node:path";
32
32
  import { driverDir } from "../../../shared/driver-dir.mjs"; //
33
- import { PLAN_PREDICATES, PLAN_MAX_OR_WIDTH, PLAN_MAX_NAME_LENGTH, fingerprint, ownerIntersectionGap, resolveRegions } from "../../register-plan.mjs";
33
+ import { PLAN_PREDICATES, PLAN_MAX_OR_WIDTH, PLAN_MAX_NAME_LENGTH, fingerprint, ownerIntersectionGap, resolveRegions, houseElementOf, withoutHouseElementTerms } from "../../register-plan.mjs";
34
34
  import { entryTermIssues } from "../../../providers/_shared/term-shape.mjs";
35
35
  import { isNonLatinTerm, romanizationRefusal, romanizationSpellings, nativeScriptIndexGap } from "../../../providers/_shared/script-form.mjs";
36
36
 
@@ -289,9 +289,10 @@ export async function proposeSupplemental(params, tctx, deps) {
289
289
  // execute-plan kernel hands it to the provider's buildEntryQuery, which backfills only entries that
290
290
  // declare none (makeRegionRequiredBuildEntryQuery). A proposal that DOES declare regions is
291
291
  // untouched, qid fingerprints are unchanged, and providers that do not require regions ignore it.
292
- let planRegions = [], planClasses = [];
292
+ let planRegions = [], planClasses = [], house = null;
293
293
  try {
294
294
  const frozen = JSON.parse(readFileSync(driverDir(dirname(dirname(outPath)), "register-plan.json"), "utf8"));
295
+ house = houseElementOf(frozen);
295
296
  planRegions = (Array.isArray(frozen?.regions) ? frozen.regions : []).map((r) => String(r).trim()).filter(Boolean);
296
297
  // C3 — the frozen plan's own class list is the priority set: proposals intersecting it compete
297
298
  // for the per-call/per-axis caps first (mintSupplementalEntries stable-sorts; values unchanged).
@@ -307,8 +308,24 @@ export async function proposeSupplemental(params, tctx, deps) {
307
308
  }
308
309
  const perCall = 12; // step 3 — was a knob; no environment ever set it
309
310
  const axisMax = 24; // step 3 — was a knob; no environment ever set it
311
+ // ── THE CLIENT'S OWN ELEMENT IS LEFT OUT BEFORE ANYTHING RUNS ───────────────────────────────────
312
+ //
313
+ // This tool EXECUTES what it mints, before the fold adds it to the plan, so the fold's own exclusion
314
+ // (`houseElementOf` in register-plan.mjs) comes too late here: the query would already have run. On
315
+ // the first live run that proposed an exclusion, this session proposed an exact search on the bare
316
+ // house element the compile had set aside. The same rule is applied here, before the mint. Not a
317
+ // rejection: rejected[] becomes an OPEN ask on the report, and the element is not an open question.
318
+ const excludedHouse = [];
319
+ const offered = [];
320
+ for (const p of proposals) {
321
+ const kept = house ? withoutHouseElementTerms({ ...(p ?? {}), predicate: String(p?.predicate ?? "default") }, house) : p;
322
+ if (!kept) { excludedHouse.push(String(p?.term ?? p?.terms?.[0] ?? "")); continue; }
323
+ offered.push(kept === p || !Array.isArray(kept.terms) ? p : { ...p, terms: kept.terms });
324
+ }
325
+ if (!offered.length)
326
+ return { type: "text", text: JSON.stringify({ minted: [], reused: [], rejected: [], excluded_house_element: excludedHouse, executed: false }, null, 2) };
310
327
  const existingQids = new Set(supp.entries.map((e) => e.qid));
311
- const { minted, reused, rejected, enriched, narrowed } = mintSupplementalEntries(axis, proposals,
328
+ const { minted, reused, rejected, enriched, narrowed } = mintSupplementalEntries(axis, offered,
312
329
  { existingQids, perCall, axisMax, existingCount: supp.entries.length, capabilities: deps.capabilities ?? null, priorityClasses: planClasses });
313
330
 
314
331
  // Field-level romanisation enrichment of a REUSED qid (2026-07-30 review round): the natural retry —
@@ -373,7 +390,7 @@ export async function proposeSupplemental(params, tctx, deps) {
373
390
  const r = await executePlan({ plan_path: suppPath, axis, output_path: outPath, qids }, tctx);
374
391
  const text = r && typeof r === "object" ? (r.text ?? "") : String(r ?? "");
375
392
  if (!text || text.startsWith("ERROR")) {
376
- return { type: "text", text: JSON.stringify({ minted: minted.map((e) => e.qid), reused, ...(enrichedQids.length ? { enriched: enrichedQids } : {}), ...(conflicts.length ? { conflict: conflicts } : {}), rejected, ...(narrowed.length ? { narrowed } : {}), executed: false, error: text.slice(0, 300) || "executor returned nothing" }, null, 2) };
393
+ return { type: "text", text: JSON.stringify({ minted: minted.map((e) => e.qid), reused, ...(enrichedQids.length ? { enriched: enrichedQids } : {}), ...(conflicts.length ? { conflict: conflicts } : {}), rejected, ...(excludedHouse.length ? { excluded_house_element: excludedHouse } : {}), ...(narrowed.length ? { narrowed } : {}), executed: false, error: text.slice(0, 300) || "executor returned nothing" }, null, 2) };
377
394
  }
378
395
  try { summary = JSON.parse(text); } catch { summary = { raw: text.slice(0, 300) }; }
379
396
  }
@@ -398,5 +415,5 @@ export async function proposeSupplemental(params, tctx, deps) {
398
415
  }
399
416
  } catch { /* band unreadable — the executor summary still crosses */ }
400
417
 
401
- return { type: "text", text: JSON.stringify({ minted: minted.map((e) => e.qid), reused, ...(enrichedQids.length ? { enriched: enrichedQids } : {}), ...(conflicts.length ? { conflict: conflicts } : {}), rejected, ...(narrowed.length ? { narrowed } : {}), executed: qids.length > 0, summary, results }, null, 2) };
418
+ return { type: "text", text: JSON.stringify({ minted: minted.map((e) => e.qid), reused, ...(enrichedQids.length ? { enriched: enrichedQids } : {}), ...(conflicts.length ? { conflict: conflicts } : {}), rejected, ...(excludedHouse.length ? { excluded_house_element: excludedHouse } : {}), ...(narrowed.length ? { narrowed } : {}), executed: qids.length > 0, summary, results }, null, 2) };
402
419
  }
@@ -0,0 +1,72 @@
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
+ // knockout-next-step.mjs — a next-step section the assessing model wrote into a name's read comes off
4
+ // before the report is published.
5
+ //
6
+ // A knockout states findings and a rating; what to do with the name is the reading lawyer's. The assessing
7
+ // instructions stopped asking for a next step, and the model still writes one now and then, under a heading
8
+ // of its own: "What to do with it", "Practical next step". Refusing the turn and re-asking cost a full
9
+ // re-dispatch each time, and the owner ruled for delivery over the loop (2026-09-19). So the section is
10
+ // removed here, in code, with no model call, and the run record names every heading removed.
11
+ //
12
+ // WHAT COMES OFF: a heading whose own words are a next step, and the section under it — nothing else. A
13
+ // hashed heading's section runs to the next heading at its level or above; a line that is only bold text
14
+ // runs to the next heading of either kind; a bold label opening a paragraph ("**Practical next step** —
15
+ // …") takes that paragraph. Position does not matter: removing a section cannot reorder what is left.
16
+ //
17
+ // WHAT STAYS, BY DESIGN: the same advice written as an ordinary sentence with no heading over it. Nothing
18
+ // bounds that sentence but judgement, and cutting a guess out of the client's read is worse than leaving
19
+ // it. The instruction not to write one is the only thing that reaches it.
20
+
21
+ // A heading's own words that make it a next step. Whole-heading: "What drives the rating" and "What is
22
+ // still open" are not next steps, and a word appearing somewhere inside a heading proves nothing.
23
+ export const NEXT_STEP_HEADING_RE =
24
+ /^(?:(?:practical|immediate|suggested|recommended|possible)\s+)?(?:next\s+steps?\b.*|what\s+to\s+do\b.*|what\s+happens\s+next|recommendations?\b.*|our\s+recommendations?\b.*|(?:the\s+)?way\s+forward)$/i;
25
+
26
+ const label = (s) => String(s).replace(/[*_`]/g, "").replace(/[\s:.–—-]+$/, "").trim();
27
+
28
+ /** What kind of heading this line is, and its words — or null for a line that heads nothing. PURE. */
29
+ function headingAt(line) {
30
+ const atx = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
31
+ if (atx) return { kind: "atx", level: atx[1].length, text: label(atx[2]) };
32
+ const bold = /^\s*(\*\*|__)(.+?)\1\s*:?\s*$/.exec(line);
33
+ if (bold) return { kind: "bold", text: label(bold[2]) };
34
+ const lead = /^\s*(\*\*|__)(.+?)\1\s*[:.–—-]?\s+\S/.exec(line);
35
+ if (lead) return { kind: "lead", text: label(lead[2]) };
36
+ return null;
37
+ }
38
+
39
+ /** Is this whole line a heading over a next step? PURE. */
40
+ export function isNextStepHeading(line) {
41
+ const h = headingAt(String(line ?? ""));
42
+ return Boolean(h && NEXT_STEP_HEADING_RE.test(h.text));
43
+ }
44
+
45
+ /**
46
+ * The text with every next-step section removed, and what was removed: `{ text, removed: [{ heading,
47
+ * chars }] }`. Text with nothing to remove comes back byte-identical. PURE.
48
+ */
49
+ export function stripNextStepSections(text) {
50
+ const src = String(text ?? "");
51
+ const lines = src.split("\n");
52
+ const keep = [];
53
+ const removed = [];
54
+ for (let i = 0; i < lines.length;) {
55
+ const h = headingAt(lines[i]);
56
+ if (!h || !NEXT_STEP_HEADING_RE.test(h.text)) { keep.push(lines[i]); i++; continue; }
57
+ let j = i + 1;
58
+ if (h.kind === "lead") {
59
+ while (j < lines.length && lines[j].trim() !== "") j++;
60
+ } else {
61
+ for (; j < lines.length; j++) {
62
+ const n = headingAt(lines[j]);
63
+ if (!n || n.kind === "lead") continue;
64
+ if (h.kind === "atx" ? n.kind === "atx" && n.level <= h.level : true) break;
65
+ }
66
+ }
67
+ removed.push({ heading: h.text, chars: lines.slice(i, j).join("\n").trim().length });
68
+ i = j;
69
+ }
70
+ if (!removed.length) return { text: src, removed };
71
+ return { text: keep.join("\n").replace(/\n[ \t]*\n(?:[ \t]*\n)+/g, "\n\n").trim(), removed };
72
+ }
@@ -137,7 +137,7 @@ export function parseNamedBand(raw) {
137
137
  // byte-identical to a slice the plan deliberately counted without fetching. Measured on a real
138
138
  // run: four capability-gap blocks carried `error:true, deferred:true` into this function and
139
139
  // reached record-carry.json with both fields gone and a sentence claiming the run "has a hit
140
- // COUNT for this slice". register-plan.mjs:1594 validatePlanFeasibility already enforces the same rule one layer up
140
+ // COUNT for this slice". register-plan.mjs:1681 validatePlanFeasibility already enforces the same rule one layer up
141
141
  // ("a transient must not ship indistinguishable from a sanctioned descriptor") — it reads the
142
142
  // RAW blocks, which is why it could. Every consumer that reads THIS projection could not.
143
143
  // Conditional like the four keys above, so old bands carry neither key and nothing shifts.
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.2-beta.11",
5
+ "version": "0.3.2-beta.12",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {
@@ -56,6 +56,7 @@ import { registerUnavailableOffices } from "./register-unreachable.mjs";
56
56
  import { runRecordLogPath } from "../providers/_shared/ledger-path.mjs"; // — this run's record log
57
57
  import { validators as koValidators, validateMergedFindings, worstBand, registerSurfacedFilings, raterCaveats, SURVIVOR_BOUNDARY_RE } from "./verify-knockout.mjs";
58
58
  import { reviewAbout, reviewEvidence, reviewEvidenceLines, applyKnockoutReview, knockoutReviewFile } from "./knockout-review-record.mjs";
59
+ import { stripNextStepSections } from "./knockout-next-step.mjs";
59
60
  import { publishKnockout, composeKnockoutEmail } from "./publish/knockout.mjs";
60
61
  import { writeRunStatus, rollupStatus, atomicWrite, identitySeed } from "./progress.mjs"; // — the identity seed is shared; the stepper is not
61
62
  import { batchMarkName } from "./mark-name.mjs";
@@ -349,6 +350,28 @@ export function survivorBoundaryNote(policy) {
349
350
  return `This is ${screenName}, not a clearance. A mark not knocked out here is not clear — it is not knocked out at this screen's depth, and proceeds to clearance. Nothing above is a finding of availability.`;
350
351
  }
351
352
 
353
+ // ── a next-step section the model wrote comes off, in code ──────────────────────────────────────────
354
+ //
355
+ // Each name's read is published as its own report, and the screen states findings and a rating — what to
356
+ // do with the name is the reading lawyer's. A heading the model wrote over a next step, and the section
357
+ // under it, are removed here and named in the run record; `knockout-next-step.mjs` says what counts. This
358
+ // replaced a pre-delivery refusal that re-asked the whole chunk for the same edit (owner, 2026-09-19).
359
+ // EXPORTED so the record line is driven directly, not only through a whole pipeline.
360
+ export function removeNextStepSections(runDir, marks) {
361
+ const out = [];
362
+ for (const m of marks ?? []) {
363
+ if (typeof m?.assessment !== "string") continue;
364
+ const { text, removed } = stripNextStepSections(m.assessment);
365
+ if (!removed.length) continue;
366
+ m.assessment = text;
367
+ const row = { event: "knockout-next-step-removed", mark: m.name, headings: removed.map((r) => r.heading),
368
+ chars: removed.reduce((n, r) => n + r.chars, 0), lane: "knockout" };
369
+ runLog(runDir, row);
370
+ out.push(row);
371
+ }
372
+ return out;
373
+ }
374
+
352
375
  // ── — the knockout lane's recovery park ───────────────────────────────────────
353
376
  //
354
377
  // EXPORTED so it can be armed directly. The clearance lane's equivalent is inline in a 6,000-line catch
@@ -964,6 +987,9 @@ export async function knockoutInner(ctx, job, opts = {}) {
964
987
  // it runs BEFORE the artifact is written, not after. The counts are logged because a receipts pass
965
988
  // over zero citations is a different fact from a receipts pass, and only the count can tell them
966
989
  // apart afterwards.
990
+ // BEFORE THE GATE AND THE WRITE, so the record on disk is the record that ships, and the reviewing
991
+ // pass never addresses a line inside a section that is about to go.
992
+ removeNextStepSections(run.runDir, merged.marks);
967
993
  const mv = validateMergedFindings(run.runDir, merged, plan);
968
994
  if (!mv.ok) throw new StageFailure("knockout-assess", `merged findings failed the lint: ${mv.failures.join("; ")}`, null);
969
995
  runLog(run.runDir, { event: "knockout-receipts", ...mv.receipts });
@@ -122,6 +122,13 @@ const CHROME_RES = [
122
122
  // has stopped matching. Removing the wrapper first would take the nav with it, drive the count to
123
123
  // zero, and turn a security assertion into a permanent false alarm.
124
124
  { tag: "div", open: /<div class="[^"]*\brep-stickyhead\b[^"]*"[^>]*>/ },
125
+ // THE SECTION MENU, WHEREVER THE RENDERER PUT IT. A report rendered today carries it inside the header
126
+ // above, and it goes with that. A report rendered before 2026-09-18 carries it just AFTER the header, so
127
+ // stripping the header alone left it in the frame: a second copy of the menu the portal draws, pinned
128
+ // over the report's title band, its current item red on red in the dark theme. Measured on an archived
129
+ // global preliminary report served by a published beta. Archived reports are served from their baked
130
+ // bytes, so this has to happen here rather than in the renderer. `sectionsOf` has already read it.
131
+ { tag: "nav", open: /<nav class="[^"]*\bstrip\b[^"]*"[^>]*>/ },
125
132
  // the "Internal review copy — stripped on export" bar, which hosted the quality-capture controls
126
133
  { tag: "div", open: /<div class="[^"]*\breview\b[^"]*\binternal\b[^"]*"[^>]*>/ },
127
134
  // per-finding flag buttons and their popovers
@@ -529,7 +536,7 @@ const EMBED_JS = `
529
536
  function schedule(){
530
537
  if(queued)return;
531
538
  queued=true;
532
- requestAnimationFrame(function(){queued=false;post();});
539
+ requestAnimationFrame(function(){queued=false;post();sections();});
533
540
  }
534
541
  // WHICH CONTROLS THIS DOCUMENT ACTUALLY HAS.
535
542
  //
@@ -559,12 +566,24 @@ const EMBED_JS = `
559
566
  // script) and the shell draws it in its own header. Only the ids that are really in the document are
560
567
  // announced: a report whose renderer named a section it did not draw would otherwise offer the reader
561
568
  // a breadcrumb entry that jumps nowhere.
569
+ //
570
+ // AND WHERE EACH ONE STARTS, so the shell can show how far the reader has got (owner, 2026-09-19): every
571
+ // section reached so far is marked, the rest are not. The page scrolls, not this frame, so only the shell
572
+ // knows where the reader is. It needs each section's top in this document to compare. Said again whenever
573
+ // the layout moves: a panel opening pushes every section below it down.
574
+ var saidSections='';
562
575
  function sections(){
563
576
  try{
564
577
  var list=window.__CORD_SECTIONS;
565
578
  if(!list||!list.length)return;
566
579
  var live=[];
567
- for(var i=0;i<list.length;i++) if(document.getElementById(list[i].id)) live.push(list[i]);
580
+ for(var i=0;i<list.length;i++){
581
+ var el=document.getElementById(list[i].id);
582
+ if(el) live.push({id:list[i].id,label:list[i].label,top:Math.max(0,Math.round(el.getBoundingClientRect().top+window.scrollY))});
583
+ }
584
+ var said=JSON.stringify(live);
585
+ if(said===saidSections)return;
586
+ saidSections=said;
568
587
  parent.postMessage({source:TAG,type:'sections',sections:live},'*');
569
588
  }catch(e){}
570
589
  }