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.
- package/.env.example +1 -1
- package/bin/clearotron.mjs +14 -0
- package/bin/connect.mjs +68 -3
- package/bin/key.mjs +6 -1
- package/bin/onboard.mjs +7 -1
- package/bin/passphrase.mjs +4 -2
- package/bin/start.mjs +12 -3
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +16 -0
- package/driver/contract-vocabulary.mjs +5 -5
- package/driver/engine/mcp/recording-server.mjs +1 -1
- package/driver/engine/mcp/supplemental.mjs +22 -5
- package/driver/knockout-next-step.mjs +72 -0
- package/driver/named-band.mjs +1 -1
- package/driver/package.json +1 -1
- package/driver/pipeline-knockout.mjs +26 -0
- package/driver/portal-report.mjs +21 -2
- package/driver/portal-service.mjs +8 -8
- package/driver/publish/render-knockout.mjs +14 -9
- package/driver/register-availability.mjs +1 -1
- package/driver/register-plan.mjs +81 -11
- package/driver/result-noun-fields.mjs +3 -1
- package/driver/stages-knockout.mjs +1 -1
- package/driver/suite-census.json +47 -17
- package/driver/verify-knockout.mjs +0 -27
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-CtvwLCti.css → index-7Lq-dXDV.css} +12 -9
- package/portal-ui/dist/assets/{index-DXSRxPV_.js → index-w8GFZftk.js} +110 -69
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/ask-ai-render-check.mjs +26 -1
- package/scripts/dead-names.mjs +17 -16
- package/scripts/env-audit.mjs +6 -0
- package/scripts/repo-writes.mjs +42 -0
- package/scripts/report-sections-render-check.mjs +245 -0
- package/scripts/report-theme-render-check.mjs +31 -3
- package/scripts/settings-render-check.mjs +10 -7
- package/scripts/test-run.mjs +63 -4
- package/shared/client-door.mjs +20 -0
- package/shared/parent-watch.mjs +33 -0
- package/shared/running-start.mjs +14 -3
- 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
|
-
# {"
|
|
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
|
#
|
package/bin/clearotron.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/bin/passphrase.mjs
CHANGED
|
@@ -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
|
-
|
|
121
|
-
console.log(
|
|
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
|
-
|
|
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
package/driver/CHANGELOG.md
CHANGED
|
@@ -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:
|
|
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:
|
|
143
|
-
{ token: "coverage_clean_unexecuted", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:
|
|
144
|
-
{ token: "coverage_clean_skipped", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:
|
|
145
|
-
{ token: "coverage_clean_unverified_incomplete", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:
|
|
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
|
|
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,
|
|
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
|
+
}
|
package/driver/named-band.mjs
CHANGED
|
@@ -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:
|
|
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.
|
package/driver/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "clearotron-driver",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.3.2-beta.
|
|
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 });
|
package/driver/portal-report.mjs
CHANGED
|
@@ -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++)
|
|
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
|
}
|