clearotron 0.3.0-beta.2 → 0.3.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTALL.md +32 -6
- package/bin/connect.mjs +68 -25
- package/bin/disconnect.mjs +16 -11
- package/bin/onboard.mjs +65 -6
- package/bin/start.mjs +7 -1
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +38 -0
- package/driver/driver.config.mjs +5 -0
- package/driver/engine/anthropic-agent.mjs +34 -17
- package/driver/gateway.mjs +76 -15
- package/driver/package.json +1 -1
- package/driver/portal-service.mjs +1 -1
- package/driver/profile-service.mjs +31 -3
- package/driver/publish/index.mjs +12 -9
- package/driver/publish/knockout.mjs +4 -2
- package/driver/publish/office-record-links.mjs +56 -21
- package/driver/recipe-service.mjs +14 -5
- package/driver/record-origins.mjs +14 -0
- package/driver/suite-census.json +34 -28
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/lib/driver.mjs +2 -0
- package/mcp-server/lib/knockout.mjs +2 -2
- package/mcp-server/lib/options.mjs +9 -2
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-CsCuPshD.css → index-Cv-E_agg.css} +199 -86
- package/portal-ui/dist/assets/{index-CcFjgM78.js → index-DWYCsOCJ.js} +514 -380
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/clarivate/src/core.js +5 -0
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/e2e-unread-terminals.mjs +1 -1
- package/scripts/e2e.mjs +114 -9
- package/scripts/revisit-render-check.mjs +22 -4
- package/scripts/travelling-predicates.mjs +1 -1
- package/shared/connect-clients.mjs +282 -321
- package/shared/names-in-force.mjs +1 -0
- package/shared/stdio-connect.mjs +63 -0
- package/shared/store-in-repo.mjs +50 -2
package/driver/gateway.mjs
CHANGED
|
@@ -1052,6 +1052,10 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
|
|
|
1052
1052
|
grant: gatherAllowedTools,
|
|
1053
1053
|
})
|
|
1054
1054
|
: null;
|
|
1055
|
+
// The start of this attempt's window in the per-run refusal journals. Taken before the witness and
|
|
1056
|
+
// the snapshot, which only widens the window by their own time: an earlier attempt's refusals all
|
|
1057
|
+
// precede its own settle, so they stay outside it.
|
|
1058
|
+
const dispatchedAt = Date.now();
|
|
1055
1059
|
for (const line of describeMethodologyDrift(witnessStageMethodology(runDir, name, effMessage, engineResolveSkill)))
|
|
1056
1060
|
note(`[${name}] ${line}`);
|
|
1057
1061
|
// — the frozen judged-by set, hashed into THIS PROCESS'S MEMORY before the seat runs. Never
|
|
@@ -1368,7 +1372,7 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
|
|
|
1368
1372
|
// ONE artifact judgement, shared by both rescues below (the rule: never a second, drifting copy of
|
|
1369
1373
|
// the contract). Present + written by THIS attempt (the per-attempt snapshot — an inherited file and
|
|
1370
1374
|
// equally an earlier attempt's file never rescue a failed turn) + passes the stage's own validator.
|
|
1371
|
-
const attemptWroteTruth = () => {
|
|
1375
|
+
const attemptWroteTruth = (why = null) => {
|
|
1372
1376
|
// — the rescues judge with `validate` directly rather than through judgeArtifacts, so the union
|
|
1373
1377
|
// has to run here too or a rescued turn would be refused for rows the form already holds. Idempotent,
|
|
1374
1378
|
// so the double call on the normal path costs a regeneration and changes nothing.
|
|
@@ -1381,16 +1385,21 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
|
|
|
1381
1385
|
// answer for a killed-in-the-gap attempt is no. Idempotent, so the double call costs a regeneration.
|
|
1382
1386
|
const pu = syncPlacementForm(files);
|
|
1383
1387
|
if (pu) lastPlacementUnion = pu;
|
|
1388
|
+
// `why`, when a caller passes one, is told WHICH file failed and on what, so a refusal can name both.
|
|
1384
1389
|
return files.every((f) => {
|
|
1385
1390
|
const now = statOf(f);
|
|
1386
|
-
|
|
1387
|
-
if (
|
|
1391
|
+
const no = (cause, reason) => { if (why) Object.assign(why, { file: rel(f), cause, ...(reason ? { reason } : {}) }); return false; };
|
|
1392
|
+
if (now === null) return no("absent");
|
|
1393
|
+
if (now === preArtifact.get(f)) return no("not-written-by-this-attempt");
|
|
1394
|
+
if (validate) { const v = validate(f, readFileSync(f, "utf8")); if (!v.ok) return no("invalid", v.reason ? String(v.reason) : undefined); }
|
|
1388
1395
|
return true;
|
|
1389
1396
|
});
|
|
1390
1397
|
};
|
|
1391
1398
|
let rescued = null;
|
|
1392
1399
|
let quiescentMs = null; //: how long the artifact had been untouched when the turn settled
|
|
1393
|
-
let rescueRefused = null; //: WHICH of the rescue's
|
|
1400
|
+
let rescueRefused = null; //: WHICH of the rescue's four causes refused — on the row, not only in a note
|
|
1401
|
+
let rescueRefusedFile = null, rescueRefusedReason = null; //: and which file, and on what
|
|
1402
|
+
let attemptRefusals = null; //: this dispatch's refusals of a tool-written artifact, on a timeout
|
|
1394
1403
|
if (fail && /^nonzero_exit_/.test(fail) && files.length) {
|
|
1395
1404
|
if (killClass || killSeen) {
|
|
1396
1405
|
note(`[${name}] ${fail} with a kill-class attempt in this ladder — the exit-1 rescue stays CLOSED (a killed turn's artifact may be torn mid-write and a validator cannot prove it whole); failing honestly instead`);
|
|
@@ -1427,16 +1436,28 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
|
|
|
1427
1436
|
//
|
|
1428
1437
|
// A stage with no validator, or whose artifact fails it, or which never wrote, is untouched.
|
|
1429
1438
|
else if (fail === "timeout" && turn.signals?.hardWall && files.length && validate && wallRescueEnabled()) {
|
|
1430
|
-
// —
|
|
1431
|
-
//
|
|
1432
|
-
//
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1439
|
+
// — A FILE THAT COULD NOT BE STAT'ED IS NAMED, WITH WHY, rather than folded into a number. It
|
|
1440
|
+
// used to be a -1 in `quiescentMs` and the cause `artifact-unreadable`, and the commonest way to get
|
|
1441
|
+
// there is not a filesystem fault at all: the turn was killed before it wrote. Measured on two
|
|
1442
|
+
// stages of one run, 2026-09-10: both rows read `wrote: false`, `quiescentMs: -1`,
|
|
1443
|
+
// `artifact-unreadable`, for files that did not exist. An ABSENT file (ENOENT) is its own cause now;
|
|
1444
|
+
// `artifact-unreadable` stays for a stat that fails any other way, with its error code. Rows
|
|
1445
|
+
// written before this change say `artifact-unreadable` for both.
|
|
1446
|
+
//
|
|
1447
|
+
// `quiescentMs` IS A MEASUREMENT OR IT IS ABSENT. With a file unstat'able there is no quiescence of
|
|
1448
|
+
// the set to measure, so it stays null and the row omits it: a -1 reads as a number to anything that
|
|
1449
|
+
// averages or compares it. An mtime that lands after the settle instant (written in that same
|
|
1450
|
+
// instant, or a clock step) measures 0 rather than a negative; it is the under-quiescence case.
|
|
1451
|
+
let missing = null;
|
|
1452
|
+
const quiet = [];
|
|
1453
|
+
for (const f of files) {
|
|
1454
|
+
try { quiet.push(Math.max(0, settledAt - statSync(f).mtimeMs)); }
|
|
1455
|
+
catch (e) { if (!missing) missing = { file: rel(f), code: String(e?.code ?? "unknown") }; }
|
|
1456
|
+
}
|
|
1457
|
+
quiescentMs = missing || !quiet.length ? null : Math.min(...quiet);
|
|
1438
1458
|
const bar = wallRescueQuiesceMs();
|
|
1439
|
-
|
|
1459
|
+
const why = {};
|
|
1460
|
+
if (quiescentMs !== null && quiescentMs >= bar && attemptWroteTruth(why)) {
|
|
1440
1461
|
rescued = fail;
|
|
1441
1462
|
fail = null;
|
|
1442
1463
|
note(`[${name}] hard-wall kill at ${Math.round(wall)}s, but every expected artifact was written by this attempt, passes its validator and had been untouched for ${Math.round(quiescentMs / 1000)}s when the turn settled — the stage FINISHED and the wall is a fact about the dispatch, not a failure of the stage (the resume's skip path would accept these same bytes)`);
|
|
@@ -1451,12 +1472,24 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
|
|
|
1451
1472
|
// rescue was refused on the third cause, its validator — and 31 minutes of finished work were
|
|
1452
1473
|
// discarded and re-run cold. `grep -c rescue run.jsonl` on that run returns 0. Nothing in the
|
|
1453
1474
|
// record said why, and reconstructing it took a file-mtime comparison against a preserved run dir.
|
|
1454
|
-
rescueRefused =
|
|
1475
|
+
rescueRefused = missing ? (missing.code === "ENOENT" ? "artifact-absent" : "artifact-unreadable")
|
|
1455
1476
|
: quiescentMs < bar ? "under-quiescence"
|
|
1456
1477
|
: "not-written-by-this-attempt-or-invalid";
|
|
1457
|
-
|
|
1478
|
+
// — AND WHICH FILE, AND ON WHAT: an unstat'able file with its error code; under the bar, the file
|
|
1479
|
+
// touched last (`quiescentMs` carries the number); otherwise the first file the artifact judgement
|
|
1480
|
+
// failed, with its cause — `absent`, `not-written-by-this-attempt`, or the validator's own reason.
|
|
1481
|
+
if (missing) { rescueRefusedFile = missing.file; rescueRefusedReason = missing.code; }
|
|
1482
|
+
else if (quiescentMs < bar) rescueRefusedFile = rel(files[quiet.indexOf(quiescentMs)]);
|
|
1483
|
+
else { rescueRefusedFile = why.file ?? null; rescueRefusedReason = why.reason ?? why.cause ?? null; }
|
|
1484
|
+
note(`[${name}] hard-wall kill at ${Math.round(wall)}s — wall rescue REFUSED (${rescueRefused}: ${rescueRefusedFile ?? "no file named"}${rescueRefusedReason ? `, ${rescueRefusedReason}` : quiescentMs < bar ? `, touched ${Math.round(quiescentMs / 1000)}s before the kill, under the ${Math.round(bar / 1000)}s bar` : ""}); failing honestly as timeout`);
|
|
1458
1485
|
}
|
|
1459
1486
|
}
|
|
1487
|
+
// — WHAT A KILLED ATTEMPT WAS DOING, where its transport kept a record. A tool-written artifact's
|
|
1488
|
+
// refusal journal says how many times this attempt sent its record and was told no, and the reason the
|
|
1489
|
+
// last time. The missing-file row has carried that last reason since the journal existed; a timeout row
|
|
1490
|
+
// carried nothing, so an attempt that spent forty minutes being refused read the same as one that never
|
|
1491
|
+
// called. Counted from THIS dispatch only: the journal is per run, and every attempt appends to it.
|
|
1492
|
+
if (fail === "timeout" && files.length) attemptRefusals = refusalsInWindow(files, runDir, dispatchedAt, settledAt);
|
|
1460
1493
|
// Arm the ladder-wide refusal for every LATER attempt. Read before `classifyWedge` below only because
|
|
1461
1494
|
// the rescue above needs it; the two can never disagree in reach — isTaintRow excludes lane_wedge, and
|
|
1462
1495
|
// a wedge is a `timeout` fail that breaks the ladder immediately, so no later attempt exists to judge.
|
|
@@ -1675,6 +1708,8 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
|
|
|
1675
1708
|
// arm did not apply (no wall, no validator, no declared file).
|
|
1676
1709
|
quiescentMs: Number.isFinite(quiescentMs) ? Math.round(quiescentMs) : undefined,
|
|
1677
1710
|
rescueRefused: rescueRefused ?? undefined, // — the cause, when the rescue looked and refused
|
|
1711
|
+
rescueRefusedFile: rescueRefusedFile ?? undefined, rescueRefusedReason: rescueRefusedReason ?? undefined,
|
|
1712
|
+
refusedCalls: attemptRefusals ?? undefined, // — {count, last}: this dispatch's refusals, on a timeout
|
|
1678
1713
|
//: the verbatim message this attempt was dispatched with — {file, sha, bytes, chars, kind}.
|
|
1679
1714
|
// null when the run has no directory or the gate is off; {present:false, error} when the write
|
|
1680
1715
|
// failed. An absence is a record here, never a silence.
|
|
@@ -1740,6 +1775,8 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
|
|
|
1740
1775
|
rescued: rescued ?? undefined, killed: killed || undefined,
|
|
1741
1776
|
quiescentMs: Number.isFinite(quiescentMs) ? Math.round(quiescentMs) : undefined, // — see the per-stage row
|
|
1742
1777
|
rescueRefused: rescueRefused ?? undefined, // — the cause, when the rescue looked and refused
|
|
1778
|
+
rescueRefusedFile: rescueRefusedFile ?? undefined, rescueRefusedReason: rescueRefusedReason ?? undefined,
|
|
1779
|
+
refusedCalls: attemptRefusals ?? undefined, // — see the per-stage row
|
|
1743
1780
|
// — AND THE BILLING PAIR, by the same argument makes for the model pair one field up:
|
|
1744
1781
|
// the spine carries it or the two logs disagree about what ran. This is the row a sweep across
|
|
1745
1782
|
// archived runs actually reads, and the question "has this box ever billed API" could not be
|
|
@@ -2007,6 +2044,30 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
|
|
|
2007
2044
|
return { ok: false, attempts: attempt, fail: lastFail, sessionKey: lastKey, modelWire: lastModelWire, modelUsed: lastModelUsed, attemptFails: [...attemptFails], warmEscalated: warmEscalatedAt > 0 || undefined, quantity: lastQuantity, reads: lastReads, readsTruncated: lastReadsTruncated, warm: lastWarm, wrote: lastWrote, formRepairs: formRepairsUsed };
|
|
2008
2045
|
}
|
|
2009
2046
|
|
|
2047
|
+
/**
|
|
2048
|
+
* One dispatch's refusals, read from the refusal journals of the tool-written artifacts in `files`.
|
|
2049
|
+
* An entry is placed by its `at`; one with no readable `at` cannot be placed in a dispatch, so it is
|
|
2050
|
+
* counted apart rather than guessed into this one.
|
|
2051
|
+
* @returns {{ count: number, last: string|null, unattributed?: number } | null} null when no file in
|
|
2052
|
+
* `files` has a refusal journal, so "this stage keeps no journal" never reads as "nothing was refused".
|
|
2053
|
+
*/
|
|
2054
|
+
function refusalsInWindow(files, runDir, from, to) {
|
|
2055
|
+
let journals = 0, count = 0, unattributed = 0, last = null, lastAt = -Infinity;
|
|
2056
|
+
for (const f of files) {
|
|
2057
|
+
const reader = toolWrittenArtifact(f)?.refusals;
|
|
2058
|
+
if (!reader) continue;
|
|
2059
|
+
journals++;
|
|
2060
|
+
for (const r of reader(runDir, f) ?? []) {
|
|
2061
|
+
const at = Date.parse(String(r?.at ?? ""));
|
|
2062
|
+
if (!Number.isFinite(at)) { unattributed++; continue; }
|
|
2063
|
+
if (at < from || at > to) continue;
|
|
2064
|
+
count++;
|
|
2065
|
+
if (at >= lastAt) { lastAt = at; last = String(r?.reason ?? ""); }
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
return journals ? { count, last, ...(unattributed ? { unattributed } : {}) } : null;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2010
2071
|
function rel(p) {
|
|
2011
2072
|
const i = p.indexOf("/prelim-search/");
|
|
2012
2073
|
return i >= 0 ? p.slice(i + 1) : p;
|
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.4",
|
|
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": {
|
|
@@ -4589,7 +4589,7 @@ const PORT = PORT_CHOICE.port;
|
|
|
4589
4589
|
const recipeCommit = committing(recipeRepoRoot, "saved-search");
|
|
4590
4590
|
// — same, against the RECIPE repo root, which may differ from the profile one.
|
|
4591
4591
|
const recAudit = makeCommittableAudit({ auditPath: recipeAuditPath, repoRoot: recipeRepoRoot });
|
|
4592
|
-
const recipes = makeRecipeService({ recipesDir, profileDir, gitCommit: recipeCommit, audit: recAudit });
|
|
4592
|
+
const recipes = makeRecipeService({ recipesDir, profileDir, readLayered: true, gitCommit: recipeCommit, audit: recAudit });
|
|
4593
4593
|
callRecipes = (method, path, body, identity) => recipes.route(method, path, { email: identity?.email }, body ?? {});
|
|
4594
4594
|
log(`saved searches ON — store=${recipesDir} repo=${recipeRepoRoot}`);
|
|
4595
4595
|
} else {
|
|
@@ -471,13 +471,41 @@ export function makeProfileService({
|
|
|
471
471
|
throw e;
|
|
472
472
|
}
|
|
473
473
|
|
|
474
|
+
// A CREATE THE STORE CANNOT RECORD IS REFUSED, NOT REPORTED AS DONE. This answered 201 with the
|
|
475
|
+
// company live and a `commitError` beside it, so a machine with no git identity got a company with no
|
|
476
|
+
// record of who made it or when, on its first company, and the store move had been made to close
|
|
477
|
+
// exactly that gap. The store is asked first, and a refusal writes nothing: no profile, no audit row
|
|
478
|
+
// and, since the organisation's grant is filed only on a 201, no grant.
|
|
479
|
+
const cannot = typeof gitCommit?.refusal === "function" ? gitCommit.refusal() : null;
|
|
480
|
+
if (cannot) {
|
|
481
|
+
return { status: 409, json: { error: `No company was created: ${cannot.message}, then try again.`, code: `store_${cannot.code.replace(/-/g, "_")}` } };
|
|
482
|
+
}
|
|
474
483
|
const { files } = writeProfile({ profileDir, key: wanted, profile, contextPack: "" });
|
|
475
|
-
// WRITTEN AND RECORDED ARE TWO EVENTS. The write is live the instant it renames; the commit can
|
|
476
|
-
// fail on its own. Reporting them as one is how somebody is told nothing happened about a company
|
|
477
|
-
// that is already governing runs.
|
|
478
484
|
const message = `chore(clearotron): create company ${wanted} (via portal, by ${by})`;
|
|
479
485
|
const { commit, commitError } = commitWithAuditRow({ audit, gitCommit, files, message, by,
|
|
480
486
|
row: { event: "profile-create", key: wanted, by, fields: Object.keys(profile) } });
|
|
487
|
+
// AND ONE THAT FAILS ANYWAY IS WITHDRAWN. A hook or a full disk can refuse the commit after the store
|
|
488
|
+
// said it could record. The paths this create wrote were absent before it, so they are returned to
|
|
489
|
+
// absent, and a third audit row says so after the two the failed commit left.
|
|
490
|
+
if (commitError) {
|
|
491
|
+
// GIT'S LAST WORD, NOT ITS ECHO. The error opens with the whole command line it ran, message and
|
|
492
|
+
// author included, and ends with the refusal itself: a hook's own sentence, "No space left on
|
|
493
|
+
// device". The operator acts on the last line.
|
|
494
|
+
const cause = String(commitError).trim().split("\n").map((l) => l.trim()).filter(Boolean).pop() ?? String(commitError);
|
|
495
|
+
let left = null;
|
|
496
|
+
try {
|
|
497
|
+
if (typeof gitCommit?.withdraw !== "function") throw new Error("this store cannot withdraw what it wrote");
|
|
498
|
+
gitCommit.withdraw(files);
|
|
499
|
+
} catch (e) { left = String(e?.message ?? e).slice(0, 200); }
|
|
500
|
+
try {
|
|
501
|
+
audit({ event: "profile-create-withdrawn", of: "profile-create", key: wanted, by,
|
|
502
|
+
note: left ? `the create could not be recorded and its files could not be removed (${left})` : "the create could not be recorded, so its files were removed" });
|
|
503
|
+
} catch { /* the refusal below is the report; a journal failure must not mask it */ }
|
|
504
|
+
if (left) {
|
|
505
|
+
return { status: 500, json: { key: wanted, error: `The company could not be recorded (${cause}), and its file could not be removed afterwards (${left}). It is on disk with no record behind it — tell an administrator.` } };
|
|
506
|
+
}
|
|
507
|
+
return { status: 409, json: { error: `No company was created: the store could not record it (${cause}). Nothing was left behind.`, code: "store_commit_failed" } };
|
|
508
|
+
}
|
|
481
509
|
return { status: 201, json: {
|
|
482
510
|
key: wanted, name, written: true, created: true, commit,
|
|
483
511
|
// The receipt reads off THESE, so it can say which framework and how many marketplaces, and
|
package/driver/publish/index.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import { reportIdentityFor, productCoverageNote, isRegisterOnly } from '../searc
|
|
|
22
22
|
import { readRecordArtifacts, bindFindingsToRecords, joinEvidenceStatus } from '../registry-fidelity.mjs';
|
|
23
23
|
import { deliveryFlagLines } from '../predelivery-lint.mjs';
|
|
24
24
|
import { PROVIDERS, config } from '../driver.config.mjs';
|
|
25
|
-
import {
|
|
25
|
+
import { declaredRecordOrigins } from '../record-origins.mjs';
|
|
26
26
|
import { NEUTRAL_DELIVERY, loadProfiles } from '../profiles.mjs';
|
|
27
27
|
import { resolveDemoData, demoBannerMd } from './demo-marking.mjs'; // — one demo question, every product; 2134 — every SURFACE
|
|
28
28
|
import { engineCommit } from '../engine-build.mjs';
|
|
@@ -546,7 +546,7 @@ export async function regenSurfaces(poolRoot) {}
|
|
|
546
546
|
* all three client surfaces state the same link. That agreement is the point:
|
|
547
547
|
* a repair that reached the report and not the workbook would put two
|
|
548
548
|
* different registers in one delivery.
|
|
549
|
-
* @param {string[]|null} origins
|
|
549
|
+
* @param {string[]|null} origins declaredRecordOrigins(the run's own provider). `null` = the run has no fetch
|
|
550
550
|
* receipts (legacy/archived): a NO-OP, byte-identical output, because a run
|
|
551
551
|
* that never named its register cannot be judged against one. `[]` is a
|
|
552
552
|
* different answer — this provider publishes no per-record page at all, so no
|
|
@@ -773,7 +773,8 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
|
|
|
773
773
|
//
|
|
774
774
|
// So this REPAIRS rather than refuses, keyed on the RUN's own provider (the receipts above), never on
|
|
775
775
|
// today's CLEAROTRON_DATABASE — a republished archive keeps its own register.
|
|
776
|
-
|
|
776
|
+
// Receipts that name no provider are read as no receipts: the gate is off, as it is for a legacy run.
|
|
777
|
+
const runOrigins = fetchReceipts ? declaredRecordOrigins(fetchReceipts[0]?.provider) : null;
|
|
777
778
|
const foreignRecordLinks = normalizeRecordLinks(findings, runOrigins);
|
|
778
779
|
// — the SAME list reaches the renderer. normalizeRecordLinks repairs foreign ABSOLUTE links on
|
|
779
780
|
// register-sourced findings; render.mjs separately CONSTRUCTS links from bare record paths, and until
|
|
@@ -859,7 +860,7 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
|
|
|
859
860
|
const esPath = driverDir(runDir ?? dirname(reportMd), 'enforcer-signals.json');
|
|
860
861
|
if (existsSync(esPath)) { const es = JSON.parse(readFileSync(esPath, 'utf8')); if (Array.isArray(es)) enforcerSignals = es; }
|
|
861
862
|
} catch { /* absent — no telemetry lines */ }
|
|
862
|
-
const officeLinks = officeLinksFor(findings, recordsByUri,
|
|
863
|
+
const officeLinks = officeLinksFor(findings, recordsByUri, runOrigins); bindFindingsToRecords(findings, recordsByUri);
|
|
863
864
|
// 404-card caveat (2026-07-22): the V4-2 closure pass persisted every cited record its targeted
|
|
864
865
|
// fetch definitively could not retrieve (predelivery-lint.json artifactSet.recordFetchFailures);
|
|
865
866
|
// the evidence join stamps `_recordFetchFailure` from it so the card render carries the
|
|
@@ -1621,12 +1622,14 @@ export function composeEmailHtml(reportMdPath, url, auditFile, names = [], deliv
|
|
|
1621
1622
|
|
|
1622
1623
|
// ── The office's own page for each fetched record, where the run's register publishes none of its own ──
|
|
1623
1624
|
// Addressed from the record's numbers (office-record-links.mjs), never from the model or the handle, and
|
|
1624
|
-
// null on every
|
|
1625
|
-
//
|
|
1626
|
-
//
|
|
1625
|
+
// null on every register that publishes pages of its own, so those runs publish exactly as before. Which
|
|
1626
|
+
// registers those are is `runOrigins`, the same answer the record-URL normalisation above keys on, so the
|
|
1627
|
+
// two cannot disagree about one register. The tally goes to meta.json and the log, so a register whose
|
|
1628
|
+
// numbers never fit shows up as a count rather than as silence. Kept down here, below every line the rest
|
|
1629
|
+
// of the tree cites by number.
|
|
1627
1630
|
import { recordLinksFor } from './office-record-links.mjs';
|
|
1628
|
-
function officeLinksFor(findings, recordsByUri,
|
|
1629
|
-
const links = recordLinksFor(findings, recordsByUri,
|
|
1631
|
+
function officeLinksFor(findings, recordsByUri, runOrigins) {
|
|
1632
|
+
const links = recordLinksFor(findings, recordsByUri, runOrigins);
|
|
1630
1633
|
if (links) console.log(`[record-links] ${links.summary}`);
|
|
1631
1634
|
return links;
|
|
1632
1635
|
}
|
|
@@ -401,8 +401,9 @@ export async function publishKnockout({ runId, codename, runDir, findings, plan,
|
|
|
401
401
|
// THE OFFICE'S OWN PAGE FOR EACH LISTED FILING, where the run's register publishes none of its own:
|
|
402
402
|
// the addressing the clearance gives its register findings (office-record-links.mjs), set on the
|
|
403
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
|
-
|
|
404
|
+
// the tally goes to meta.json, so numbers that never fit show as a count rather than as silence. A
|
|
405
|
+
// sidecar that names no provider is left as it was, as the normalisation above leaves it.
|
|
406
|
+
const officeLinks = addressListedFilings(registerRecords, declaredRecordOrigins(registerRecords?.provider));
|
|
406
407
|
if (officeLinks) note(`[record-links] ${officeLinks.summary}`);
|
|
407
408
|
|
|
408
409
|
// ── Predelivery lint — the APPLICABLE subset, FLAGS not FAILS (2026-07-31) ─────────────────────────
|
|
@@ -753,3 +754,4 @@ export function knockoutDocumentRoutes(reports, { auditFile = null } = {}) {
|
|
|
753
754
|
// The office's own page for each listed filing (office-record-links.mjs). Kept down here, below every
|
|
754
755
|
// line the rest of the tree cites by number.
|
|
755
756
|
import { addressListedFilings, reasonCellFor } from './office-record-links.mjs';
|
|
757
|
+
import { declaredRecordOrigins } from '../record-origins.mjs';
|
|
@@ -67,9 +67,13 @@ export const OFFICE_RECORD_PAGES = Object.freeze({
|
|
|
67
67
|
fr: { name: "France", address: (r) => addressFrom([r.applicationNumber, r.registrationNumber], /^(?:FR)?(\d{7})$/i, (m) => ({
|
|
68
68
|
number: `FR${m[1]}`, href: `https://data.inpi.fr/marques/FR${m[1]}` })) },
|
|
69
69
|
// The office's record address as indexed; it challenges automated fetches, so none was opened from a
|
|
70
|
-
// server. UK numbers are UK plus eleven digits (UK000..., UK008..., UK009...).
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
// server. UK numbers are UK plus eleven digits (UK000..., UK008..., UK009...). A national number given
|
|
71
|
+
// bare, seven digits as one register hands it over, is the same number under the office's UK0000
|
|
72
|
+
// prefix: UK00003456789 is national mark 3456789.
|
|
73
|
+
gb: { name: "United Kingdom", address: (r) => addressFrom([r.applicationNumber, r.registrationNumber], /^(?:(UK\d{11})|(\d{7}))$/i, (m) => {
|
|
74
|
+
const n = m[1] ? m[1].toUpperCase() : `UK0000${m[2]}`;
|
|
75
|
+
return { number: n, href: `https://trademarks.ipo.gov.uk/ipo-tmcase/page/Results/1/${n}` };
|
|
76
|
+
}) },
|
|
73
77
|
// The register's own search opens records at exactly this address: the application number, then the
|
|
74
78
|
// registration number when there is one. Opened a real record. The older search.patentstyret.no
|
|
75
79
|
// addresses now answer 410 Gone.
|
|
@@ -91,6 +95,12 @@ export const OFFICE_RECORD_PAGES = Object.freeze({
|
|
|
91
95
|
|
|
92
96
|
const handleOffice = (uri) => (/^\/mark\/([a-z]{2,4})\//i.exec(clean(uri))?.[1] ?? "").toLowerCase();
|
|
93
97
|
|
|
98
|
+
// The same office under another register's code. The EUIPO's WIPO ST.3 code is EM, and a register that
|
|
99
|
+
// keeps it (Compumark does) names the office the table holds as EU. An alias, never a second table entry:
|
|
100
|
+
// the table is keyed by the offices, and one office has one address.
|
|
101
|
+
const OFFICE_ALIASES = Object.freeze({ em: "eu" });
|
|
102
|
+
const officeOf = (code) => OFFICE_ALIASES[code] ?? code;
|
|
103
|
+
|
|
94
104
|
// A number that already carries its office's letters (FR4123456, UK00003456789) stands alone; a bare
|
|
95
105
|
// one is shown with its office (CH 08133/2025), so the label always says whose number it is.
|
|
96
106
|
const labelOf = (office, number) => (number ? (/^[a-z]{2}/i.test(number) ? number : `${office.toUpperCase()} ${number}`) : null);
|
|
@@ -108,7 +118,7 @@ const labelOf = (office, number) => (number ? (/^[a-z]{2}/i.test(number) ? numbe
|
|
|
108
118
|
export function officeRecordLink(rec, uri = "") {
|
|
109
119
|
if (!rec || typeof rec !== "object") return null;
|
|
110
120
|
const designation = clean(rec.filingRoute) === "madrid_designation";
|
|
111
|
-
const office = designation ? "wo" : (clean(rec.office).toLowerCase() || handleOffice(uri));
|
|
121
|
+
const office = designation ? "wo" : officeOf(clean(rec.office).toLowerCase() || handleOffice(uri));
|
|
112
122
|
const page = OFFICE_RECORD_PAGES[office];
|
|
113
123
|
const own = designation ? clean(rec.irNumber) : clean(rec.applicationNumber) || clean(rec.registrationNumber);
|
|
114
124
|
const unlinked = (reason) => ({ office, label: labelOf(office, own), href: null, reason });
|
|
@@ -118,17 +128,28 @@ export function officeRecordLink(rec, uri = "") {
|
|
|
118
128
|
return a ? { office, label: labelOf(office, a.number), href: a.href, reason: null } : unlinked("unaddressable");
|
|
119
129
|
}
|
|
120
130
|
|
|
121
|
-
/**
|
|
122
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Does the run's register publish no page per record, so that the office's page is the only link a reader
|
|
133
|
+
* can be given? `origins` is `recordOriginsFor`'s answer for the run's register: the hosts it may put in a
|
|
134
|
+
* record URL. An EMPTY list is that answer, and it is the same one normalizeRecordLinks enforces, which
|
|
135
|
+
* strips every record URL on such a register, so none of its records reaches publish with a link of its
|
|
136
|
+
* own. Null means no register was named, and nothing is addressed.
|
|
137
|
+
*
|
|
138
|
+
* DECIDED BY THE REGISTER'S DECLARATION, NEVER BY A LIST OF VENDOR NAMES. A list here once named one
|
|
139
|
+
* vendor, on the premise that every other register's records carry links of their own. Compumark's do
|
|
140
|
+
* not, and its runs reached the reader with a handle and no reason, while this code knew the address.
|
|
141
|
+
*/
|
|
142
|
+
const publishesNoRecordPages = (origins) => Array.isArray(origins) && origins.length === 0;
|
|
123
143
|
|
|
124
144
|
/**
|
|
125
|
-
* Every registration the findings cite, keyed by its lower-cased uri, for a run whose register
|
|
126
|
-
* `
|
|
127
|
-
*
|
|
128
|
-
* never fit shows up as a count instead of as a report that quietly looks
|
|
145
|
+
* Every registration the findings cite, keyed by its lower-cased uri, for a run whose register may put a
|
|
146
|
+
* record URL on `origins` (see publishesNoRecordPages). Returns null for a register that publishes pages
|
|
147
|
+
* of its own, so those runs render exactly as before. `tally` is what publish records, per office, so
|
|
148
|
+
* that a register whose numbers never fit shows up as a count instead of as a report that quietly looks
|
|
149
|
+
* the way it always did.
|
|
129
150
|
*/
|
|
130
|
-
export function recordLinksFor(findings, recordsByUri,
|
|
131
|
-
if (!
|
|
151
|
+
export function recordLinksFor(findings, recordsByUri, origins) {
|
|
152
|
+
if (!publishesNoRecordPages(origins)) return null;
|
|
132
153
|
const byUri = new Map();
|
|
133
154
|
const tally = { linked: {}, cited: {}, notRetrieved: 0 };
|
|
134
155
|
for (const f of Array.isArray(findings) ? findings : []) {
|
|
@@ -169,17 +190,30 @@ const nameOf = (office) => OFFICE_RECORD_PAGES[office]?.name ?? office.toUpperCa
|
|
|
169
190
|
* NO COUNT. The cards list only some findings' registrations (an off-field finding lists none), so a
|
|
170
191
|
* number here could name more registrations than the reader can find on the page. The workbook lists
|
|
171
192
|
* them all, each with its reason.
|
|
193
|
+
*
|
|
194
|
+
* ONE SENTENCE FOR EVERY OFFICE THE TABLE HOLDS NO ADDRESS FOR, after the per-office ones. A register
|
|
195
|
+
* covering the world cites filings from offices this table does not know: one Compumark knockout on the
|
|
196
|
+
* test instance listed sixteen of them. A sentence each would repeat one caveat sixteen times, which is
|
|
197
|
+
* the repeated note the owner ruled reads to a client as a broken report.
|
|
172
198
|
*/
|
|
173
199
|
export function officeReasonSentences(byUri) {
|
|
174
200
|
const keys = new Set();
|
|
175
|
-
|
|
176
|
-
|
|
201
|
+
const unknown = new Set();
|
|
202
|
+
for (const l of byUri instanceof Map ? byUri.values() : []) {
|
|
203
|
+
if (!l || l.href) continue;
|
|
204
|
+
if (l.reason === "unknown-office") unknown.add(l.office);
|
|
205
|
+
else keys.add(`${l.office} ${l.reason}`);
|
|
206
|
+
}
|
|
207
|
+
const each = [...keys].sort().map((k) => {
|
|
177
208
|
const [office, reason] = k.split(" ");
|
|
178
209
|
const name = nameOf(office);
|
|
179
210
|
if (reason === "no-page") return `${name}: the register publishes no page for a single record, so its registrations are cited by number.`;
|
|
180
|
-
if (reason === "unknown-office") return `${name}: we hold no page address for this register, so its registrations are cited by number.`;
|
|
181
211
|
return `${name}: registrations whose numbers are not in the form the register's page address takes are cited by number, not linked.`;
|
|
182
212
|
});
|
|
213
|
+
const codes = [...unknown].sort().map(nameOf);
|
|
214
|
+
if (codes.length === 1) each.push(`${codes[0]}: we hold no page address for this register, so its registrations are cited by number.`);
|
|
215
|
+
else if (codes.length > 1) each.push(`${codes.join(", ")}: we hold no page address for these registers, so their registrations are cited by number.`);
|
|
216
|
+
return each;
|
|
183
217
|
}
|
|
184
218
|
|
|
185
219
|
/**
|
|
@@ -215,20 +249,21 @@ export function linkCellFor(registrations, byUri) {
|
|
|
215
249
|
|
|
216
250
|
/**
|
|
217
251
|
* A knockout's listed filings, each addressed at its office's page, for a listing taken on a register
|
|
218
|
-
* with no record pages of its own. `doc` is register-records.json
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
252
|
+
* with no record pages of its own. `doc` is register-records.json and `origins` is `recordOriginsFor`'s
|
|
253
|
+
* answer for the register the sidecar names (see publishesNoRecordPages). Each filing that carries an
|
|
254
|
+
* office number gains an `officeLink` in place, as normalizeRegisterRecordLinks rewrites links in place, so
|
|
255
|
+
* the report, the workbook and report-data.json all state the same link. Returns null for a register that
|
|
256
|
+
* publishes pages of its own, whose filings render exactly as before.
|
|
222
257
|
*
|
|
223
258
|
* A FILING WITH NO OFFICE NUMBER IS LEFT AS IT WAS, and shows as it always did. Every listing taken
|
|
224
259
|
* before the numbers were kept is that case, so an archived knockout republishes unchanged. An
|
|
225
260
|
* `officeLink` found already on the sidecar is dropped first: the link is set here, from the numbers, and
|
|
226
261
|
* nowhere else.
|
|
227
262
|
*/
|
|
228
|
-
export function addressListedFilings(doc) {
|
|
263
|
+
export function addressListedFilings(doc, origins) {
|
|
229
264
|
const records = (doc?.marks ?? []).flatMap((m) => m?.records ?? []).filter((r) => r && typeof r === "object");
|
|
230
265
|
for (const r of records) delete r.officeLink;
|
|
231
|
-
if (!
|
|
266
|
+
if (!publishesNoRecordPages(origins)) return null;
|
|
232
267
|
const tally = { linked: {}, cited: {}, noNumber: 0 };
|
|
233
268
|
for (const r of records) {
|
|
234
269
|
const link = officeRecordLink(r, r.recordId);
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
// server-owned (monotonic per save); EVERY write re-runs the SAME load-time validator the driver uses
|
|
25
25
|
// (search-policy.mjs validateRecipe), so the UI can never persist a recipe the driver would later reject;
|
|
26
26
|
// free text gets the profiles.mjs anti-rule prose guards (a recipe must never smuggle a rating rule in as
|
|
27
|
-
// prose — levels select machinery, never rating authority); a recipe belongs to a
|
|
28
|
-
// profile roster
|
|
27
|
+
// prose — levels select machinery, never rating authority); a recipe belongs to a company on the
|
|
28
|
+
// profile roster, Generic included. The routing core (`makeRecipeService`) is fs-only + injected
|
|
29
29
|
// git/audit so it unit-tests offline; auth + rate-limit are wired in the bootstrap.
|
|
30
30
|
|
|
31
31
|
import "../shared/env-local.mjs"; // — FIRST: the CLEAROTRON_* translation must land before any
|
|
@@ -61,6 +61,13 @@ export const componentCatalog = () =>
|
|
|
61
61
|
export function makeRecipeService({
|
|
62
62
|
recipesDir,
|
|
63
63
|
profileDir = undefined, // undefined ⇒ profiles.mjs default dir
|
|
64
|
+
// THE ROSTER THE PORTAL SHOWS, read the way profile-service reads it (its `readLayered` note). The
|
|
65
|
+
// portal passes true: the deployment's store, overlay over base, with `generic` falling through from
|
|
66
|
+
// the product. Read as an explicit directory instead, a store holding no `generic.json` — every fresh
|
|
67
|
+
// install and every demo — throws "generic.json is REQUIRED", the catch below turned that into "no
|
|
68
|
+
// such company", and saved searches answered 404 for every company on the install, one created a
|
|
69
|
+
// minute earlier included, while the same company's projects answered 200.
|
|
70
|
+
readLayered = false,
|
|
64
71
|
loadRecipes = loadRecipesDefault,
|
|
65
72
|
loadProfiles = loadProfilesDefault,
|
|
66
73
|
writeRecipe = defaultWriteRecipe,
|
|
@@ -69,8 +76,10 @@ export function makeRecipeService({
|
|
|
69
76
|
} = {}) {
|
|
70
77
|
const rosterHas = (customer) => {
|
|
71
78
|
try {
|
|
72
|
-
const profiles = profileDir === undefined ? loadProfiles({ force: true }) : loadProfiles({ dir: profileDir, force: true });
|
|
73
|
-
|
|
79
|
+
const profiles = readLayered || profileDir === undefined ? loadProfiles({ force: true }) : loadProfiles({ dir: profileDir, force: true });
|
|
80
|
+
// GENERIC OWNS SAVED SEARCHES LIKE ANY COMPANY (owner ruling, 2026-09-11). It was refused here as not a
|
|
81
|
+
// real customer, and a fresh install, which holds Generic alone, opened Custom searches on an error.
|
|
82
|
+
return profiles.has(customer);
|
|
74
83
|
} catch { return false; } // an unreadable roster fails CLOSED here — writes need a verifiable owner
|
|
75
84
|
};
|
|
76
85
|
const listRow = ([key, r]) => {
|
|
@@ -122,7 +131,7 @@ export function makeRecipeService({
|
|
|
122
131
|
return { status: 404, json: { error: "not_found" } };
|
|
123
132
|
if (method !== "POST") return { status: 405, json: { error: "method_not_allowed" } };
|
|
124
133
|
if (!rosterHas(customer))
|
|
125
|
-
return { status: 400, json: { error: `customer "${customer}" is not on the profile roster — a saved search belongs to a
|
|
134
|
+
return { status: 400, json: { error: `customer "${customer}" is not on the profile roster — a saved search belongs to a company on the roster (create the company first)` } };
|
|
126
135
|
|
|
127
136
|
const incoming = body.recipe;
|
|
128
137
|
if (!incoming || typeof incoming !== "object" || Array.isArray(incoming))
|
|
@@ -43,3 +43,17 @@ export function activeRecordOrigins(env = process.env) {
|
|
|
43
43
|
const id = String(env?.CLEAROTRON_DATABASE ?? "").trim().toLowerCase();
|
|
44
44
|
return id ? recordOriginsFor(id) : null;
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The record origins of the register a stored artefact NAMES — a knockout's listing, a run's fetch
|
|
49
|
+
* receipts — or null when it names none.
|
|
50
|
+
*
|
|
51
|
+
* EMPTY IS "NOBODY SAID", and it turns the gate off, as normalizeRegisterRecordLinks reads the same
|
|
52
|
+
* field: an artefact that never recorded its register cannot be judged against one. An id this table
|
|
53
|
+
* does not know is an answer, and resolves as recordOriginsFor resolves it, to no origins, because no
|
|
54
|
+
* host of its can be established as legitimate.
|
|
55
|
+
*/
|
|
56
|
+
export function declaredRecordOrigins(provider) {
|
|
57
|
+
const id = String(provider ?? "").trim().toLowerCase();
|
|
58
|
+
return id ? recordOriginsFor(id) : null;
|
|
59
|
+
}
|