clearotron 0.2.2 → 0.2.3
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 +28 -0
- package/.nvmrc +1 -1
- package/INSTALL.md +13 -11
- package/README.md +11 -16
- package/THIRD-PARTY-NOTICES.md +1 -1
- package/bin/clearotron.mjs +22 -0
- package/bin/example.mjs +4 -0
- package/bin/onboard.mjs +195 -19
- package/bin/start.mjs +154 -8
- package/build-info.json +2 -2
- package/demo/full-country-search/run/_driver/framework.json +1 -1
- package/demo/global-preliminary-search/run/_driver/framework.json +1 -1
- package/demo/knockout-search/run/_driver/framework.json +1 -1
- package/demo/multi-country-focus-search/run/_driver/framework.json +1 -1
- package/docs/E2E.md +5 -5
- package/docs/PORTAL.md +2 -2
- package/docs/README.md +5 -4
- package/docs/SECURITY.md +17 -0
- package/docs/architecture/04-configuration-reference.md +2 -0
- package/docs/architecture/05-config-governance.md +14 -0
- package/docs/architecture/05-customer-profiles.md +2 -2
- package/docs/configuration.md +4 -3
- package/driver/CHANGELOG.md +56 -0
- package/driver/common-law-receipts.mjs +1 -1
- package/driver/config-staleness.mjs +1 -1
- package/driver/dev-portal.mjs +2 -2
- package/driver/driver.config.mjs +1 -1
- package/driver/engine/cli-version.mjs +103 -0
- package/driver/enqueue-schema.mjs +8 -8
- package/driver/feedback-store.mjs +1 -1
- package/driver/findings-model.mjs +52 -2
- package/driver/flag-snapshot.mjs +20 -0
- package/driver/framework.mjs +1 -1
- package/driver/gateway.mjs +18 -1
- package/driver/package.json +3 -3
- package/driver/party-facts.mjs +2 -2
- package/driver/phase0.mjs +1 -1
- package/driver/pipeline.mjs +5 -5
- package/driver/plain-register.mjs +120 -0
- package/driver/portal-access.mjs +1 -1
- package/driver/portal-config-view.mjs +40 -1
- package/driver/portal-families.mjs +1 -1
- package/driver/portal-report.mjs +1 -1
- package/driver/portal-service.mjs +26 -7
- package/driver/portal-upstream.mjs +2 -2
- package/driver/predelivery-lint.mjs +121 -1
- package/driver/profile-page.html +10 -2
- package/driver/profile-service.mjs +4 -4
- package/driver/profiles/README.md +3 -3
- package/driver/profiles.mjs +144 -12
- package/driver/publish/index.mjs +2 -2
- package/driver/publish/knockout.mjs +7 -1
- package/driver/publish/render-knockout.mjs +453 -118
- package/driver/publish/render.mjs +2 -2
- package/driver/reference-strip-signatures.mjs +20 -0
- package/driver/search-policy.mjs +2 -2
- package/driver/skills/blind-frame/SKILL.md +1 -1
- package/driver/skills/knockout-assess/SKILL.md +84 -0
- package/driver/skills/matter-frame/SKILL.md +6 -6
- package/driver/skills/matter-frame/watchlist-reference.md +2 -2
- package/driver/skills/prelim-common-law/SKILL.md +1 -1
- package/driver/skills/prelim-search/SKILL.md +1 -1
- package/driver/skills/prelim-search/report-prose.md +82 -0
- package/driver/skills/prelim-search/risk-framework-demo.manifest.json +1 -1
- package/driver/skills/prelim-search/risk-framework-triage.manifest.json +1 -1
- package/driver/skills/prelim-search/risk-framework.manifest.json +1 -1
- package/driver/skills/prelim-search/synthesis-rules.md +1 -1
- package/driver/skills/prelim-variants/SKILL.md +3 -3
- package/driver/stages.mjs +4 -4
- package/driver/suite-census.json +134 -38
- package/driver/unit-inventory.mjs +184 -27
- package/driver/verify.mjs +1 -1
- package/driver/whatif-memo-run.mjs +26 -4
- package/driver/whatif-memo.mjs +19 -1
- package/examples/README.md +2 -2
- package/examples/grants.example.json +6 -7
- package/mcp-server/CHANGELOG.md +2 -0
- package/mcp-server/lib/scrub.mjs +1 -1
- package/mcp-server/lib/whatif.mjs +2 -2
- package/mcp-server/package.json +1 -1
- package/mcp-server/packs/ops/EXAMPLES.md +2 -2
- package/mcp-server/server.mjs +1 -1
- package/package.json +28 -10
- package/portal-ui/dist/assets/{index-Ct3iQtZ2.css → index-1ziUJX1E.css} +43 -2
- package/portal-ui/dist/assets/{index-BUGgiYtF.js → index-CEYvXdqH.js} +226 -49
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/_shared/term-shape.mjs +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +2 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/uspto-local/src/index-store.js +2 -2
- package/scripts/citation-line-check.mjs +121 -2
- package/scripts/env-classify.mjs +67 -3
- package/scripts/live-surface-check.mjs +38 -1
- package/scripts/nightly-notice.mjs +0 -2
- package/scripts/preinstall-node-check.mjs +24 -0
- package/scripts/strip-tracker-citations.mjs +29 -6
- package/scripts/test-run.mjs +16 -0
- package/shared/anon-overlay.mjs +3 -3
- package/shared/customer-store.mjs +1 -1
- package/shared/invocation.mjs +28 -0
- package/shared/names-in-force.mjs +2 -0
- package/shared/node-floor.mjs +97 -0
- package/shared/staff-domain.mjs +154 -0
- package/shared/store-in-repo.mjs +23 -2
- package/skills/clearotron-ops/SKILL.md +1 -1
- package/driver/recipes/README.md +0 -49
- package/driver/skills/prelim-search/risk-framework-aurora.manifest.json +0 -19
- package/driver/skills/prelim-search/risk-framework-aurora.md +0 -77
- package/driver/skills/prelim-search/risk-framework-zephyr.manifest.json +0 -14
- package/driver/skills/prelim-search/risk-framework-zephyr.md +0 -33
- package/driver/skills/prelim-search/worked-examples-aurora.md +0 -10
- package/driver/skills/prelim-search/worked-examples-zephyr.md +0 -10
- package/scripts/ai-page-render-check.mjs +0 -532
- package/scripts/clearances-render-check.mjs +0 -480
- package/scripts/composer-render-check.mjs +0 -1027
- package/scripts/home-render-check.mjs +0 -526
- package/scripts/portal-lifecycle-check.mjs +0 -639
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
3
|
+
//
|
|
4
|
+
// THE VERSION OF THE BINARY THAT SERVED A RUN, captured at dispatch.
|
|
5
|
+
//
|
|
6
|
+
// The record already says whether the model id the provider reported names a pinned build or an alias it
|
|
7
|
+
// may repoint. That answers "did the model move". It cannot answer "did the TOOL move", and the two are
|
|
8
|
+
// different questions with the same symptom: a run whose judgment differs from last week's.
|
|
9
|
+
//
|
|
10
|
+
// Nothing recorded it. Three archived runs were walked for every spelling of a version field and carried
|
|
11
|
+
// none — so what served them is not recoverable, and the box could have answered at any time.
|
|
12
|
+
//
|
|
13
|
+
// ── UNREADABLE IS A VALUE, NOT AN OMISSION ──────────────────────────────────────────────────────────
|
|
14
|
+
//
|
|
15
|
+
// The whole point of the field is telling three states apart, and only one of them is "we know":
|
|
16
|
+
//
|
|
17
|
+
// { version: "2.1.241", probe: "ok" } the binary answered
|
|
18
|
+
// { version: null, probe: "unreadable", why } it was asked and could not say
|
|
19
|
+
// (no field at all) this record predates the gauge
|
|
20
|
+
//
|
|
21
|
+
// An omission on failure collapses the middle into the third, and a reader comparing two runs cannot
|
|
22
|
+
// tell a tool that would not answer from a record written before anybody asked. That distinction is the
|
|
23
|
+
// reason for the field, so failing to write it is failing at the thing rather than at the edge of it.
|
|
24
|
+
//
|
|
25
|
+
// ── ONE SPAWN PER BUILD, NOT PER PROCESS ────────────────────────────────────────────────────────────
|
|
26
|
+
//
|
|
27
|
+
// A dispatch is many stages and every stage would otherwise pay. But the cache lives as long as the
|
|
28
|
+
// process, and the process is NOT one run: the drainer's watch loop calls the pipeline for job after job
|
|
29
|
+
// without exiting. A first version cached there would be reported as fact for every later run in that
|
|
30
|
+
// process — including runs served by a binary somebody upgraded in place underneath it.
|
|
31
|
+
//
|
|
32
|
+
// That is the exact silence this field exists to end, reintroduced by the cache meant to make it cheap,
|
|
33
|
+
// and it would have been invisible: the record would carry a version, confidently, and be wrong.
|
|
34
|
+
//
|
|
35
|
+
// So the key is the path AND what the filesystem says about the file — an in-place upgrade changes the
|
|
36
|
+
// modification time and the size, so it misses the cache and is probed again. The path alone is not
|
|
37
|
+
// enough (two engines can point at one binary, an engine can be repointed) and the engine id is not
|
|
38
|
+
// enough for the same reason.
|
|
39
|
+
//
|
|
40
|
+
// A file the filesystem cannot describe is NOT CACHED at all. Caching an unreadable probe under a key
|
|
41
|
+
// derived from a failed stat would pin the failure for the life of the process, so a binary that
|
|
42
|
+
// appeared a moment later would keep reading as absent.
|
|
43
|
+
// ── WHAT THIS DEPENDS ON, WHICH IS NOT ENFORCEABLE FROM HERE ────────────────────────────────────────
|
|
44
|
+
//
|
|
45
|
+
// A probe that can change what it probes is not a probe. This one spawns the engine binary, so it rests
|
|
46
|
+
// on `--version` being side-effect-free — true of every real CLI and not something this module can make
|
|
47
|
+
// true. It bit immediately: the suite's engine stand-ins fell through to their stage path, and one of
|
|
48
|
+
// them counts invocations to decide when to fail, so the probe consumed the failure a retry test was
|
|
49
|
+
// measuring and the retry never happened. The symptom was an attempt count off by one, three files away
|
|
50
|
+
// from the cause.
|
|
51
|
+
//
|
|
52
|
+
// The stand-ins now answer `--version` and exit, which is what the binaries they stand in for do. A new
|
|
53
|
+
// one that forgets will produce the same off-by-one, so `driver/test/a-run-records-the-tool-that-served-it`
|
|
54
|
+
// asserts every engine stand-in answers — the cheap ratchet under a condition that cannot be checked at
|
|
55
|
+
// the call site.
|
|
56
|
+
import { execFileSync } from "node:child_process";
|
|
57
|
+
import { statSync } from "node:fs";
|
|
58
|
+
|
|
59
|
+
/** Live for the process, keyed by resolved path. A run is one process; a probe is one spawn. */
|
|
60
|
+
const CACHE = new Map();
|
|
61
|
+
|
|
62
|
+
/** The first version-shaped token in the output, or the whole first line when nothing matches. */
|
|
63
|
+
export function parseVersion(out) {
|
|
64
|
+
const line = String(out ?? "").split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "";
|
|
65
|
+
if (!line) return null;
|
|
66
|
+
// These CLIs answer `2.1.241`, `codex-cli 0.5.0`, `claude 2.1.241 (Claude Code)`. Take the first
|
|
67
|
+
// dotted number and keep it; a build that answers in prose is recorded verbatim rather than dropped,
|
|
68
|
+
// because a string somebody can compare beats a null.
|
|
69
|
+
return (line.match(/\b\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?\b/) ?? [line])[0];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Probe one binary. PURE apart from the spawn, which is injected so an arm can drive both branches
|
|
74
|
+
* without a binary on the box — the unreadable branch is the one that matters and it cannot be produced
|
|
75
|
+
* on demand from a real install.
|
|
76
|
+
*
|
|
77
|
+
* Never throws. A probe that could take down a dispatch would be a worse defect than the gap it closes.
|
|
78
|
+
*/
|
|
79
|
+
export function probeCliVersion(bin, { run = null, timeoutMs = 5000, cache = CACHE, stat = statSync } = {}) {
|
|
80
|
+
if (!bin) return { version: null, probe: "unreadable", why: "no engine binary was resolved" };
|
|
81
|
+
let key = null;
|
|
82
|
+
try { const st = stat(bin); key = `${bin}\u0000${st.mtimeMs}:${st.size}`; } catch { /* not cacheable */ }
|
|
83
|
+
if (key && cache.has(key)) return cache.get(key);
|
|
84
|
+
const spawn = run ?? ((b) => execFileSync(b, ["--version"], {
|
|
85
|
+
encoding: "utf8", timeout: timeoutMs, stdio: ["ignore", "pipe", "ignore"],
|
|
86
|
+
}));
|
|
87
|
+
let result;
|
|
88
|
+
try {
|
|
89
|
+
const version = parseVersion(spawn(bin));
|
|
90
|
+
result = version
|
|
91
|
+
? { version, probe: "ok" }
|
|
92
|
+
// It ran and said nothing a version could be read from. That is not the same as failing to run,
|
|
93
|
+
// and a reader chasing a tool change needs to know which happened.
|
|
94
|
+
: { version: null, probe: "unreadable", why: "the binary answered with no version-shaped token" };
|
|
95
|
+
} catch (e) {
|
|
96
|
+
result = { version: null, probe: "unreadable", why: String(e?.message ?? e).slice(0, 160) };
|
|
97
|
+
}
|
|
98
|
+
if (key) cache.set(key, result);
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Drop the cache. For arms, and for a caller that has just repointed an engine deliberately. */
|
|
103
|
+
export function forgetCliVersions(cache = CACHE) { cache.clear(); }
|
|
@@ -237,7 +237,7 @@ function niceClassErrors(list, label) {
|
|
|
237
237
|
// and reaches the report body, where it REVERSES THE DISPLAY of everything after it. The report is a
|
|
238
238
|
// legal deliverable that goes to a client.
|
|
239
239
|
//
|
|
240
|
-
// A zero-width joiner makes "
|
|
240
|
+
// A zero-width joiner makes "IRON\u200DWHISK" render as IRONWHISK and compare unequal to it, so
|
|
241
241
|
// `selfExclusionOwners` and dedup both miss a mark a human reads as the account's own.
|
|
242
242
|
//
|
|
243
243
|
// NFD "SIRÈNE" !== NFC "SIRÈNE", so one mark submitted two ways is two matters, two searches, two bills.
|
|
@@ -285,7 +285,7 @@ const markNameSites = (job) => {
|
|
|
285
285
|
for (const f of ["markName", "name"]) if (job?.[f] != null) sites.push([f, () => job[f], (v) => { job[f] = v; }]);
|
|
286
286
|
if (Array.isArray(job?.marks)) job.marks.forEach((m, i) => {
|
|
287
287
|
// A BARE STRING IS A MARK NAME, and this shape reaches validateJob unconverted. `assembleFromFlags`
|
|
288
|
-
// turns `marks: ["
|
|
288
|
+
// turns `marks: ["IRONWHISK"]` into `[{ name: "IRONWHISK" }]`, so every door that assembles is already
|
|
289
289
|
// covered — but the runner's wall calls validateJob({ atClaim: true }) on the MANIFEST AS IT SITS ON
|
|
290
290
|
// DISK, which nothing re-assembles. The kebab-collision check below reads the same shape for exactly
|
|
291
291
|
// that reason. Walking only `m.name` would leave this guard covering every door except the one it
|
|
@@ -778,16 +778,16 @@ export function validateJob(job, { atClaim = false } = {}) {
|
|
|
778
778
|
// that would drop the customer's platforms, self-exclusion seed and the framework that RATES the matter".
|
|
779
779
|
// A MISSING key returns generic in silence. Both produce the same wrong deliverable, and only one says so.
|
|
780
780
|
//
|
|
781
|
-
// It has already happened: a paid
|
|
782
|
-
// request named the customer in prose and left the account field empty. Nothing objected.
|
|
783
|
-
// noticed hours later and re-ran it.
|
|
781
|
+
// It has already happened: a paid clearance for a test account was rated on the Generic default scale
|
|
782
|
+
// because the request named the customer in prose and left the account field empty. Nothing objected.
|
|
783
|
+
// A person noticed hours later and re-ran it.
|
|
784
784
|
//
|
|
785
785
|
// WHY THIS IS NOT SIMPLY "customer present, key absent". That shape is byte-identical to a LEGITIMATE
|
|
786
786
|
// third-party search — a firm asking us to clear a mark for their own client, who is not our account —
|
|
787
787
|
// and the applicant deliberately never selects a profile, because a third-party search must never inherit
|
|
788
788
|
// a customer's exclusions. Clarifying on that shape alone would bounce real work every day.
|
|
789
789
|
//
|
|
790
|
-
// The discriminator is the ROSTER.
|
|
790
|
+
// The discriminator is the ROSTER. That request differed in one way that matters: we hold an
|
|
791
791
|
// account by that name. A named applicant we have no account for is a third-party search and runs
|
|
792
792
|
// untouched; a named applicant who IS one of our customers, with no key, is ambiguous in a way only a
|
|
793
793
|
// person can settle — did intake forget to tag it, or is this genuinely a search against a name that
|
|
@@ -965,7 +965,7 @@ export const EXAMPLE_JOB = {
|
|
|
965
965
|
rawRequest: "<the verbatim forwarded email text, untouched — archived as inbound-request.txt (§A5)>",
|
|
966
966
|
brief: "<the confirmation brief exactly as sent to the requester — archived as confirmation-brief.md>",
|
|
967
967
|
deadline: "2026-06-20T17:00:00Z", // optional; drives the §A3 deadline-envelope arithmetic
|
|
968
|
-
profileKey: "
|
|
968
|
+
profileKey: "demo-brand-owner", // D4.1: the customer ACCOUNT the intake AI resolved → selects the
|
|
969
969
|
// profile (marketplaces/classes/delivery/appetite); omit ⇒ generic
|
|
970
970
|
product: "multi-country-focus-search",// WHICH OF THE FOUR (OPTIONAL): one of products.mjs PRODUCT_IDS.
|
|
971
971
|
// Omit ⇒ the project/customer defaultProduct, else the product
|
|
@@ -987,7 +987,7 @@ export const EXAMPLE_JOB = {
|
|
|
987
987
|
projectKey: "console-ecosystem", // spec 62 (OPTIONAL): the PROJECT/engagement under the customer whose
|
|
988
988
|
// overlay (its own marketplaces/classes/sector/posture) rates this
|
|
989
989
|
// matter; omit ⇒ runs on the customer profile. Unknown key ⇒ clarify.
|
|
990
|
-
customer: "
|
|
990
|
+
customer: "Demo Brand Owner", // applicant/owner → affiliate self-exclusion set (§B3.2)
|
|
991
991
|
customerUnknown: false, // B5: true when the applicant is neither stated nor forwarder-implied —
|
|
992
992
|
// arms candidate-self classification + the late-bind watch (NEVER inferred from the mark)
|
|
993
993
|
// caseLaw — NOT A FIELD. The case-law and opposition reading is what a Full country search IS
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// come from report-data.json; the disposition comes from findings.json beside it in the same run dir,
|
|
20
20
|
// because report-data.json is the CLIENT cut and stopped serving the engine's placement key. A
|
|
21
21
|
// reader who finds the ordinal now points at `KURENA / Manageable / rebuttable` when the flag says
|
|
22
|
-
// `
|
|
22
|
+
// `BURROWELL / Medium / conceded` knows the run was republished, which is exactly the thing a bare id
|
|
23
23
|
// would have hidden.
|
|
24
24
|
|
|
25
25
|
import { randomUUID } from "node:crypto";
|
|
@@ -168,8 +168,8 @@ export const ACTION_KINDS = [...CONDITION_KINDS, ...ADVISORY_KINDS];
|
|
|
168
168
|
export const CLIENT_TIER_BY_COMPOSITE = { 1: "LOW", 2: "MANAGEABLE", 3: "MEDIUM", 4: "HIGH", 5: "VERY HIGH" };
|
|
169
169
|
|
|
170
170
|
// ── doc 50 — band mode (schema_version 4): the framework in force rates the matter ────────────────────
|
|
171
|
-
// A v4 finding carries `band` — one of the frozen framework manifest's ordered band words (
|
|
172
|
-
// "Medium", the Generic default says "Moderate",
|
|
171
|
+
// A v4 finding carries `band` — one of the frozen framework manifest's ordered band words (one ladder
|
|
172
|
+
// says "Medium", the Generic default says "Moderate", another has a "Low") — and NO composite/level/
|
|
173
173
|
// dispute_type (one rating authority; the retired scale is FORBIDDEN, not just optional). Presentation
|
|
174
174
|
// joins on the band's TONE (a closed enum in the manifest) so 4-band and 5-band ladders both land on the
|
|
175
175
|
// existing badge/gauge ramps without any per-framework code.
|
|
@@ -2441,6 +2441,56 @@ export function knockoutFindingViews(mark, { manifest = null } = {}) {
|
|
|
2441
2441
|
* Derived through knockoutFindingViews rather than re-deriving that fallback here — one projection
|
|
2442
2442
|
* decides what a finding's number is, and this cell cannot disagree with the sheet it points into.
|
|
2443
2443
|
*/
|
|
2444
|
+
// ── THE REVIEWER'S NOTES: WHAT EACH ONE IS ABOUT (tracker issues 331 A.4, 333 rule 5) ───────────────
|
|
2445
|
+
//
|
|
2446
|
+
// The knockout page prints a note about the REQUEST at the top, above the conflicts, and a note about
|
|
2447
|
+
// the NAME under that name's cards. A note saying the screen may have been scoped to the wrong market
|
|
2448
|
+
// is the most consequential line on the page and it used to sit under roughly 1,900 words.
|
|
2449
|
+
//
|
|
2450
|
+
// THE READER LIVES HERE BECAUSE TWO MODULES NEED THE SAME ANSWER. The renderer sorts the notes; the
|
|
2451
|
+
// predelivery lint warns a writer whose note will sort the way they did not intend. Two copies of this
|
|
2452
|
+
// regex would drift, and the drift would be silent — the page would file a note one way while the
|
|
2453
|
+
// reviewer told the writer it went the other.
|
|
2454
|
+
//
|
|
2455
|
+
// IT SORTS ON WHAT THE NOTE TALKS ABOUT, and the doctrine is written to match: a note about the request
|
|
2456
|
+
// NAMES the request. The word set spans both vocabularies deliberately — "dispatch" and "instructed"
|
|
2457
|
+
// are what runs written before tracker issue 333 say, "the request" and "was asked" are what runs
|
|
2458
|
+
// written after it say — so one reader serves the archive and the new doctrine at once.
|
|
2459
|
+
export const REQUEST_NOTE_WORDS = /\b(?:dispatch|the request|the requester|instructed|was asked)\b/i;
|
|
2460
|
+
|
|
2461
|
+
// Subjects that belong to the ASKING rather than to the name. A note on one of these that never names
|
|
2462
|
+
// the request will be filed under the name, which is why the lint flags it rather than the page
|
|
2463
|
+
// guessing: the fix is one clause in the note, and only its writer can add it.
|
|
2464
|
+
export const REQUEST_SUBJECT_WORDS =
|
|
2465
|
+
/\b(?:customer industry|client(?:'s)? (?:own )?(?:prior|earlier) use|prior use|intended goods|the goods we|wrong market|described as an? )/i;
|
|
2466
|
+
|
|
2467
|
+
/** One note, in either shape, as { text, about }. `about` is null when nothing has classified it. */
|
|
2468
|
+
export function knockoutNoteView(n) {
|
|
2469
|
+
if (n && typeof n === "object" && !Array.isArray(n)) {
|
|
2470
|
+
const about = String(n.about ?? "").trim().toLowerCase();
|
|
2471
|
+
return { text: String(n.text ?? n.note ?? "").trim(), about: about === "request" || about === "name" ? about : null };
|
|
2472
|
+
}
|
|
2473
|
+
return { text: String(n ?? "").trim(), about: null };
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
/**
|
|
2477
|
+
* A mark's notes, split into the two places the page prints them.
|
|
2478
|
+
*
|
|
2479
|
+
* The rater's own `about` wins outright where one is given; only an unclassified note meets the word
|
|
2480
|
+
* reader. That precedence is the whole design: it means a run written under a doctrine that no longer
|
|
2481
|
+
* uses these words is answered without them.
|
|
2482
|
+
*/
|
|
2483
|
+
export function splitKnockoutNotes(mark) {
|
|
2484
|
+
const all = (Array.isArray(mark?.purpleNotes) ? mark.purpleNotes : []).map(knockoutNoteView).filter((v) => v.text);
|
|
2485
|
+
const request = [];
|
|
2486
|
+
const name = [];
|
|
2487
|
+
for (const v of all) {
|
|
2488
|
+
const isRequest = v.about ? v.about === "request" : REQUEST_NOTE_WORDS.test(v.text);
|
|
2489
|
+
(isRequest ? request : name).push(v.text);
|
|
2490
|
+
}
|
|
2491
|
+
return { request, name, all: all.map((v) => v.text) };
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2444
2494
|
export function knockoutFindingRange(mark) {
|
|
2445
2495
|
const ords = knockoutFindingViews(mark).map((v) => v.ordinal).sort((a, b) => a - b);
|
|
2446
2496
|
const markName = String(mark?.name ?? "").trim();
|
package/driver/flag-snapshot.mjs
CHANGED
|
@@ -318,6 +318,26 @@ export function postureDisagreement(snapshot, live) {
|
|
|
318
318
|
"silent-output-change — a different engine answers differently and nobody is told");
|
|
319
319
|
differ("billing mode", snapshot.engine?.billing?.mode ?? null, live.engine?.billing?.mode ?? null,
|
|
320
320
|
"what a run costs, and who it is billed to");
|
|
321
|
+
// THE FIELD THAT DECIDES WHETHER A SEARCH CAN START, and the one this comparison could not see.
|
|
322
|
+
//
|
|
323
|
+
// The configuration page reads the LIVE posture and the New clearance screen reads this capture, so a
|
|
324
|
+
// box where they differ on this one boolean draws a green Engine row on one screen while the other
|
|
325
|
+
// replaces its start button with "no search engine is attached". That is not a hypothetical: it is
|
|
326
|
+
// what an outside user photographed, and he gave up on the product because the greener screen looked
|
|
327
|
+
// more authoritative. Both readings were correct about their own question and nothing compared them.
|
|
328
|
+
//
|
|
329
|
+
// Worse than silence, before this line: `disagrees` came back `[]` — which this page renders as "the
|
|
330
|
+
// last run ran under this same configuration". The one field they actually disagreed on was not in the
|
|
331
|
+
// comparison, so the page positively affirmed agreement while the two surfaces contradicted each other.
|
|
332
|
+
// STRINGS, NOT THE BOOLEANS THEMSELVES. Every other row here compares names and modes, so the browser
|
|
333
|
+
// contract parses `capture` and `live` with `asString` — handed `false` it yields null, and the row
|
|
334
|
+
// would reach the page with its two values blank and only the effect sentence left. The words are also
|
|
335
|
+
// the better answer for a reader: "found" against "not found" says it without a legend.
|
|
336
|
+
const found = (v) => (v === true ? "found" : v === false ? "not found" : null);
|
|
337
|
+
differ("engine program", found(snapshot.engine?.binaryPresent), found(live.engine?.binaryPresent),
|
|
338
|
+
"whether a NEW search can start — the engine that last ran and this deployment do not agree that the "
|
|
339
|
+
+ "engine program can be found, so one screen offers a search the other refuses. Restart the engine "
|
|
340
|
+
+ "service so it re-reads its PATH, or install the CLI where the service can see it");
|
|
321
341
|
|
|
322
342
|
// Flags: compare only names BOTH sides declare, for the same reason `differ` skips absent values —
|
|
323
343
|
// a build that adds a flag must not read as every older capture disagreeing with it.
|
package/driver/framework.mjs
CHANGED
|
@@ -102,7 +102,7 @@ export function parseFrameworkManifest(raw) {
|
|
|
102
102
|
// `rootDir` may be a string (legacy: join against it) OR a resolver function taking the manifest's
|
|
103
103
|
// skills-relative path and returning an absolute one — the layered overlay-over-base lookup
|
|
104
104
|
// (driver.config.resolveSkillPath). The driver MUST resolve a framework exactly as the agent does, or the
|
|
105
|
-
// two read different files: that divergence is what killed
|
|
105
|
+
// two read different files: that divergence is what killed a test account's first run (framework_manifest_missing
|
|
106
106
|
// against the bundled tree while the agent was pointed at the config store).
|
|
107
107
|
export function loadFrameworkManifest(rootDir, fwPath) {
|
|
108
108
|
const rel = manifestPathFor(fwPath);
|
package/driver/gateway.mjs
CHANGED
|
@@ -13,7 +13,8 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, wri
|
|
|
13
13
|
import { join, dirname, basename } from "node:path";
|
|
14
14
|
import { driverDir } from "../shared/driver-dir.mjs"; //
|
|
15
15
|
import { tmpdir } from "node:os";
|
|
16
|
-
import { config, resolveModel, modelFamily, modelSnapshotKind, envOn, envGateOn } from "./driver.config.mjs";
|
|
16
|
+
import { config, resolveModel, modelFamily, modelSnapshotKind, envOn, envGateOn, preflightEngineBinary } from "./driver.config.mjs";
|
|
17
|
+
import { probeCliVersion } from "./engine/cli-version.mjs";
|
|
17
18
|
import { stageLog, runLog, note, outputMeta } from "./log.mjs";
|
|
18
19
|
// — the closed disposition set has ONE author; this file dictates it and must not retype it.
|
|
19
20
|
import { DISPOSITIONS, POSITION_REQUIRED_DISPOSITIONS } from "./findings-model.mjs";
|
|
@@ -1147,6 +1148,20 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
|
|
|
1147
1148
|
// may repoint, and recorded beside a dated one they read identically. null when there is nothing to
|
|
1148
1149
|
// judge, never collapsed into "alias".
|
|
1149
1150
|
const modelSnapshot = modelSnapshotKind(modelActual);
|
|
1151
|
+
// WHICH BUILD OF THE TOOL SERVED THIS TURN. `modelSnapshot` above answers "did the model move";
|
|
1152
|
+
// this answers "did the tool move", and the two have one symptom — a run whose judgment differs from
|
|
1153
|
+
// last week's. Captured at dispatch rather than read back afterwards, because the binary a reader
|
|
1154
|
+
// could probe later is not necessarily the one that ran.
|
|
1155
|
+
//
|
|
1156
|
+
// Written as an object with its own `probe` field, so an unreadable version is a RECORDED state
|
|
1157
|
+
// rather than a missing one. Omitting it on failure would collapse "asked and could not say" into
|
|
1158
|
+
// "written before anybody asked", which is the distinction the field exists for. One spawn per
|
|
1159
|
+
// binary per process; a probe never throws, because taking down a dispatch to record a version
|
|
1160
|
+
// would be a worse defect than the gap it closes.
|
|
1161
|
+
const cli = (() => {
|
|
1162
|
+
try { return probeCliVersion(preflightEngineBinary(process.env)?.resolved ?? null); }
|
|
1163
|
+
catch (e) { return { version: null, probe: "unreadable", why: String(e?.message ?? e).slice(0, 160) }; }
|
|
1164
|
+
})();
|
|
1150
1165
|
if (modelActual) lastModelWire = modelActual; // — never overwritten with null
|
|
1151
1166
|
// The comparison is by FAMILY (driver.config modelFamily), because `--model haiku` legitimately comes
|
|
1152
1167
|
// back as `claude-haiku-4-5-20251001`. THREE-VALUED: null when either side names no family this
|
|
@@ -1576,6 +1591,7 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
|
|
|
1576
1591
|
// Written even on the rows where they are null, so "this engine cannot report" stays visibly
|
|
1577
1592
|
// different from "this record predates the gauge".
|
|
1578
1593
|
modelActual, modelBasis, modelSnapshot, modelMismatch,
|
|
1594
|
+
cliVersion: cli.version, cliVersionProbe: cli.probe, ...(cli.why ? { cliVersionWhy: cli.why } : {}),
|
|
1579
1595
|
// W3 billing telemetry: which engine ran + the RESOLVED billing mode (subscription vs api-key). This
|
|
1580
1596
|
// records INTENT (the mode the engine was configured to bill under), not independent billing evidence
|
|
1581
1597
|
// — the actual proof is the provider console (claude's stream also reports apiKeySource; codex does
|
|
@@ -1711,6 +1727,7 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
|
|
|
1711
1727
|
//: the spine carries the same pair as the per-stage log, or the two disagree about what
|
|
1712
1728
|
// ran. `model` stays the requested resolution (its existing readers); `modelActual` is the wire.
|
|
1713
1729
|
model: modelRequested, modelActual, modelBasis, modelSnapshot, modelMismatch,
|
|
1730
|
+
cliVersion: cli.version, cliVersionProbe: cli.probe, ...(cli.why ? { cliVersionWhy: cli.why } : {}),
|
|
1714
1731
|
wrote, warm: warm || undefined, warmEscalated: attempt === warmEscalatedAt || undefined,
|
|
1715
1732
|
rescued: rescued ?? undefined, killed: killed || undefined,
|
|
1716
1733
|
quiescentMs: Number.isFinite(quiescentMs) ? Math.round(quiescentMs) : undefined, // — see the per-stage row
|
package/driver/package.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"name": "clearotron-driver",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.3",
|
|
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": {
|
|
9
|
-
"node": ">=22.
|
|
9
|
+
"node": ">=22.13.0"
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"test": "node ../scripts/test-run.mjs node ../scripts/test-fast.mjs test/*.test.mjs",
|
|
@@ -14,6 +14,6 @@
|
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"exceljs": "^4.4.0",
|
|
17
|
-
"undici": "^
|
|
17
|
+
"undici": "^7.29.1"
|
|
18
18
|
}
|
|
19
19
|
}
|
package/driver/party-facts.mjs
CHANGED
|
@@ -226,8 +226,8 @@ const corpusHasKind = (set, kind) => {
|
|
|
226
226
|
/**
|
|
227
227
|
* The form of a party's name to match ENTRIES against.
|
|
228
228
|
*
|
|
229
|
-
* Register owners carry legal forms ("
|
|
230
|
-
* web uses ("
|
|
229
|
+
* Register owners carry legal forms ("Foxglade Therapeutics SA"); web candidate titles carry whatever the
|
|
230
|
+
* web uses ("Foxglade Therapeutics"). That asymmetry is systematic — one corpus is the register, the other
|
|
231
231
|
* is search results — so requiring the full string admits SOME entries about a party and excludes others
|
|
232
232
|
* purely by name form. A half-scoped corpus is the failure this scoping was built to avoid, inverted: it
|
|
233
233
|
* still reports a contradiction, now against a source the run plainly holds.
|
package/driver/phase0.mjs
CHANGED
|
@@ -87,7 +87,7 @@ export function todayISO(now = new Date()) {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
// The resolved customer = the profile key (profiles.mjs resolveProfile, forwarder-domain only). This was
|
|
90
|
-
// a
|
|
90
|
+
// a customer-vs-generic HARDCODE (D4.1); the engine is already profile-driven, so resolveProfile is
|
|
91
91
|
// the single source of truth. ctx.customer is telemetry-only (the run-start log line); per-customer
|
|
92
92
|
// DELIVERY reads the FROZEN sidecar ctx.profile.profileKey (which wins on resume) — never read
|
|
93
93
|
// ctx.customer for a delivery decision, or a profiles/ edit between run and resume would diverge.
|
package/driver/pipeline.mjs
CHANGED
|
@@ -888,7 +888,7 @@ function deriveGridSpec(ctx) {
|
|
|
888
888
|
// Deterministic grid contract (robust fix, 2026-06-14): write the EXACT terms × platforms the grid
|
|
889
889
|
// will run as a code-owned spec. The perplexity plugin reads this, runs only these cells, and writes
|
|
890
890
|
// common-law-grid.json ITSELF from the API response — the large grid JSON never round-trips through
|
|
891
|
-
// the model's bounded turn-output (no truncation:
|
|
891
|
+
// the model's bounded turn-output (no truncation: measured on a dense beverages run) and the model never re-types
|
|
892
892
|
// the keys or cells (no drops/mis-keys: NOVA PULSE/marble-anvil). The receipts gate joins against THIS
|
|
893
893
|
// same file, so it can never demand a key the grid did not run. No profile (no dictated platforms) ⇒
|
|
894
894
|
// legacy model-authored path (back-compat; the gate falls back to the prose manifest).
|
|
@@ -918,7 +918,7 @@ function deriveGridSpec(ctx) {
|
|
|
918
918
|
const gridSpecPath = P.gridSpec;
|
|
919
919
|
// #5 — required channels: a NAMED profile's curated platforms are authoritative. The GENERIC fallback
|
|
920
920
|
// derives the channels from the MATTER FRAME's industry/goods reasoning (its "Search channels:" line) so
|
|
921
|
-
// a regulated/B2B matter (
|
|
921
|
+
// a regulated/B2B matter (veterinary pharma, say) searches its real channels (EMA/FDA registers) instead of
|
|
922
922
|
// being forced onto — and invalidated by — consumer storefronts. No static class→channel table: the
|
|
923
923
|
// frame names them the way a lawyer reasons about the vertical, so it generalises to ANY industry. The
|
|
924
924
|
// deterministic grid runs grid-spec.platforms and the receipts gate joins the SAME file, so this is the
|
|
@@ -1465,7 +1465,7 @@ export function freezeProfile(p, project = null) {
|
|
|
1465
1465
|
demoData: p.demoData === true,
|
|
1466
1466
|
// BUGFIX 2026-06-19: per-customer reasoning-skill SELECTION must be frozen too. It was missing here, so
|
|
1467
1467
|
// frameworkFor()/workedExamplesFor() (stages.mjs) read the frozen ctx.profile, found no path, and silently
|
|
1468
|
-
// fell back to the firm-neutral DEFAULT for EVERY run —
|
|
1468
|
+
// fell back to the firm-neutral DEFAULT for EVERY run — the profiles' configured frameworks
|
|
1469
1469
|
// were never applied. RAW value (NOT `?? ""`): absent ⇒ undefined ⇒ JSON drops it ⇒ frameworkFor falls back
|
|
1470
1470
|
// to DEFAULT; an empty string would NOT trigger the `?? DEFAULT` and would break the read.
|
|
1471
1471
|
frameworkPath: p.frameworkPath,
|
|
@@ -1490,7 +1490,7 @@ export function freezeProfile(p, project = null) {
|
|
|
1490
1490
|
};
|
|
1491
1491
|
// spec 62 — when the run is under a PROJECT, record which project rated it and the per-field origin map,
|
|
1492
1492
|
// added AFTER the 14-field base so a NO-PROJECT freeze stays byte-identical to a pre-62 sidecar (the
|
|
1493
|
-
//
|
|
1493
|
+
// regression anchor and every existing run's profileSha are preserved; profileShaOf sorts keys,
|
|
1494
1494
|
// so these hash in only when present, making "which project rated this run" verifiable, never asserted).
|
|
1495
1495
|
if (project?.projectKey) {
|
|
1496
1496
|
frozen.projectKey = project.projectKey;
|
|
@@ -14633,7 +14633,7 @@ async function pipelineInner(job, opts = {}) {
|
|
|
14633
14633
|
// ratings (joinFindingToBlock), never the summary's own words.
|
|
14634
14634
|
try { emailVerdictOpts.findings = parseFindingsJsonLenient(readFileSync(P.findings, "utf8"))?.findings ?? undefined; } catch { /* no findings — table falls back to the summary words */ }
|
|
14635
14635
|
// doc 50 — the run's band ladder: the email table colours + sorts by the framework in force's own
|
|
14636
|
-
// vocabulary (
|
|
14636
|
+
// vocabulary (one framework's "Medium", the house's "Moderate", another's five bands) instead of the fixed table.
|
|
14637
14637
|
if (ctx.framework) emailVerdictOpts.bands = ctx.framework.bands.map((b) => ({ label: b.label, tone: b.tone }));
|
|
14638
14638
|
// ONE report (spec 2026-07-30 §6): the cover note is a cover note — verdict, one-line summary, link.
|
|
14639
14639
|
// A10 (addendum, 2026-07-30) NARROWS the old "machine-QC never on the email" rule rather than
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
3
|
+
//
|
|
4
|
+
// THE TWO-REGISTER RULE, AS THE REVIEWER READS IT — tracker issue 333.
|
|
5
|
+
//
|
|
6
|
+
// The report goes to a lawyer who layers advice on top, and that lawyer's client reads the same page.
|
|
7
|
+
// The band, the summary, the basis line and the one-liners are the whole product for the second reader,
|
|
8
|
+
// and they were the hardest lines on it: single sentences of seventy-odd words in the lawyer's
|
|
9
|
+
// vocabulary. The owner's ruling is that default-visible text carries no legal or engine vocabulary at
|
|
10
|
+
// all, and that inside a fold the lawyer's words are allowed where a plain one would lose precision.
|
|
11
|
+
//
|
|
12
|
+
// THIS IS ADVISORY AND MUST STAY ADVISORY. A hit is a rewrite of that line — never a disclosure to the
|
|
13
|
+
// client, never a run failure. The rule is presentation: it changes no band, no evidence and nothing
|
|
14
|
+
// that is searched.
|
|
15
|
+
//
|
|
16
|
+
// — AND IT MUST NOT FIRE ON THE MARK IT IS CLEARING.
|
|
17
|
+
//
|
|
18
|
+
// This is the defect `coverage-form.mjs` records one level in: a refusal that cannot tell a mark from
|
|
19
|
+
// engine vocabulary blocked a clearance on the mark SLICE, and a render-time substitution turned "AXIS
|
|
20
|
+
// Bank filed in class 36" into "group Bank filed in class 36" on a report clearing AXIS. Half the words
|
|
21
|
+
// here are ordinary English and several are plausible marks — PREVAIL, SENIOR, SPECIFICATION. A check
|
|
22
|
+
// that flagged the mark under clearance would put noise on exactly the report that matters most, so
|
|
23
|
+
// every term the run is about is excluded before the text is read.
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The lawyer's vocabulary, as WORKED EXAMPLES with the plain form beside each. Not a ban list: the
|
|
27
|
+
* issue rejects "a list of forbidden words as the mechanism" in terms, and this is what the reviewer
|
|
28
|
+
* offers a seat as the rewrite, which is a different thing from a gate that refuses.
|
|
29
|
+
*
|
|
30
|
+
* Each entry is [what a lawyer writes, what the reader needs]. The second half is the load-bearing one —
|
|
31
|
+
* a flag naming a word teaches nothing, and the seat has to produce a sentence.
|
|
32
|
+
*/
|
|
33
|
+
export const PLAIN_FORMS = Object.freeze([
|
|
34
|
+
["proprietor", "owner"],
|
|
35
|
+
["subsisting", "live"],
|
|
36
|
+
["specification", "goods list"],
|
|
37
|
+
["citable", "earlier marks the office can raise against you"],
|
|
38
|
+
["prevail", "win"],
|
|
39
|
+
["formative", "names built on"],
|
|
40
|
+
["belt-and-braces", "extra"],
|
|
41
|
+
["non-use attack", "could be cancelled for not being used"],
|
|
42
|
+
["on the record as it stands", "on what we found"],
|
|
43
|
+
["marks-and-goods comparison", "same name, same goods"],
|
|
44
|
+
["dispatch", "the request"],
|
|
45
|
+
["instructed", "what was asked"],
|
|
46
|
+
["chunk", ""],
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/** The longest visible sentence a reader should meet. The issue's number, not a derived one. */
|
|
50
|
+
export const SENTENCE_WORD_LIMIT = 25;
|
|
51
|
+
|
|
52
|
+
/** Everything the run is ABOUT — the mark, its variants, the owners named. Never flagged. */
|
|
53
|
+
const ownTerms = (about = {}) => {
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const v of [about.mark, ...(about.marks ?? []), ...(about.owners ?? []), ...(about.terms ?? [])]) {
|
|
56
|
+
const s = String(v ?? "").trim();
|
|
57
|
+
if (s) out.push(s.toLowerCase());
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Sentences, split on terminators that end one. Crude on purpose — this counts words, not grammar. */
|
|
63
|
+
export const sentencesOf = (text) =>
|
|
64
|
+
String(text ?? "").split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
|
|
65
|
+
|
|
66
|
+
export const wordsIn = (sentence) => String(sentence ?? "").trim().split(/\s+/).filter(Boolean).length;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What is wrong with one default-visible line, as rewrite advice. `[]` means nothing to say.
|
|
70
|
+
*
|
|
71
|
+
* `about` carries the run's own marks and owners so they are never reported — see the header. A term
|
|
72
|
+
* that IS the thing being cleared is not the lawyer's vocabulary, it is the subject.
|
|
73
|
+
*/
|
|
74
|
+
export function plainRegisterFlags(text, about = {}) {
|
|
75
|
+
const raw = String(text ?? "");
|
|
76
|
+
if (!raw.trim()) return [];
|
|
77
|
+
const mine = ownTerms(about);
|
|
78
|
+
// Blank the run's own terms before reading, rather than filtering hits afterwards: a mark can contain
|
|
79
|
+
// one of these words ("PREVAIL"), and a hit inside it is not a hit at all.
|
|
80
|
+
let scan = raw;
|
|
81
|
+
for (const t of mine) {
|
|
82
|
+
if (!t) continue;
|
|
83
|
+
scan = scan.replace(new RegExp(t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), " ");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const flags = [];
|
|
87
|
+
for (const [term, plain] of PLAIN_FORMS) {
|
|
88
|
+
const re = new RegExp(`\\b${term.replace(/[-]/g, "[- ]").replace(/\s+/g, "\\s+")}\\b`, "i");
|
|
89
|
+
if (!re.test(scan)) continue;
|
|
90
|
+
flags.push({
|
|
91
|
+
kind: "vocabulary",
|
|
92
|
+
term,
|
|
93
|
+
say: plain
|
|
94
|
+
? `"${term}" is the lawyer's word — the reader needs "${plain}". Rewrite the sentence, do not swap the word.`
|
|
95
|
+
: `"${term}" is an engine word and has no place on a page a client reads. Rewrite the sentence.`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const s of sentencesOf(raw)) {
|
|
100
|
+
const n = wordsIn(s);
|
|
101
|
+
if (n > SENTENCE_WORD_LIMIT) {
|
|
102
|
+
flags.push({
|
|
103
|
+
kind: "length",
|
|
104
|
+
words: n,
|
|
105
|
+
say: `${n} words in one sentence, and a visible line takes ${SENTENCE_WORD_LIMIT}. Split it — one idea per sentence, `
|
|
106
|
+
+ "the conclusion first. Do not shorten it by dropping the reason.",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return flags;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The fields a reader meets before opening anything. Named here rather than at each call site so the
|
|
115
|
+
* two products answer to one list — the knockout and the clearance drifted apart once already.
|
|
116
|
+
*/
|
|
117
|
+
export const DEFAULT_VISIBLE_FIELDS = Object.freeze({
|
|
118
|
+
knockout: ["summary", "batchOpener", "basis", "net", "factors", "counterFactors", "mitigation", "standardCaveats", "reviewerNotes"],
|
|
119
|
+
clearance: ["summary", "oneLiner", "registrability", "thirdPartyRights", "ownRights", "freedomToOperate", "reviewerNotes", "coverage"],
|
|
120
|
+
});
|
package/driver/portal-access.mjs
CHANGED
|
@@ -17,7 +17,7 @@ const domainOf = (email) => { const e = String(email ?? "").toLowerCase(); const
|
|
|
17
17
|
/**
|
|
18
18
|
* makePrincipal({ email, grants, staffDomains }) →
|
|
19
19
|
* { role: "staff", email, accounts: "*" } — firm identity: everything, acting-for allowed
|
|
20
|
-
* | { role: "client", email, accounts: ["
|
|
20
|
+
* | { role: "client", email, accounts: ["foxglade", …] } — enrolled client: exactly the granted accounts
|
|
21
21
|
* | null — unknown identity: no portal (the door 403s)
|
|
22
22
|
* Staff wins over an (accidental) grants row; a client row with a tenant-wide "*" grant is honored
|
|
23
23
|
* but the role stays client (no staff surfaces).
|
|
@@ -239,6 +239,41 @@ export function authView({ mode = "", oidcIssuer = "", team = "", jwksUrl = "",
|
|
|
239
239
|
};
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Where the staff-domain rule was written, so a reader can go and undo it.
|
|
244
|
+
*
|
|
245
|
+
* ── WHY A PAGE THAT NAMES A RULE MUST ALSO NAME ITS ADDRESS ─────────────────────────────────────────
|
|
246
|
+
*
|
|
247
|
+
* The People & access screen renders the rule — "Anyone at <domain> — a rule, not a person" — and said
|
|
248
|
+
* nothing about where it came from. A reader who does not recognise the domain therefore learns that
|
|
249
|
+
* strangers may hold an administrator's view of their instance and has no next step at all: the value
|
|
250
|
+
* is in an environment variable, in one of two files depending on how the instance is run, and neither
|
|
251
|
+
* is named anywhere on the screen. The one outside reader who met this reported it as a back door,
|
|
252
|
+
* twice, which is the correct thing to do with an access rule you cannot trace.
|
|
253
|
+
*
|
|
254
|
+
* PURE, and it answers "could not tell" as itself. `envLoad` is `shared/env-local.mjs`'s own report of
|
|
255
|
+
* what this process read, so the answer describes the process actually serving the page rather than
|
|
256
|
+
* being composed from a path that some other process would have read — the distinction that module
|
|
257
|
+
* exists for. A service started by systemd took its configuration from an EnvironmentFile; a child of
|
|
258
|
+
* `clearotron start` was handed an explicit environment and read no file at all; a hand-run CLI read
|
|
259
|
+
* the CLI's file. Each gets its own sentence, because the remedy is a different file in each.
|
|
260
|
+
*/
|
|
261
|
+
export function staffRuleSource({ name = "PORTAL_STAFF_DOMAINS", value = "", envLoad = null,
|
|
262
|
+
unitEnvFile = null, cliEnvFile = null } = {}) {
|
|
263
|
+
if (!String(value ?? "").trim()) return null;
|
|
264
|
+
const reason = envLoad?.reason ?? null;
|
|
265
|
+
const applied = Array.isArray(envLoad?.applied) ? envLoad.applied : [];
|
|
266
|
+
if (reason === "read" && applied.includes(name))
|
|
267
|
+
return { name, where: `read from ${envLoad.path}` };
|
|
268
|
+
if (reason === "service-managed")
|
|
269
|
+
return { name, where: unitEnvFile ? `set in this service's environment file, ${unitEnvFile}` : "set in this service's environment" };
|
|
270
|
+
if (reason === "opted-out")
|
|
271
|
+
return { name, where: cliEnvFile
|
|
272
|
+
? `handed to this service by the command that started it, which takes it from ${cliEnvFile} or derives it from the sign-in address`
|
|
273
|
+
: "handed to this service by the command that started it" };
|
|
274
|
+
return { name, where: cliEnvFile ? `set in this service's environment (the file it would otherwise read is ${cliEnvFile})` : "set in this service's environment" };
|
|
275
|
+
}
|
|
276
|
+
|
|
242
277
|
/**
|
|
243
278
|
* The enrolment view: who is granted what, and where an enrolment is half done.
|
|
244
279
|
*
|
|
@@ -246,7 +281,7 @@ export function authView({ mode = "", oidcIssuer = "", team = "", jwksUrl = "",
|
|
|
246
281
|
* they are reported separately — a staff member absent from the grants file is normal, not a fault,
|
|
247
282
|
* and listing them as "unenrolled" would bury the real problems.
|
|
248
283
|
*/
|
|
249
|
-
export function accessView({ grants, staffDomains = [], knownAccounts = [], grantsFile = null }) {
|
|
284
|
+
export function accessView({ grants, staffDomains = [], knownAccounts = [], grantsFile = null, staffRule = null }) {
|
|
250
285
|
const tenants = grants?.tenants ?? {};
|
|
251
286
|
const known = new Set(knownAccounts);
|
|
252
287
|
const people = [];
|
|
@@ -277,6 +312,10 @@ export function accessView({ grants, staffDomains = [], knownAccounts = [], gran
|
|
|
277
312
|
return {
|
|
278
313
|
people: people.sort((a, b) => a.email.localeCompare(b.email)),
|
|
279
314
|
staffDomains: [...staffDomains],
|
|
315
|
+
// An ADDITIONAL field rather than a reshape of `staffDomains`: that array is parsed by the browser
|
|
316
|
+
// contract and read by three screens' worth of arms, and a rule nobody can trace is a copy problem,
|
|
317
|
+
// not a data-shape problem. Null when there is no rule, or when the source could not be told.
|
|
318
|
+
staffRule,
|
|
280
319
|
// Accounts named in grants that no profile matches — the other typo direction.
|
|
281
320
|
unknownAccounts: [...unknownAccounts].sort(),
|
|
282
321
|
// Where to go to change any of this — a filename and a date, so "I want to add someone" has a
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// ── the shape ────────────────────────────────────────────────────────────────────────────────────────
|
|
19
19
|
//
|
|
20
20
|
// { "schema": 1,
|
|
21
|
-
// "families": { "hydra-range": { "name": "Hydra range", "account": "
|
|
21
|
+
// "families": { "hydra-range": { "name": "Hydra range", "account": "foxglade" } },
|
|
22
22
|
// "of": { "<runId>": "hydra-range" } }
|
|
23
23
|
//
|
|
24
24
|
// Keyed by RUN rather than by mark. A mark is not a thing the pool stores — it is a grouping the browser
|