clearotron 0.3.0-beta.1 → 0.3.0-beta.2
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 +13 -2
- package/CONTRIBUTING.md +1 -1
- package/INSTALL.md +12 -10
- package/README.md +3 -2
- package/bin/brandowner.mjs +94 -1
- package/bin/onboard.mjs +156 -55
- package/bin/passphrase.mjs +23 -4
- package/bin/start.mjs +177 -38
- package/build-info.json +2 -2
- package/docs/architecture/04-configuration-reference.md +5 -2
- package/docs/architecture/05-config-governance.md +3 -2
- package/driver/CHANGELOG.md +95 -0
- package/driver/demo-posture.mjs +59 -8
- package/driver/driver.config.mjs +36 -7
- package/driver/engine/child-record.mjs +93 -0
- package/driver/findings-model.mjs +6 -1
- package/driver/knockout-assess-record.mjs +6 -3
- package/driver/package.json +1 -1
- package/driver/portal-local-auth.mjs +98 -3
- package/driver/portal-service.mjs +65 -29
- package/driver/publish/knockout.mjs +12 -1
- package/driver/publish/office-record-links.mjs +61 -10
- package/driver/publish/render-knockout.mjs +31 -11
- package/driver/publish/xlsx.mjs +33 -1
- package/driver/register-records.mjs +6 -0
- package/driver/run-requirements.mjs +24 -1
- package/driver/runner.mjs +10 -5
- package/driver/skills/knockout-assess/SKILL.md +1 -1
- package/driver/suite-census.json +174 -30
- package/driver/unit-inventory.mjs +109 -5
- package/driver/updater-identity.mjs +178 -0
- package/driver/usage-ledger.mjs +5 -5
- package/mcp-server/CHANGELOG.md +6 -0
- package/mcp-server/http-server.mjs +16 -13
- package/mcp-server/lib/driver.mjs +7 -0
- package/mcp-server/lib/knockout.mjs +14 -2
- package/mcp-server/lib/ops.mjs +38 -16
- package/mcp-server/package.json +2 -2
- package/mcp-server/server.mjs +1 -1
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-CWTHP0sH.js → index-CcFjgM78.js} +32 -17
- package/portal-ui/dist/assets/{index-KpytsmNH.css → index-CsCuPshD.css} +7 -2
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +3 -3
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +2 -2
- package/scripts/changelog-plain-language.mjs +7 -30
- package/scripts/e2e.mjs +13 -2
- package/scripts/env-audit.mjs +10 -3
- package/scripts/env-classify.mjs +205 -12
- package/scripts/live-surface-check.mjs +74 -2
- package/scripts/plain-language-rules.mjs +103 -0
- package/scripts/release-note-required.mjs +118 -24
- package/scripts/release-notes-lint.mjs +22 -43
- package/scripts/release-version.mjs +59 -0
- package/scripts/revisit-render-check.mjs +6 -2
- package/scripts/text-difference.mjs +22 -0
- package/shared/env-local.mjs +25 -2
- package/shared/names-in-force.mjs +1 -0
- package/shared/reap-on-exit.mjs +27 -14
- package/shared/store-in-repo.mjs +147 -0
- package/shared/withheld-paths-access.mjs +6 -6
- package/shared/yes-no-echo.mjs +35 -0
package/driver/demo-posture.mjs
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
// demo out of one. Anything but the literal `1` is not a demo.
|
|
27
27
|
import { existsSync, readdirSync } from "node:fs";
|
|
28
28
|
import { join } from "node:path";
|
|
29
|
+
import { loadProfiles, loadProjects } from "./profiles.mjs";
|
|
29
30
|
|
|
30
31
|
/** Literal `1`, and nothing else. A truthy-looking value is not a demo. */
|
|
31
32
|
export function isDemo(env = process.env) {
|
|
@@ -58,15 +59,58 @@ export function demoReportCount(env = process.env) {
|
|
|
58
59
|
} catch { return null; }
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The companies the roster in force actually holds, Generic excepted.
|
|
64
|
+
*
|
|
65
|
+
* ── A READ OF THE ROSTER, NEVER A CONSTANT ────────────────────────────────────────────────────────
|
|
66
|
+
*
|
|
67
|
+
* The line below used to name Demo Brand Owner, "rating under the generic default framework, with its
|
|
68
|
+
* demo project", as a literal, and on 0.3.0-beta.1 it was wrong twice. The demo account rates under a
|
|
69
|
+
* framework of its own, and a demo that had taken a real install's settings served a roster without it
|
|
70
|
+
* while still printing its name. So each fact is read off the loader this process serves from: the
|
|
71
|
+
* name, whether the profile names a framework of its own, how many projects sit under it, and whether it
|
|
72
|
+
* is marked demo data. When the roster changes, the sentence changes with it.
|
|
73
|
+
*
|
|
74
|
+
* `roster` and `projects` are the loader's own Maps, passed by a caller that already holds them and read
|
|
75
|
+
* here otherwise. `null` when the roster cannot be read, which is a different fact from an empty one.
|
|
76
|
+
*/
|
|
77
|
+
export function demoCompanies({ roster, projects } = {}) {
|
|
78
|
+
try {
|
|
79
|
+
const profiles = roster ?? loadProfiles({ force: true });
|
|
80
|
+
let byProject = projects;
|
|
81
|
+
// Projects that cannot be read are not counted, and a count of zero is never said: the clause is
|
|
82
|
+
// left out rather than stating a number nobody looked at.
|
|
83
|
+
if (!byProject) { try { byProject = loadProjects({ profiles }); } catch { byProject = new Map(); } }
|
|
84
|
+
return [...profiles.values()]
|
|
85
|
+
.filter((p) => p && p.key !== "generic")
|
|
86
|
+
.map((p) => ({
|
|
87
|
+
key: p.key,
|
|
88
|
+
name: String(p.name ?? "").trim() || p.key,
|
|
89
|
+
ownFramework: Boolean(String(p.frameworkPath ?? "").trim()),
|
|
90
|
+
projects: [...byProject.keys()].filter((k) => k.startsWith(`${p.key}/`)).length,
|
|
91
|
+
demoData: p.demoData === true,
|
|
92
|
+
}))
|
|
93
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
94
|
+
} catch { return null; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const describeCompany = (c) =>
|
|
98
|
+
`${c.name}, rating under ${c.ownFramework ? "its own framework" : "the generic default framework"}`
|
|
99
|
+
+ (c.projects ? `, with ${c.projects} project${c.projects === 1 ? "" : "s"}` : "");
|
|
100
|
+
|
|
61
101
|
/**
|
|
62
102
|
* What to say instead of an operator's warning, when this process is part of a demo.
|
|
63
103
|
*
|
|
64
104
|
* ── THE SAME FACTS, AS WHAT THE DEMO *IS* RATHER THAN WHAT THE INSTALL *LACKS* ──────────────────────
|
|
65
105
|
*
|
|
66
|
-
* The
|
|
67
|
-
*
|
|
68
|
-
* "config store" tells a first-time visitor that the thing they just
|
|
69
|
-
* this is the output that gets captured for the website.
|
|
106
|
+
* The shape the owner described: the demo's company, the framework it rates under, its project and its
|
|
107
|
+
* reports. Every one of those is read (`demoCompanies`, `demoReportCount`), never stated. Naming two
|
|
108
|
+
* environment variables and a "config store" tells a first-time visitor that the thing they just
|
|
109
|
+
* started is misconfigured — and this is the output that gets captured for the website.
|
|
110
|
+
*
|
|
111
|
+
* THE CLOSING SENTENCE IS EARNED. "Nothing here is configured against a real customer" is said only
|
|
112
|
+
* when every company on the roster is marked demo data; a company a visitor created in the demo is not,
|
|
113
|
+
* and neither is anything a misconfigured store brought in.
|
|
70
114
|
*
|
|
71
115
|
* NEITHER WARNING IS SILENCED OUTSIDE A DEMO. Both are load-bearing on a real deployment, and the second
|
|
72
116
|
* warns that a page may show synthetic data as though it were a customer's own, which is precisely the
|
|
@@ -74,7 +118,7 @@ export function demoReportCount(env = process.env) {
|
|
|
74
118
|
*
|
|
75
119
|
* `null` outside a demo, so a caller that forgets to branch prints its warning rather than nothing.
|
|
76
120
|
*/
|
|
77
|
-
export function demoPostureLine(env = process.env) {
|
|
121
|
+
export function demoPostureLine(env = process.env, { roster, projects } = {}) {
|
|
78
122
|
if (!isDemo(env)) return null;
|
|
79
123
|
const n = demoReportCount(env);
|
|
80
124
|
const reports = n === null
|
|
@@ -82,7 +126,14 @@ export function demoPostureLine(env = process.env) {
|
|
|
82
126
|
// not look at — an absence is a finding, and a number invented here is one nobody can check.
|
|
83
127
|
? "its finished clearance reports"
|
|
84
128
|
: `${n} finished clearance report${n === 1 ? "" : "s"}`;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
129
|
+
const head = `This is ${"`clearotron demo`"}`;
|
|
130
|
+
const companies = demoCompanies({ roster, projects });
|
|
131
|
+
if (companies === null) return `${head}: its roster could not be read, so what it carries is not said here. It has ${reports} ready to open.`;
|
|
132
|
+
if (!companies.length) return `${head}, and its roster holds no company but the generic default. It has ${reports} ready to open.`;
|
|
133
|
+
const carries = companies.length === 1
|
|
134
|
+
? describeCompany(companies[0])
|
|
135
|
+
: `${companies.slice(0, -1).map(describeCompany).join("; ")}; and ${describeCompany(companies.at(-1))}`;
|
|
136
|
+
const unreal = companies.every((c) => c.demoData)
|
|
137
|
+
? " Nothing here is configured against a real customer, and nothing needs to be." : "";
|
|
138
|
+
return `${head}: it carries ${carries}, and ${reports} ready to open.${unreal}`;
|
|
88
139
|
}
|
package/driver/driver.config.mjs
CHANGED
|
@@ -219,7 +219,7 @@ export const config = {
|
|
|
219
219
|
// file to the repo — swapping a customer's own risk framework for the Generic default with nothing
|
|
220
220
|
// in the log to say so. A configured-but-unreadable overlay is a deploy defect, not a fallback.
|
|
221
221
|
if (!existsSync(overlay))
|
|
222
|
-
throw new Error(`skills_overlay_unreadable:${overlay} (CLEAROTRON_INSTRUCTIONS_DIR
|
|
222
|
+
throw new Error(`skills_overlay_unreadable:${overlay} (CLEAROTRON_INSTRUCTIONS_DIR names it, set by the operator or derived by the portal from PROFILE_REPO_ROOT, but this process cannot see it — customer-specific skills would silently fall back to the repo defaults)`);
|
|
223
223
|
const p = join(dirname(overlay), rel);
|
|
224
224
|
if (existsSync(p)) return p;
|
|
225
225
|
}
|
|
@@ -255,7 +255,7 @@ export const config = {
|
|
|
255
255
|
const overlay = this.skillsOverlayDir;
|
|
256
256
|
if (!overlay) return { path: basePath, rel, layer: existsSync(basePath) ? "base-only" : "missing", overlayPath: null, basePath };
|
|
257
257
|
if (!existsSync(overlay))
|
|
258
|
-
throw new Error(`skills_overlay_unreadable:${overlay} (CLEAROTRON_INSTRUCTIONS_DIR
|
|
258
|
+
throw new Error(`skills_overlay_unreadable:${overlay} (CLEAROTRON_INSTRUCTIONS_DIR names it, set by the operator or derived by the portal from PROFILE_REPO_ROOT, but this process cannot see it — customer-specific skills would silently fall back to the repo defaults)`);
|
|
259
259
|
const overlayPath = join(dirname(overlay), rel);
|
|
260
260
|
if (existsSync(overlayPath)) return { path: overlayPath, rel, layer: "overlay", overlayPath, basePath };
|
|
261
261
|
return { path: basePath, rel, layer: existsSync(basePath) ? "base" : "missing", overlayPath, basePath };
|
|
@@ -1193,21 +1193,50 @@ export const PROVIDERS = {
|
|
|
1193
1193
|
approximate: p.total_approximate === true, present: p.present === true, note: p.note };
|
|
1194
1194
|
} catch (e) { return { ok: false, cause: `countHits threw: ${e.message}` }; }
|
|
1195
1195
|
},
|
|
1196
|
+
// `reason`, not `cause`, on every refusal: the listing reads `reason` (register-records.mjs), so a
|
|
1197
|
+
// Signa search that failed reached the workbook as "the search did not run", its cause dropped.
|
|
1196
1198
|
async listRecords({ name, matchMode, classes, regions, limit }, { agentId, sessionKey, recordLog = null }) {
|
|
1197
|
-
if (!process.env.SIGNA_API_KEY) return { ok: false,
|
|
1199
|
+
if (!process.env.SIGNA_API_KEY) return { ok: false, records: null, reason: "SIGNA_API_KEY absent from driver env" };
|
|
1198
1200
|
let core;
|
|
1199
1201
|
try { core = await import("../providers/signa/src/core.js"); }
|
|
1200
|
-
catch (e) { return { ok: false,
|
|
1202
|
+
catch (e) { return { ok: false, records: null, reason: `plugin core unavailable: ${e.message}` }; }
|
|
1201
1203
|
const base = process.env.SIGNA_BASE_URL || core.DEFAULT_BASE;
|
|
1202
1204
|
try {
|
|
1203
1205
|
const r = await core.doSearch(process.env.SIGNA_API_KEY, base,
|
|
1204
1206
|
{ ...core.toSignaParams({ name, match_mode: matchMode || "exact", nice_classes: classes, regions }), limit },
|
|
1205
1207
|
{ kind: "search", agentId, sessionKey, sessionId: null, recordLog });
|
|
1206
1208
|
const text = typeof r?.text === "string" ? r.text : "";
|
|
1207
|
-
if (text.startsWith("ERROR")) return { ok: false,
|
|
1209
|
+
if (text.startsWith("ERROR")) return { ok: false, records: null, reason: text.slice(0, 200) };
|
|
1208
1210
|
const p = JSON.parse(text);
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
+
// THE SEARCH ROW IS NOT THE LISTING'S RECORD. The row names the owner `owner`, the classes
|
|
1212
|
+
// `nice_classes` and the filing date `filing_date`; the listing reads `owner_name`, `classes` and
|
|
1213
|
+
// `application_date`. Handed over unmapped, every Signa filing reached the knockout with no owner,
|
|
1214
|
+
// no classes and no filing date. Mapped here, as clarivate's adapter above maps its screen row.
|
|
1215
|
+
//
|
|
1216
|
+
// The office's own numbers come off the full record Signa returns on search, through the
|
|
1217
|
+
// provider's own normaliser, so publish can address the office's page for each filing
|
|
1218
|
+
// (publish/office-record-links.mjs) instead of showing the handle.
|
|
1219
|
+
return { ok: true, records: (Array.isArray(p.results) ? p.results : []).map((row) => {
|
|
1220
|
+
const rec = row?.raw && typeof row.raw === "object" ? core.normalizeRecord(row.raw, row.office || null) : null;
|
|
1221
|
+
return {
|
|
1222
|
+
record_id: row?.record_id ?? null,
|
|
1223
|
+
mark_text: row?.mark_text ?? null,
|
|
1224
|
+
owner_name: rec?.owner ?? row?.owner ?? null,
|
|
1225
|
+
owner_country: rec?.ownerCountry ?? null,
|
|
1226
|
+
status: row?.status ?? null,
|
|
1227
|
+
classes: Array.isArray(row?.nice_classes) ? row.nice_classes : null,
|
|
1228
|
+
office: row?.office || null,
|
|
1229
|
+
application_date: row?.filing_date ?? null,
|
|
1230
|
+
registration_date: row?.registration_date ?? null,
|
|
1231
|
+
application_number: rec?.applicationNumber ?? null,
|
|
1232
|
+
registration_number: rec?.registrationNumber ?? null,
|
|
1233
|
+
ir_number: rec?.irNumber ?? null,
|
|
1234
|
+
filing_route: rec?.filingRoute ?? null,
|
|
1235
|
+
// No page per record at Signa (hasPublicRecordUrl: false above), and none is made up here.
|
|
1236
|
+
record_url: null,
|
|
1237
|
+
};
|
|
1238
|
+
}) };
|
|
1239
|
+
} catch (e) { return { ok: false, records: null, reason: `listRecords threw: ${e.message}` }; }
|
|
1211
1240
|
},
|
|
1212
1241
|
async executePlan({ planPath, axis, outputPath, qids }, { agentId, sessionKey, recordLog = null }) {
|
|
1213
1242
|
if (!process.env.SIGNA_API_KEY) return { ok: false, cause: "SIGNA_API_KEY absent from driver env" };
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
// reads. A second spelling of "which process is this" is a second thing to keep in step.
|
|
44
44
|
|
|
45
45
|
import { writeFileSync, readFileSync, rmSync } from "node:fs";
|
|
46
|
+
import { spawnSync } from "node:child_process";
|
|
46
47
|
import { driverDir } from "../../shared/driver-dir.mjs";
|
|
47
48
|
import { procStarttime, parseClaimSidecar } from "../claim-liveness.mjs";
|
|
48
49
|
|
|
@@ -110,3 +111,95 @@ export function engineChildIsLive(rec, { starttimeOf = procStarttime } = {}) {
|
|
|
110
111
|
if (!rec.starttime) return false;
|
|
111
112
|
return starttimeOf(rec.pid) === rec.starttime;
|
|
112
113
|
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The process group `pid` is in, or null when that cannot be read.
|
|
117
|
+
*
|
|
118
|
+
* Linux reads it from /proc; everywhere else asks `ps`, as `procStarttime` does for a start time, and an
|
|
119
|
+
* injected reader outranks the platform for the reason `procStarttime` gives.
|
|
120
|
+
*/
|
|
121
|
+
export function procPgid(pid, readStat = undefined, { platform = process.platform, readPsPgid = defaultReadPsPgid } = {}) {
|
|
122
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
123
|
+
let v = NaN;
|
|
124
|
+
try {
|
|
125
|
+
if (readStat || platform === "linux") {
|
|
126
|
+
const stat = (readStat ?? ((p) => readFileSync(`/proc/${p}/stat`, "utf8")))(pid);
|
|
127
|
+
// After the last ')', because comm may hold spaces and parens: state is index 0, ppid 1, pgrp 2.
|
|
128
|
+
v = Number(stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/)[2]);
|
|
129
|
+
} else {
|
|
130
|
+
v = Number(String(readPsPgid(pid)).trim());
|
|
131
|
+
}
|
|
132
|
+
} catch { return null; }
|
|
133
|
+
return Number.isInteger(v) && v > 0 ? v : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function defaultReadPsPgid(pid) {
|
|
137
|
+
const r = spawnSync("ps", ["-o", "pgid=", "-p", String(pid)], { encoding: "utf8" });
|
|
138
|
+
return r.status === 0 ? r.stdout : "";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* End the recorded turn, and report whether it ENDED from what was seen afterwards, never from the
|
|
143
|
+
* signal having been sent.
|
|
144
|
+
*
|
|
145
|
+
* SIGNALLED IS NOT ENDED. `process.kill` returning means the OS accepted the signal for delivery, and a
|
|
146
|
+
* process that handles or ignores SIGTERM leaves that line looking the same. On the owner's WSL install
|
|
147
|
+
* of 0.3.0-beta.1 a stop that was reported as having ended the step ran on to the next step boundary. So
|
|
148
|
+
* this watches the turn after the signal, sends SIGKILL to whatever is left when the grace runs out, and
|
|
149
|
+
* answers `ended: true` only once it has seen nothing left.
|
|
150
|
+
*
|
|
151
|
+
* THE GROUP, WHEN THE TURN LEADS ONE. Both engines spawn the turn `detached: true`, so it leads its own
|
|
152
|
+
* process group and the MCP servers it starts are members of it. Measured 2026-09-10 on the coding CLI
|
|
153
|
+
* the engine spawns: a SIGTERM to the turn's pid alone ends the turn and its servers, but a SIGKILL to
|
|
154
|
+
* the pid alone leaves a server running under pid 1, and a SIGKILL to the group does not. So the
|
|
155
|
+
* escalation addresses the group, and "ended" means the group is empty as well as the turn gone.
|
|
156
|
+
*
|
|
157
|
+
* ONLY A GROUP THIS RECORD LEADS. The watchdogs signal `-child.pid` without asking, because they spawned
|
|
158
|
+
* the child detached themselves. This reads a pid off disk. If that pid does not lead its own group,
|
|
159
|
+
* `-pid` names no group of this run's, so the signals go to the pid alone. Nothing at or below pid 1 is
|
|
160
|
+
* ever signalled: `-1` reaches every process this user may signal.
|
|
161
|
+
*/
|
|
162
|
+
export async function endEngineChild(rec, {
|
|
163
|
+
graceMs = 5000, settleMs = 2000, pollMs = 50,
|
|
164
|
+
kill = (target, sig) => process.kill(target, sig),
|
|
165
|
+
isLive = (r) => engineChildIsLive(r),
|
|
166
|
+
pgidOf = (pid) => procPgid(pid),
|
|
167
|
+
sleep = (ms) => new Promise((res) => setTimeout(res, ms)),
|
|
168
|
+
now = () => Date.now(),
|
|
169
|
+
} = {}) {
|
|
170
|
+
if (!rec || !Number.isInteger(rec.pid) || rec.pid <= 1)
|
|
171
|
+
return { signalled: null, escalated: null, group: false, ended: false, error: "no process to signal" };
|
|
172
|
+
const group = pgidOf(rec.pid) === rec.pid;
|
|
173
|
+
const target = group ? -rec.pid : rec.pid;
|
|
174
|
+
const send = (sig) => { try { kill(target, sig); return null; } catch (e) { return e?.code ?? String(e?.message ?? e); } };
|
|
175
|
+
// Signal 0 asks only whether the group still has a member. EPERM is a member this user may not
|
|
176
|
+
// signal, and a member all the same. Once the leader has exited, the start-time check no longer covers
|
|
177
|
+
// this number: `-pid` names the run's group because a group's number is not reused while any member of
|
|
178
|
+
// it lives. What is left uncovered is a reuse, by a new process that then leads a group of its own,
|
|
179
|
+
// inside one poll after the run's group has emptied.
|
|
180
|
+
const groupHasMembers = () => {
|
|
181
|
+
if (!group) return false;
|
|
182
|
+
try { kill(-rec.pid, 0); return true; } catch (e) { return e?.code === "EPERM"; }
|
|
183
|
+
};
|
|
184
|
+
const gone = () => !isLive(rec) && !groupHasMembers();
|
|
185
|
+
const seenGone = async (ms) => {
|
|
186
|
+
const until = now() + ms;
|
|
187
|
+
for (;;) {
|
|
188
|
+
if (gone()) return true;
|
|
189
|
+
if (now() >= until) return false;
|
|
190
|
+
await sleep(pollMs);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// ESRCH: it exited between the caller's liveness read and this signal, a race lost harmlessly. EPERM:
|
|
195
|
+
// it is not ours to signal, which must not read as success. Either way nothing was sent.
|
|
196
|
+
const refused = send("SIGTERM");
|
|
197
|
+
if (refused) return { signalled: null, escalated: null, group, ended: false, error: refused };
|
|
198
|
+
if (await seenGone(graceMs)) return { signalled: "SIGTERM", escalated: null, group, ended: true };
|
|
199
|
+
|
|
200
|
+
// Still there when the grace ran out. SIGKILL cannot be handled or ignored.
|
|
201
|
+
const killRefused = send("SIGKILL");
|
|
202
|
+
if (killRefused && killRefused !== "ESRCH")
|
|
203
|
+
return { signalled: "SIGTERM", escalated: null, group, ended: gone(), error: killRefused };
|
|
204
|
+
return { signalled: "SIGTERM", escalated: killRefused ? null : "SIGKILL", group, ended: await seenGone(settleMs) };
|
|
205
|
+
}
|
|
@@ -2219,7 +2219,12 @@ export const KNOCKOUT_FINDING_TYPES = [
|
|
|
2219
2219
|
"Famous Brand", "Active Business", "Cultural Reference", "Domain", "Descriptive Use",
|
|
2220
2220
|
"Negative Association", "Competitor Intelligence",
|
|
2221
2221
|
];
|
|
2222
|
-
|
|
2222
|
+
// THE ONE LIST of a knockout finding's keys. The recording transport's allowlist takes it from here
|
|
2223
|
+
// (knockout-assess-record.mjs), so the call and the validator cannot come to disagree. They did: the
|
|
2224
|
+
// transport allowed `weighedFilings` and this list refused it, so a seat that sent what its doctrine
|
|
2225
|
+
// teaches had the whole stage refused, and the retry dropped the key the report's source chip is derived
|
|
2226
|
+
// from. `weighedFilings` is optional; verify-knockout.mjs joins every id against the run's own records.
|
|
2227
|
+
export const KNOCKOUT_FINDING_KEYS = Object.freeze(["ordinal", "name", "owner", "band", "net", "type", "evidence", "basis", "weighedFilings"]);
|
|
2223
2228
|
// The throw family is `knockout_`, NOT `findings_`, and that is deliberate: gateway.mjs's
|
|
2224
2229
|
// repairSiblingName routes every `/findings?_/` token to **findings.json**, which is the clearance
|
|
2225
2230
|
// artifact and does not exist on a knockout run. A knockout token borrowing that family would aim its
|
|
@@ -67,6 +67,8 @@ import { refuseUndeclared as refuseUndeclaredShared, keepIfAbsent, lastAccepted,
|
|
|
67
67
|
// THE VALIDATOR'S OWN PREDICATE, imported rather than restated. Two spellings of one closed set is how
|
|
68
68
|
// the call and the stage come to disagree about what is legal, which is the defect this check closes.
|
|
69
69
|
import { normalizeKnockoutQualifier, KNOCKOUT_RATING_QUALIFIERS } from "./verify-knockout.mjs";
|
|
70
|
+
// A FINDING'S KEYS, likewise: the validator's one list, so a key the call records is a key the stage accepts.
|
|
71
|
+
import { KNOCKOUT_FINDING_KEYS } from "./findings-model.mjs";
|
|
70
72
|
|
|
71
73
|
const SCHEMA_VERSION = 1;
|
|
72
74
|
|
|
@@ -146,14 +148,15 @@ const DECLARED = Object.freeze({
|
|
|
146
148
|
// store, so it is a fact and not an echo.
|
|
147
149
|
"registerReads",
|
|
148
150
|
],
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
+
// A finding's closed keys are the VALIDATOR'S OWN LIST, imported rather than restated. This allowlist
|
|
152
|
+
// and that list were two spellings of one closed set, and they disagreed: this one allowed
|
|
153
|
+
// `weighedFilings` and the validator refused it, so the call recorded and the stage failed.
|
|
151
154
|
// — `weighedFilings` is the register record ids this finding's reasoning rests on.
|
|
152
155
|
// It exists so the SOURCE of a finding is a driver fact rather than a label: stages.mjs already
|
|
153
156
|
// classifies source_type as `mechanical:code-extracted` for the clearance lane, on the ground that
|
|
154
157
|
// "the lane that produced the record is a driver fact". The chip is derived from this joined list and
|
|
155
158
|
// from the finding's own receipted evidence — never from a word the seat typed about itself.
|
|
156
|
-
"marks.findings":
|
|
159
|
+
"marks.findings": KNOCKOUT_FINDING_KEYS,
|
|
157
160
|
// `band` is the rater's rating OF THAT FILING, optional, in the framework's own ladder words. It is
|
|
158
161
|
// declared here as well as in the tool schema because this allowlist — not the schema — is what the
|
|
159
162
|
// driver validates against: a key the seat sends and this list omits is refused, so the read would
|
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.0-beta.
|
|
5
|
+
"version": "0.3.0-beta.2",
|
|
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": {
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
// running portal; prefixing it would invalidate every in-flight confirmation on deploy, and it does not
|
|
45
45
|
// need the prefix — one side of a pair is enough to separate the pair.
|
|
46
46
|
import { randomBytes, scryptSync, timingSafeEqual, createHmac } from "node:crypto";
|
|
47
|
-
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
|
|
48
|
-
import { dirname, join } from "node:path";
|
|
47
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from "node:fs";
|
|
48
|
+
import { basename, dirname, join } from "node:path";
|
|
49
49
|
import { homedir } from "node:os";
|
|
50
50
|
import { envPrefix } from "../shared/os-advice.mjs";
|
|
51
51
|
|
|
@@ -165,10 +165,105 @@ export function passphraseResetCommand({ prefix = "", credentialPath = null, env
|
|
|
165
165
|
const base = `${prefix}clearotron passphrase --reset`;
|
|
166
166
|
const path = credentialPath ?? env.PORTAL_LOCAL_CREDENTIAL ?? null;
|
|
167
167
|
if (!path || path === credentialPathFor({}, home)) return base;
|
|
168
|
+
// AN INSTALL'S OWN FILE IS NAMED BY ITS INSTALL, the way `clearotron start` names it: `--base`, which the
|
|
169
|
+
// verb resolves through installCredential exactly as start does, and nothing at all for the default
|
|
170
|
+
// install, where the verb looks first. Every new install keeps its credential in its own directory now,
|
|
171
|
+
// so this is the ordinary line, and it has no environment variable in it to bind to the wrong command.
|
|
172
|
+
if (basename(path) === INSTALL_CREDENTIAL_FILE) {
|
|
173
|
+
const dir = dirname(path);
|
|
174
|
+
if (dir === defaultInstallBase({ home })) return base;
|
|
175
|
+
return `${base} --base ${/\s/.test(dir) ? `"${dir}"` : dir}`;
|
|
176
|
+
}
|
|
168
177
|
// `VAR=value cmd` IS POSIX-ONLY. PowerShell has no such juxtaposition — the assignment is its own
|
|
169
178
|
// statement there — so this line told a Windows reader their variable name was not a cmdlet, naming
|
|
170
179
|
// the wrong half of the command as the fault. Reported from a real run.
|
|
171
|
-
|
|
180
|
+
//
|
|
181
|
+
// AND IT BINDS TO THE COMMAND BESIDE IT, which after a `cd … && ` prefix is `cd`. Printed from a
|
|
182
|
+
// checkout, the line set the variable for the directory change and ran the verb without it, so the
|
|
183
|
+
// reset went to the shared file. Measured by running the printed line, 2026-09-10. So the assignment
|
|
184
|
+
// goes after the prefix's directory change, next to the verb it is for.
|
|
185
|
+
const posix = prefix.lastIndexOf("&& "), ps = prefix.lastIndexOf("; ");
|
|
186
|
+
const at = Math.max(posix < 0 ? 0 : posix + 3, ps < 0 ? 0 : ps + 2);
|
|
187
|
+
return `${prefix.slice(0, at)}${envPrefix("PORTAL_LOCAL_CREDENTIAL", path)}${prefix.slice(at)}clearotron passphrase --reset`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The name an install's own credential has inside the install's directory: the shared default's name,
|
|
192
|
+
* so the two are one kind of file in two places.
|
|
193
|
+
*/
|
|
194
|
+
export const INSTALL_CREDENTIAL_FILE = "portal-local-credential.json";
|
|
195
|
+
|
|
196
|
+
/** The directory `clearotron start` and setup use when none is given: `~/trademark`, or the demo's own. */
|
|
197
|
+
export function defaultInstallBase({ demo = false, home = null } = {}) {
|
|
198
|
+
return join(home ?? homedir(), demo ? "trademark-demo" : "trademark");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* WHICH CREDENTIAL AN INSTALL SIGNS IN WITH, decided here once, so the file `clearotron passphrase` resets
|
|
203
|
+
* is the file the portal reads.
|
|
204
|
+
*
|
|
205
|
+
* A NEW INSTALL GETS ITS OWN, as the demo already does. Every install used to sign in with the one shared
|
|
206
|
+
* file under the operator's home, so an install made on a machine that had held another adopted that
|
|
207
|
+
* install's digest. Its first start said the passphrase "was minted on an earlier start and is NOT
|
|
208
|
+
* reprinted", and the person who had just installed had no passphrase and no way to learn one. Reported
|
|
209
|
+
* from a real install, 2026-09-10.
|
|
210
|
+
*
|
|
211
|
+
* AN INSTALL THAT HAS BEEN USING THE SHARED FILE KEEPS IT. Moving it would stop the passphrase its operator
|
|
212
|
+
* holds from working, and under a service unit the new one would reach only the journal.
|
|
213
|
+
*
|
|
214
|
+
* In order: the operator's own setting; the install's own file when it exists; the install's own file on
|
|
215
|
+
* the install's first start, or when there is no shared file to keep; otherwise the shared file. `source`
|
|
216
|
+
* says which of "configured", "install" and "shared" answered.
|
|
217
|
+
*/
|
|
218
|
+
export function installCredential({ base, env = process.env, home = null, firstStart = false, exists = existsSync } = {}) {
|
|
219
|
+
const configured = String(env.PORTAL_LOCAL_CREDENTIAL ?? "").trim();
|
|
220
|
+
if (configured) return { path: configured, source: "configured" };
|
|
221
|
+
const own = join(base, INSTALL_CREDENTIAL_FILE);
|
|
222
|
+
if (exists(own) || firstStart) return { path: own, source: "install" };
|
|
223
|
+
const shared = credentialPathFor({}, home);
|
|
224
|
+
return exists(shared) ? { path: shared, source: "shared" } : { path: own, source: "install" };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* What a start that minted nothing says about signing in, composed so a test can read the TEXT.
|
|
229
|
+
*
|
|
230
|
+
* THE WAY BACK IN COMES FIRST. The reader holds, at best, a passphrase from a start they may not remember,
|
|
231
|
+
* and at worst none: an install signing in with a file another install wrote. The recovery command used
|
|
232
|
+
* to be the last words of the paragraph. It is the first line now.
|
|
233
|
+
*
|
|
234
|
+
* AND IT SAYS WHICH FILE, WHEN IT WAS WRITTEN AND FOR WHOM, which is all that can be known about a reused
|
|
235
|
+
* credential. Nothing records which install wrote a shared file, so a shared file is called shared rather
|
|
236
|
+
* than attributed to anyone.
|
|
237
|
+
*/
|
|
238
|
+
export function laterStartLines({ user, reset, credentialPath, source = "install", record = null }) {
|
|
239
|
+
const made = record?.createdAt ? `created ${String(record.createdAt).slice(0, 10)}` : "no creation date recorded";
|
|
240
|
+
const lines = [` Sign in as ${user}. No passphrase for it? Run ${reset} to mint a new one; it is printed once.`];
|
|
241
|
+
if (source === "shared") {
|
|
242
|
+
lines.push(` This install signs in with the shared credential ${credentialPath} (${made}${record?.email ? `, for ${record.email}` : ""}).`);
|
|
243
|
+
lines.push(" It is not inside this install's directory, so an earlier install on this machine may have written it.");
|
|
244
|
+
} else {
|
|
245
|
+
lines.push(` Its credential is ${credentialPath} (${made}).`);
|
|
246
|
+
}
|
|
247
|
+
lines.push(" The passphrase was minted on an earlier start and is NOT reprinted: the file holds only a digest of it.");
|
|
248
|
+
return lines;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The demo credential a demo start should replace, or null.
|
|
253
|
+
*
|
|
254
|
+
* A DEMO ALWAYS HAS A WAY IN. A demo run on a machine where an earlier demo had left its credential reused
|
|
255
|
+
* it, minted nothing, and told the visitor the passphrase "was minted on an earlier start": a sign-in they
|
|
256
|
+
* could not pass, reported from a real machine, 2026-09-10. A demo's credential guards invented data in a
|
|
257
|
+
* directory that is removed as one, so every demo start replaces it and prints the new passphrase.
|
|
258
|
+
*
|
|
259
|
+
* ONLY ITS OWN FILE, AND ONLY FOR THE DEMO'S ADDRESS. A credential anywhere else, for another address, or
|
|
260
|
+
* one that cannot be read is left exactly where it is, and the portal's own boot says what is wrong with it.
|
|
261
|
+
*/
|
|
262
|
+
export function demoCredentialToReplace({ path, ownPath, user, read = readLocalCredential }) {
|
|
263
|
+
if (!path || path !== ownPath) return null;
|
|
264
|
+
let rec;
|
|
265
|
+
try { rec = read(path); } catch { return null; }
|
|
266
|
+
return rec && rec.email === String(user ?? "").trim().toLowerCase() ? path : null;
|
|
172
267
|
}
|
|
173
268
|
|
|
174
269
|
/**
|
|
@@ -1998,7 +1998,12 @@ export function makePortalService({
|
|
|
1998
1998
|
// immediate mode carries `immediate.pid` — a process id on the box — and this response goes to
|
|
1999
1999
|
// a browser. Nothing in the client has ever read `upstream`; what a reader needs is which stop
|
|
2000
2000
|
// is happening and the sentence the driver already composed for them.
|
|
2001
|
-
|
|
2001
|
+
//
|
|
2002
|
+
// AND "IMMEDIATE" MEANS THE STEP WAS SEEN TO END. `signalled` says only that a signal was accepted
|
|
2003
|
+
// for delivery; `ended` is what the driver saw afterwards. An answer without it, an older driver's
|
|
2004
|
+
// `ended: null` included, is the boundary stop: that is the honest reading of an answer that does
|
|
2005
|
+
// not say.
|
|
2006
|
+
const mode = r?.immediate?.attempted && r?.immediate?.ended === true ? "immediate" : "boundary";
|
|
2002
2007
|
audit({ event: "stop", by: principal.email, account, runId, ok: Boolean(r?.ok), action: r?.action ?? null,
|
|
2003
2008
|
// ASKED and TAKEN, both, because they differ exactly when something went wrong — a reader
|
|
2004
2009
|
// pressed "stop now" and got the boundary stop, which is the row somebody will come looking
|
|
@@ -3282,6 +3287,45 @@ export function opsTokenPosture(token, { now = Date.now() } = {}) {
|
|
|
3282
3287
|
// Best-effort, always. A health probe that 500s because git is slow has turned a diagnostic into an
|
|
3283
3288
|
// outage — the whole point is that it answers from any account, at any time, without a grant.
|
|
3284
3289
|
const STORE_TTL_MS = 30_000;
|
|
3290
|
+
/**
|
|
3291
|
+
* The skills overlay this portal resolves frameworks through, decided once at boot, and the one line
|
|
3292
|
+
* that says what was decided.
|
|
3293
|
+
*
|
|
3294
|
+
* DERIVED MEANS "IF THERE IS ONE". When the operator set nothing, PROFILE_REPO_ROOT names the config
|
|
3295
|
+
* store and the store's layout puts instruction overrides in its `skills` folder. A folder that is
|
|
3296
|
+
* absent or empty overrides nothing, which is the state setup leaves on purpose: it keeps
|
|
3297
|
+
* CLEAROTRON_INSTRUCTIONS_DIR unset so the product's own instructions are used. Pinning the folder anyway
|
|
3298
|
+
* made every framework read throw `skills_overlay_unreadable`, so the company profile answered 500 on
|
|
3299
|
+
* a store with no `skills` folder, and the boot told a fresh install its frameworks might be synthetic.
|
|
3300
|
+
* Pinned to an EMPTY folder inside the store's repository, the doctrine-store verdict read "blocked",
|
|
3301
|
+
* because the checkout tracks no file under it. Unset reads "pass". Both measured, 2026-09-10.
|
|
3302
|
+
*
|
|
3303
|
+
* A folder that EXISTS AND CANNOT BE READ is still pinned, so every read of it keeps refusing by name
|
|
3304
|
+
* rather than falling back, and a WARNING says why. An overlay the OPERATOR set is theirs: nothing here
|
|
3305
|
+
* changes it, and the warning for one this process cannot see is unchanged.
|
|
3306
|
+
*
|
|
3307
|
+
* `posture` is the demo's own sentence. In a demo it stands wherever this would otherwise say more.
|
|
3308
|
+
*/
|
|
3309
|
+
export function skillsOverlayAtBoot({ explicit = null, profileRepoRoot = null, readdir = readdirSync, exists = existsSync, posture = null } = {}) {
|
|
3310
|
+
const synthetic = "customer risk frameworks will resolve to this repo's demo fixtures and the Brand profile page will "
|
|
3311
|
+
+ "show either \"could not be read\" or a SYNTHETIC framework as though it were the customer's own. "
|
|
3312
|
+
+ "Set CLEAROTRON_INSTRUCTIONS_DIR (or PROFILE_REPO_ROOT) to the config store.";
|
|
3313
|
+
if (explicit) return { pin: null, line: exists(explicit) ? null : (posture || `WARNING: skills overlay unreadable (${explicit}) — ${synthetic}`) };
|
|
3314
|
+
if (!profileRepoRoot) return { pin: null, line: posture || `WARNING: skills overlay unset — ${synthetic}` };
|
|
3315
|
+
const dir = join(profileRepoRoot, "skills");
|
|
3316
|
+
const nothing = (how) => posture || (`skills overlay: ${dir} ${how}, so this install overrides nothing and the product's own `
|
|
3317
|
+
+ "instruction files are used. To override one, put the file there, commit it, set CLEAROTRON_INSTRUCTIONS_DIR to that folder, and restart.");
|
|
3318
|
+
let entries;
|
|
3319
|
+
try { entries = readdir(dir); }
|
|
3320
|
+
catch (e) {
|
|
3321
|
+
if (e?.code === "ENOENT") return { pin: null, line: nothing("does not exist") };
|
|
3322
|
+
return { pin: dir, line: posture || (`WARNING: skills overlay ${dir} exists and cannot be read (${e?.code ?? String(e?.message ?? e)}) — `
|
|
3323
|
+
+ "every instruction read will refuse by name rather than fall back. Make the folder readable by this process, or remove it if this install overrides nothing.") };
|
|
3324
|
+
}
|
|
3325
|
+
if (!entries.length) return { pin: null, line: nothing("is empty") };
|
|
3326
|
+
return { pin: dir, line: `skills overlay derived from PROFILE_REPO_ROOT: ${dir}` };
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3285
3329
|
let storeCache = { at: 0, value: null };
|
|
3286
3330
|
function doctrineStore(now = Date.now) {
|
|
3287
3331
|
const t = now();
|
|
@@ -4467,34 +4511,26 @@ const PORT = PORT_CHOICE.port;
|
|
|
4467
4511
|
// profile-service's own unit sets the instructions dir correctly — neither should learn a fallback
|
|
4468
4512
|
// from the portal's mistake. Setting the env var (rather than threading a value) is what the
|
|
4469
4513
|
// getter reads, and this process never runs the engine.
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
//
|
|
4478
|
-
//
|
|
4479
|
-
//
|
|
4480
|
-
const
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
// the class of thing that must stay loud. The defect was the audience, not the content.
|
|
4491
|
-
const posture = demoPostureLine(process.env);
|
|
4492
|
-
if (posture) log(posture);
|
|
4493
|
-
else log(`WARNING: skills overlay ${overlay ? `unreadable (${overlay})` : "unset"} — customer risk `
|
|
4494
|
-
+ `frameworks will resolve to this repo's demo fixtures and the Brand profile page will show `
|
|
4495
|
-
+ `either "could not be read" or a SYNTHETIC framework as though it were the customer's own. `
|
|
4496
|
-
+ `Set CLEAROTRON_INSTRUCTIONS_DIR (or PROFILE_REPO_ROOT) to the config store.`);
|
|
4497
|
-
}
|
|
4514
|
+
//
|
|
4515
|
+
// DERIVED ONLY WHEN THE STORE HOLDS AN OVERRIDE, and said in one line either way: the rule and its
|
|
4516
|
+
// measurements are at `skillsOverlayAtBoot`. One directory read, once, at boot, mirroring the
|
|
4517
|
+
// roster-vs-ops-token boot check below.
|
|
4518
|
+
//
|
|
4519
|
+
// — SAME FACT, DIFFERENT READER. In a demo there is no customer whose framework could be shown
|
|
4520
|
+
// wrongly: the demo's company is marked demo data and rates under the framework shipped for it.
|
|
4521
|
+
// Naming two environment variables and a config store at a first-time visitor tells them the thing
|
|
4522
|
+
// they just started is broken, and this output is what gets captured for the website.
|
|
4523
|
+
// So the demo's posture line stands wherever a warning would.
|
|
4524
|
+
const overlayAtBoot = skillsOverlayAtBoot({
|
|
4525
|
+
explicit: envFrom(process.env, "CLEAROTRON_INSTRUCTIONS_DIR"),
|
|
4526
|
+
profileRepoRoot: process.env.PROFILE_REPO_ROOT || null,
|
|
4527
|
+
posture: demoPostureLine(process.env),
|
|
4528
|
+
});
|
|
4529
|
+
// `pinEnv`, not a bare assignment. This write lands at RUNTIME, long after `applyEnvAliases`
|
|
4530
|
+
// back-filled the spellings at load, so assigning one name reaches only the readers already
|
|
4531
|
+
// converted. `pinEnv` writes every spelling, which is what keeps a half-converted tree honest.
|
|
4532
|
+
if (overlayAtBoot.pin) pinEnv(process.env, "CLEAROTRON_INSTRUCTIONS_DIR", overlayAtBoot.pin);
|
|
4533
|
+
if (overlayAtBoot.line) log(overlayAtBoot.line);
|
|
4498
4534
|
// — SAY IT WHEN IT HAPPENS. The core catches this and reports `commitError` on the response and
|
|
4499
4535
|
// in the audit row, both read by whoever made the save and nobody else. A failed commit leaves a
|
|
4500
4536
|
// permanent sync blocker, so the service journal needs it too: the boot check above removes the
|
|
@@ -201,7 +201,7 @@ export async function buildKnockoutWorkbook(findings, receipts, outPath, registe
|
|
|
201
201
|
'Trademark': r.mark ?? '—', 'Owner': r.owner ?? '—', 'Status': r.status ?? '—',
|
|
202
202
|
'Classes': (r.classes ?? []).join(', ') || '—', 'Territory': r.territory ?? '—',
|
|
203
203
|
'Filed': r.applicationDate ?? '—', 'Registered': r.registrationDate ?? '—',
|
|
204
|
-
'Record': r.url ?? r.recordId ?? '—', 'Note': '',
|
|
204
|
+
'Record': r.officeLink?.href ?? r.officeLink?.label ?? r.url ?? r.recordId ?? '—', 'Note': r.officeLink && !r.officeLink.href ? reasonCellFor(r.officeLink) : '',
|
|
205
205
|
});
|
|
206
206
|
}
|
|
207
207
|
// Every search that did NOT answer gets its own row. Without them a mark with two dead searches
|
|
@@ -398,6 +398,12 @@ export async function publishKnockout({ runId, codename, runDir, findings, plan,
|
|
|
398
398
|
+ `cannot produce reduced to the record number — ${recordLinksDropped.slice(0, 3).map((d) => d.was).join(', ')}`
|
|
399
399
|
+ `${recordLinksDropped.length > 3 ? ` and ${recordLinksDropped.length - 3} more` : ''}`);
|
|
400
400
|
}
|
|
401
|
+
// THE OFFICE'S OWN PAGE FOR EACH LISTED FILING, where the run's register publishes none of its own:
|
|
402
|
+
// the addressing the clearance gives its register findings (office-record-links.mjs), set on the
|
|
403
|
+
// sidecar here for the same reason the normalisation above is. Keyed on the run's own provider, and
|
|
404
|
+
// the tally goes to meta.json, so numbers that never fit show as a count rather than as silence.
|
|
405
|
+
const officeLinks = addressListedFilings(registerRecords);
|
|
406
|
+
if (officeLinks) note(`[record-links] ${officeLinks.summary}`);
|
|
401
407
|
|
|
402
408
|
// ── Predelivery lint — the APPLICABLE subset, FLAGS not FAILS (2026-07-31) ─────────────────────────
|
|
403
409
|
// This lane wrote no lint receipt at all until now (docs/DELIVERY.md decision memo, updated in the
|
|
@@ -641,6 +647,7 @@ export async function publishKnockout({ runId, codename, runDir, findings, plan,
|
|
|
641
647
|
registerCounts: registerCounts
|
|
642
648
|
? { provider: registerCounts.provider, takenAt: registerCounts.takenAt, marks: registerCounts.marks?.length ?? 0, counted: countedMarks(registerCounts) }
|
|
643
649
|
: undefined,
|
|
650
|
+
recordLinks: officeLinks?.tally ?? undefined, // per office: linked, or cited by number and why; only where the register has no record pages
|
|
644
651
|
recipe: searchPolicy?.recipe ?? undefined,
|
|
645
652
|
enqueuedVia: searchPolicy?.enqueuedVia ?? undefined,
|
|
646
653
|
parentRunId: searchPolicy?.parentRunId ?? undefined,
|
|
@@ -742,3 +749,7 @@ export function knockoutDocumentRoutes(reports, { auditFile = null } = {}) {
|
|
|
742
749
|
...(auditFile ? [`The receipts are in the audit workbook: \`${auditFile}\`.`] : []),
|
|
743
750
|
];
|
|
744
751
|
}
|
|
752
|
+
|
|
753
|
+
// The office's own page for each listed filing (office-record-links.mjs). Kept down here, below every
|
|
754
|
+
// line the rest of the tree cites by number.
|
|
755
|
+
import { addressListedFilings, reasonCellFor } from './office-record-links.mjs';
|