clearotron 0.3.2-beta.10 → 0.3.2-beta.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +1 -1
- package/CONTRIBUTING.md +1 -0
- package/INSTALL.md +49 -46
- package/README.md +8 -7
- package/bin/clearotron.mjs +14 -0
- package/bin/connect.mjs +68 -3
- package/bin/key.mjs +6 -1
- package/bin/onboard.mjs +51 -14
- package/bin/passphrase.mjs +4 -2
- package/bin/start.mjs +43 -9
- package/build-info.json +2 -2
- package/docs/architecture/05-config-governance.md +1 -1
- package/driver/CHANGELOG.md +416 -388
- package/driver/contract-vocabulary.mjs +5 -5
- package/driver/engine/mcp/recording-server.mjs +21 -1
- package/driver/engine/mcp/supplemental.mjs +22 -5
- package/driver/knockout-next-step.mjs +72 -0
- package/driver/named-band.mjs +1 -1
- package/driver/package.json +1 -1
- package/driver/phase0.mjs +16 -7
- package/driver/pipeline-knockout.mjs +26 -0
- package/driver/pipeline.mjs +48 -1
- package/driver/portal-local-auth.mjs +14 -4
- package/driver/portal-report.mjs +21 -2
- package/driver/portal-service.mjs +20 -11
- package/driver/publish/attr.mjs +36 -0
- package/driver/publish/index.mjs +10 -10
- package/driver/publish/parse.mjs +1 -1
- package/driver/publish/render-knockout.mjs +14 -9
- package/driver/publish/render.mjs +9 -9
- package/driver/publish/xlsx.mjs +7 -1
- package/driver/queue-watch-verdict.mjs +1 -1
- package/driver/register-availability.mjs +1 -1
- package/driver/register-plan.mjs +81 -11
- package/driver/result-noun-fields.mjs +3 -1
- package/driver/stages-knockout.mjs +1 -1
- package/driver/suite-census.json +104 -38
- package/driver/unit-inventory.mjs +7 -7
- package/driver/verify-knockout.mjs +0 -27
- package/mcp-server/CHANGELOG.md +27 -19
- package/mcp-server/lib/audit-view.mjs +4 -4
- package/mcp-server/lib/trace.mjs +1 -1
- package/mcp-server/package.json +1 -1
- package/package.json +2 -2
- package/portal-ui/dist/assets/{index-CtvwLCti.css → index-7Lq-dXDV.css} +12 -9
- package/portal-ui/dist/assets/{index-DXSRxPV_.js → index-w8GFZftk.js} +110 -69
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/jx/README.md +2 -2
- package/providers/jx-subclass/README.md +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/README.md +5 -10
- package/scripts/ask-ai-render-check.mjs +26 -1
- package/scripts/citation-line-check.mjs +2 -2
- package/scripts/dead-names.mjs +17 -16
- package/scripts/e2e.mjs +8 -8
- package/scripts/env-audit.mjs +6 -0
- package/scripts/generated-files-are-current.mjs +35 -4
- package/scripts/live-surface-check.mjs +17 -17
- package/scripts/release-code-scanning-check.mjs +114 -0
- package/scripts/release-visible-check.mjs +117 -0
- package/scripts/repo-writes.mjs +42 -0
- package/scripts/report-sections-render-check.mjs +245 -0
- package/scripts/report-theme-render-check.mjs +31 -3
- package/scripts/settings-render-check.mjs +10 -7
- package/scripts/test-run.mjs +63 -4
- package/shared/client-door.mjs +20 -0
- package/shared/invocation.mjs +2 -2
- package/shared/parent-watch.mjs +33 -0
- package/shared/register-selection.mjs +4 -1
- package/shared/root-doc-commands.mjs +10 -4
- package/shared/running-start.mjs +14 -3
- package/shared/scope.mjs +24 -5
|
@@ -254,19 +254,19 @@ html.anon-on [data-anon='mark']:not(.anon-clear) {
|
|
|
254
254
|
border-bottom: 1px solid var(--border-hairline);
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
-
/* THE HEADER'S
|
|
257
|
+
/* THE HEADER'S LOWER ROWS: which read you are in, then where you are inside it.
|
|
258
258
|
The Reads strip used to sit BELOW this box and the report's own section breadcrumb inside the frame,
|
|
259
259
|
so neither stayed on screen — the frame is sized to its content, which leaves a `position:sticky` bar
|
|
260
260
|
in there pinned to nothing. Both are rows of the pinned header now (owner, 2026-09-18).
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
.
|
|
264
|
-
|
|
261
|
+
THE SECTIONS HAVE A ROW OF THEIR OWN, UNDER THE READS (owner, 2026-09-19). Side by side, sixteen reads
|
|
262
|
+
wrapped into two ragged columns while the strip floated beside them at mid-height. The reads wrap; the
|
|
263
|
+
sections scroll sideways rather than wrapping, so the strip stays one line however narrow the screen. */
|
|
264
|
+
.report-nav { display: flex; flex-direction: column; align-items: stretch; gap: 8px; margin-top: 9px; }
|
|
265
|
+
.report-sections {
|
|
266
|
+
display: flex; align-items: center; gap: 2px;
|
|
265
267
|
overflow-x: auto; scrollbar-width: none;
|
|
266
268
|
}
|
|
267
|
-
.report-
|
|
268
|
-
.report-nav-sep { flex: none; width: 1px; align-self: stretch; background: var(--border-hairline); }
|
|
269
|
-
.report-sections { display: flex; align-items: center; gap: 2px; }
|
|
269
|
+
.report-sections::-webkit-scrollbar { display: none; }
|
|
270
270
|
.report-section {
|
|
271
271
|
flex: none; display: inline-flex; align-items: center; gap: 7px; white-space: nowrap;
|
|
272
272
|
font: inherit; font-size: 12.5px; font-weight: 600; letter-spacing: .01em;
|
|
@@ -277,8 +277,11 @@ html.anon-on [data-anon='mark']:not(.anon-clear) {
|
|
|
277
277
|
.report-section-dot {
|
|
278
278
|
width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid currentColor; box-sizing: border-box;
|
|
279
279
|
}
|
|
280
|
+
/* PROGRESS: every section reached has a filled dot, the rest stay hollow; the one in view also sits on
|
|
281
|
+
the sunken pill, so it can be told from the ones behind it. */
|
|
282
|
+
.report-section[data-reached='true'] { color: var(--text-strong); }
|
|
283
|
+
.report-section[data-reached='true'] .report-section-dot { background: var(--accent); border-color: var(--accent); }
|
|
280
284
|
.report-section[aria-current='true'] { color: var(--text-strong); background: var(--surface-sunken); }
|
|
281
|
-
.report-section[aria-current='true'] .report-section-dot { border-color: var(--accent); border-width: 3px; }
|
|
282
285
|
|
|
283
286
|
/* The breadcrumb above it: one step back as a quiet button, then where the reader is. Compound selectors
|
|
284
287
|
throughout this block, because the button and row rules they adjust are declared later in the file. */
|
|
@@ -18201,11 +18201,15 @@ function readFrameSections(data, sameSource) {
|
|
|
18201
18201
|
const out = [];
|
|
18202
18202
|
for (const raw of msg.sections) {
|
|
18203
18203
|
if (typeof raw !== "object" || raw === null) continue;
|
|
18204
|
-
const { id, label } = raw;
|
|
18204
|
+
const { id, label, top } = raw;
|
|
18205
18205
|
if (typeof id !== "string" || typeof label !== "string") continue;
|
|
18206
18206
|
const trimmed = label.trim().slice(0, 40);
|
|
18207
18207
|
if (!id.trim() || !trimmed) continue;
|
|
18208
|
-
out.push({
|
|
18208
|
+
out.push(typeof top === "number" && Number.isFinite(top) && top >= 0 ? {
|
|
18209
|
+
id,
|
|
18210
|
+
label: trimmed,
|
|
18211
|
+
top: Math.round(top)
|
|
18212
|
+
} : {
|
|
18209
18213
|
id,
|
|
18210
18214
|
label: trimmed
|
|
18211
18215
|
});
|
|
@@ -18213,6 +18217,16 @@ function readFrameSections(data, sameSource) {
|
|
|
18213
18217
|
}
|
|
18214
18218
|
return out;
|
|
18215
18219
|
}
|
|
18220
|
+
function sectionsReached(sections, line, where) {
|
|
18221
|
+
if (where.atTop) return 0;
|
|
18222
|
+
if (where.atFoot) return sections.length;
|
|
18223
|
+
let n = 0;
|
|
18224
|
+
for (const s of sections) {
|
|
18225
|
+
if (typeof s.top !== "number" || s.top > line) break;
|
|
18226
|
+
n += 1;
|
|
18227
|
+
}
|
|
18228
|
+
return n;
|
|
18229
|
+
}
|
|
18216
18230
|
function readFrameTheme(data, sameSource) {
|
|
18217
18231
|
if (!sameSource) return null;
|
|
18218
18232
|
if (typeof data !== "object" || data === null) return null;
|
|
@@ -18243,6 +18257,8 @@ function usePortalTheme() {
|
|
|
18243
18257
|
}, []);
|
|
18244
18258
|
return theme;
|
|
18245
18259
|
}
|
|
18260
|
+
var TOPBAR_PX = 56;
|
|
18261
|
+
var LANDING_PX = 10;
|
|
18246
18262
|
function useReportFrame() {
|
|
18247
18263
|
const ref = (0, import_react.useRef)(null);
|
|
18248
18264
|
const [height, setHeight] = (0, import_react.useState)(FIRST_PAINT);
|
|
@@ -18276,7 +18292,7 @@ function useReportFrame() {
|
|
|
18276
18292
|
if (jump !== null) {
|
|
18277
18293
|
if (frame) {
|
|
18278
18294
|
const head = document.querySelector(".report-head");
|
|
18279
|
-
const chrome =
|
|
18295
|
+
const chrome = TOPBAR_PX + (head instanceof HTMLElement ? head.offsetHeight : 48) + LANDING_PX;
|
|
18280
18296
|
const y = frame.getBoundingClientRect().top + window.scrollY + jump - chrome;
|
|
18281
18297
|
window.scrollTo({
|
|
18282
18298
|
top: Math.max(0, y),
|
|
@@ -18478,10 +18494,45 @@ function Result({ ctx, runId, markSlug }) {
|
|
|
18478
18494
|
const run = (0, import_react.useMemo)(() => runs.find((r) => r.runId === runId) ?? null, [runs, runId]);
|
|
18479
18495
|
const reads = (0, import_react.useMemo)(() => run ? readsFor(runs, run) : [], [runs, run]);
|
|
18480
18496
|
const frame = useReportFrame();
|
|
18481
|
-
const [
|
|
18497
|
+
const [reached, setReached] = (0, import_react.useState)(0);
|
|
18498
|
+
const sectionList = frame.sections;
|
|
18499
|
+
const frameRef = frame.ref;
|
|
18482
18500
|
(0, import_react.useEffect)(() => {
|
|
18483
|
-
|
|
18484
|
-
|
|
18501
|
+
if (!sectionList || sectionList.length < 2) {
|
|
18502
|
+
setReached(0);
|
|
18503
|
+
return;
|
|
18504
|
+
}
|
|
18505
|
+
let queued = false;
|
|
18506
|
+
const measure = () => {
|
|
18507
|
+
queued = false;
|
|
18508
|
+
const el = frameRef.current;
|
|
18509
|
+
if (!el) return;
|
|
18510
|
+
const head = document.querySelector(".report-head");
|
|
18511
|
+
const lineOnScreen = (head instanceof HTMLElement ? head.getBoundingClientRect().bottom : TOPBAR_PX) + LANDING_PX + 2;
|
|
18512
|
+
const where = {
|
|
18513
|
+
atTop: document.documentElement.scrollHeight > window.innerHeight + 2 && window.scrollY <= 0,
|
|
18514
|
+
atFoot: window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2
|
|
18515
|
+
};
|
|
18516
|
+
setReached(sectionsReached(sectionList, lineOnScreen - el.getBoundingClientRect().top, where));
|
|
18517
|
+
};
|
|
18518
|
+
const later = () => {
|
|
18519
|
+
if (!queued) {
|
|
18520
|
+
queued = true;
|
|
18521
|
+
requestAnimationFrame(measure);
|
|
18522
|
+
}
|
|
18523
|
+
};
|
|
18524
|
+
measure();
|
|
18525
|
+
window.addEventListener("scroll", later, { passive: true });
|
|
18526
|
+
window.addEventListener("resize", later);
|
|
18527
|
+
return () => {
|
|
18528
|
+
window.removeEventListener("scroll", later);
|
|
18529
|
+
window.removeEventListener("resize", later);
|
|
18530
|
+
};
|
|
18531
|
+
}, [
|
|
18532
|
+
sectionList,
|
|
18533
|
+
frame.height,
|
|
18534
|
+
frameRef
|
|
18535
|
+
]);
|
|
18485
18536
|
const grouped = run !== null && showsAssessment(run, markSlug);
|
|
18486
18537
|
const { result: summaryResult } = useLoad(() => grouped ? api.runSummary(runId) : Promise.resolve({ kind: "notFound" }), [runId, grouped]);
|
|
18487
18538
|
const assessment = summaryResult?.kind === "ok" ? summaryResult.value : [];
|
|
@@ -18612,70 +18663,60 @@ function Result({ ctx, runId, markSlug }) {
|
|
|
18612
18663
|
]
|
|
18613
18664
|
}), hasThread(reads) || (frame.sections?.length ?? 0) > 1 ? (0, import_jsx_runtime.jsxs)("div", {
|
|
18614
18665
|
className: "report-nav",
|
|
18615
|
-
children: [
|
|
18616
|
-
|
|
18617
|
-
|
|
18618
|
-
|
|
18619
|
-
|
|
18620
|
-
|
|
18621
|
-
|
|
18622
|
-
|
|
18623
|
-
|
|
18624
|
-
|
|
18625
|
-
|
|
18626
|
-
|
|
18627
|
-
|
|
18628
|
-
|
|
18629
|
-
|
|
18630
|
-
|
|
18631
|
-
|
|
18632
|
-
|
|
18633
|
-
|
|
18666
|
+
children: [hasThread(reads) ? (0, import_jsx_runtime.jsxs)("div", {
|
|
18667
|
+
style: {
|
|
18668
|
+
display: "flex",
|
|
18669
|
+
gap: 8,
|
|
18670
|
+
flexWrap: "wrap",
|
|
18671
|
+
alignItems: "center"
|
|
18672
|
+
},
|
|
18673
|
+
children: [(0, import_jsx_runtime.jsx)("span", {
|
|
18674
|
+
className: "eyebrow",
|
|
18675
|
+
style: { alignSelf: "center" },
|
|
18676
|
+
children: "Reads"
|
|
18677
|
+
}), reads.map((r) => {
|
|
18678
|
+
const active = r.runId === run.runId;
|
|
18679
|
+
return (0, import_jsx_runtime.jsxs)("button", {
|
|
18680
|
+
type: "button",
|
|
18681
|
+
className: "pill",
|
|
18682
|
+
"aria-current": active ? "true" : void 0,
|
|
18683
|
+
onClick: () => ctx.go(resultPath(r.runId)),
|
|
18684
|
+
style: {
|
|
18685
|
+
cursor: "pointer",
|
|
18686
|
+
display: "inline-flex",
|
|
18687
|
+
alignItems: "center",
|
|
18688
|
+
gap: 7,
|
|
18689
|
+
background: active ? "var(--accent-wash)" : "var(--surface-sunken)",
|
|
18690
|
+
borderColor: active ? "var(--accent)" : "var(--border-hairline)",
|
|
18691
|
+
color: active ? "var(--text-accent)" : "var(--text-muted)"
|
|
18692
|
+
},
|
|
18693
|
+
children: [r.tone ? (0, import_jsx_runtime.jsx)("span", {
|
|
18694
|
+
className: "dot",
|
|
18634
18695
|
style: {
|
|
18635
|
-
|
|
18636
|
-
|
|
18637
|
-
|
|
18638
|
-
|
|
18639
|
-
|
|
18640
|
-
|
|
18641
|
-
|
|
18642
|
-
|
|
18643
|
-
|
|
18644
|
-
|
|
18645
|
-
|
|
18646
|
-
|
|
18647
|
-
|
|
18648
|
-
|
|
18649
|
-
|
|
18650
|
-
|
|
18651
|
-
|
|
18652
|
-
|
|
18653
|
-
|
|
18654
|
-
|
|
18655
|
-
|
|
18656
|
-
|
|
18657
|
-
})
|
|
18658
|
-
|
|
18659
|
-
className: "report-sections",
|
|
18660
|
-
"aria-label": "Sections of this report",
|
|
18661
|
-
children: (frame.sections ?? []).map((sec, i) => {
|
|
18662
|
-
const now = atSection === null ? i === 0 : atSection === sec.id;
|
|
18663
|
-
return (0, import_jsx_runtime.jsxs)("button", {
|
|
18664
|
-
type: "button",
|
|
18665
|
-
className: "report-section",
|
|
18666
|
-
"aria-current": now ? "true" : void 0,
|
|
18667
|
-
onClick: () => {
|
|
18668
|
-
setAtSection(sec.id);
|
|
18669
|
-
frame.send("section", sec.id);
|
|
18670
|
-
},
|
|
18671
|
-
children: [(0, import_jsx_runtime.jsx)("span", {
|
|
18672
|
-
className: "report-section-dot",
|
|
18673
|
-
"aria-hidden": "true"
|
|
18674
|
-
}), sec.label]
|
|
18675
|
-
}, sec.id);
|
|
18676
|
-
})
|
|
18677
|
-
}) : null
|
|
18678
|
-
]
|
|
18696
|
+
background: `var(--tone-${r.tone})`,
|
|
18697
|
+
width: 7,
|
|
18698
|
+
height: 7
|
|
18699
|
+
}
|
|
18700
|
+
}) : null, readLabel(r)]
|
|
18701
|
+
}, r.runId);
|
|
18702
|
+
})]
|
|
18703
|
+
}) : null, (frame.sections?.length ?? 0) > 1 ? (0, import_jsx_runtime.jsx)("nav", {
|
|
18704
|
+
className: "report-sections",
|
|
18705
|
+
"aria-label": "Sections of this report",
|
|
18706
|
+
children: (frame.sections ?? []).map((sec, i) => {
|
|
18707
|
+
return (0, import_jsx_runtime.jsxs)("button", {
|
|
18708
|
+
type: "button",
|
|
18709
|
+
className: "report-section",
|
|
18710
|
+
"data-reached": i < reached ? "true" : void 0,
|
|
18711
|
+
"aria-current": i === reached - 1 ? "true" : void 0,
|
|
18712
|
+
onClick: () => frame.send("section", sec.id),
|
|
18713
|
+
children: [(0, import_jsx_runtime.jsx)("span", {
|
|
18714
|
+
className: "report-section-dot",
|
|
18715
|
+
"aria-hidden": "true"
|
|
18716
|
+
}), sec.label]
|
|
18717
|
+
}, sec.id);
|
|
18718
|
+
})
|
|
18719
|
+
}) : null]
|
|
18679
18720
|
}) : null]
|
|
18680
18721
|
}),
|
|
18681
18722
|
frame.failed ? (0, import_jsx_runtime.jsxs)("div", {
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
-->
|
|
50
50
|
<link rel="preconnect" href="https://api.fontshare.com" crossorigin />
|
|
51
51
|
<link href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700,900&display=swap" rel="stylesheet" />
|
|
52
|
-
<script type="module" crossorigin src="/portal/assets/index-
|
|
53
|
-
<link rel="stylesheet" crossorigin href="/portal/assets/index-
|
|
52
|
+
<script type="module" crossorigin src="/portal/assets/index-w8GFZftk.js"></script>
|
|
53
|
+
<link rel="stylesheet" crossorigin href="/portal/assets/index-7Lq-dXDV.css">
|
|
54
54
|
</head>
|
|
55
55
|
<body>
|
|
56
56
|
<div id="root"></div>
|
package/portal-ui/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "portal-ui",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.3.2-beta.
|
|
5
|
+
"version": "0.3.2-beta.12",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
7
7
|
"description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
|
|
8
8
|
"engines": {
|
package/providers/jx/README.md
CHANGED
|
@@ -4,14 +4,14 @@ Three pure modules, **one non-agentic structured turn each, per mark × lane**,
|
|
|
4
4
|
configured for. Not an agent: no tools, no MCP, no skills, no resume — one prompt in, one JSON object out,
|
|
5
5
|
validated at parse.
|
|
6
6
|
|
|
7
|
-
## What these lanes
|
|
7
|
+
## What these lanes are, and what runs them
|
|
8
8
|
|
|
9
9
|
**One vendor, one billing mode, decided by the run and not by this directory.** The lanes go through
|
|
10
10
|
`engine.runTurn()` — the same door all fourteen agentic stages use — via
|
|
11
11
|
[`../../driver/engine/jx-turn.mjs`](../../driver/engine/jx-turn.mjs). Whatever program the customer configured
|
|
12
12
|
(`CLEAROTRON_AI`) and whatever billing mode the run is on (subscription, API key or, for Claude, a cloud account)
|
|
13
13
|
carries these calls too.
|
|
14
|
-
That is the
|
|
14
|
+
That is the product's rule — *one reasoning provider per run, API or subscription, never a mix* — and until 2026-08-20
|
|
15
15
|
these three lanes were the one place in the product that broke it: they POSTed to the Anthropic Messages API on
|
|
16
16
|
`ANTHROPIC_API_KEY` at a hardcoded cheap tier no matter what the rest of the run was doing.
|
|
17
17
|
|
|
@@ -72,7 +72,7 @@ class 44) and `351918` (cosmetics retail, class 35). A class-3 search finds neit
|
|
|
72
72
|
goods that have a Nice basic number (609), so validating a relation's target group against it would drop
|
|
73
73
|
real relations without a word. The authority is `tw/tw-group-codes.json`, taken from TIPO's group headers.
|
|
74
74
|
|
|
75
|
-
## Korea
|
|
75
|
+
## Korea
|
|
76
76
|
|
|
77
77
|
KIPO publishes 「유사상품 심사기준(니스 제13판)」 as a direct download: **21.8 MB, 1,011 pages, full text
|
|
78
78
|
layer** (`kpoContFileDown.do?seq=30&fileNum=25`, Referer header required).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-oauth-mcp-bridge",
|
|
3
|
-
"version": "0.3.2-beta.
|
|
3
|
+
"version": "0.3.2-beta.12",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
5
|
"private": true,
|
|
6
6
|
"description": "OAuth 2.1 MCP stdio bridge used by the engine's case-law gather stage (courtlistener / legaldatahunter).",
|
package/scripts/README.md
CHANGED
|
@@ -13,7 +13,7 @@ The axis is *what the script assumes exists*, not where it happens to be run.
|
|
|
13
13
|
| Script | What it does |
|
|
14
14
|
|---|---|
|
|
15
15
|
| `test-run.mjs` | Gives a test run its own `TMPDIR` and deletes it at exit. **Load-bearing** — every workspace's `npm test` shells out to it, so `scripts/` can never be dropped wholesale. |
|
|
16
|
-
| `render-check.mjs` | Renders a published report **inside the portal's iframe** in a real browser — the height bridge, the scrollbar loop, the sticky topbar. Needs a published run: point it at a pool with `--pool`, or pass `--fixture-pool` to replay `demo/` into a throwaway one.
|
|
16
|
+
| `render-check.mjs` | Renders a published report **inside the portal's iframe** in a real browser — the height bridge, the scrollbar loop, the sticky topbar. Needs a published run: point it at a pool with `--pool`, or pass `--fixture-pool` to replay `demo/` into a throwaway one. Run by CI on a fixture pool. Its interior assertions measure deterministically: real time plus an explicit readiness signal, and `--plant-overflow` proves it still catches a broken report. |
|
|
17
17
|
| `home-render-check.mjs` | Draws the portal Home in a real browser, every state, both themes. Run by CI. |
|
|
18
18
|
| `composer-render-check.mjs` | Lays out the New Clearance composer and checks the levers reach the wire. Run by CI. |
|
|
19
19
|
| `clearances-render-check.mjs` | Checks the `/portal/clearances` columns hold together. Run by CI. |
|
|
@@ -80,15 +80,10 @@ settling on the first try every time, and the slack figures that looked like a l
|
|
|
80
80
|
147px against a 15-24 band) turned out to be the stale probe too. The sandbox is byte-identical to
|
|
81
81
|
`Result.tsx`'s and `allow-same-origin` was never added.
|
|
82
82
|
|
|
83
|
-
**
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
Read the other six with that in mind: they are outer-document measurements — does the page scroll, is
|
|
88
|
-
the header pinned, is Export reachable — and every one would pass against **an iframe that loaded
|
|
89
|
-
nothing**, because the frame is styled to a fixed height. A green from this check would have meant
|
|
90
|
-
almost nothing. The step is backed out and the script is declared, with the measurement, rather than
|
|
91
|
-
quietly dropped.
|
|
83
|
+
**CI runs it**, on a pool replayed from `demo/` into a throwaway directory:
|
|
84
|
+
`node scripts/render-check.mjs --fixture-pool`. Its outer-document assertions — does the page scroll, is
|
|
85
|
+
the header pinned, is Export reachable — would pass against an iframe that loaded nothing, because the
|
|
86
|
+
frame is styled to a fixed height; the interior ones above are what make a green mean something.
|
|
92
87
|
|
|
93
88
|
**Which check runs where is now checked, not just written here.** A test in the driver suite
|
|
94
89
|
reads `.github/workflows/ci.yml` and requires every `scripts/*-check.mjs` to be either invoked by a job
|
|
@@ -133,6 +133,10 @@ let deck = 'connected'
|
|
|
133
133
|
// travels the real injected script to the real listener. Two cards at fixed places: a numbered finding,
|
|
134
134
|
// and an "Also considered" card, which carries the button and no number.
|
|
135
135
|
let reportDoc = 'stub'
|
|
136
|
+
// A SLOW RUNNER, ON DEMAND. The finding's document is held back this long before it is served, which is
|
|
137
|
+
// how a CI runner that loaded it late is reproduced: the press below has to wait for the document, not
|
|
138
|
+
// for a fixed time. Zero, the default, serves it at once.
|
|
139
|
+
const FRAME_DELAY_MS = Number(process.env.ASK_AI_CHECK_FRAME_DELAY_MS ?? 0) || 0
|
|
136
140
|
const FINDING_DOC = () => prepareReportForEmbed(`<!doctype html><html><head><meta charset="utf-8"><title>report</title></head>
|
|
137
141
|
<body style="margin:0;font-family:sans-serif;background:#fff;height:400px">
|
|
138
142
|
<div class="card" id="c3" style="position:absolute;top:40px;left:20px"><div class="cardhead"><span class="fnum">3</span>
|
|
@@ -159,7 +163,8 @@ const server = createServer((req, res) => {
|
|
|
159
163
|
// the height a published report's bridge announces, so the header draws the Export menu a reader meets.
|
|
160
164
|
if (p.startsWith('/portal/report/') && reportDoc === 'finding') {
|
|
161
165
|
res.writeHead(200, { 'content-type': 'text/html' })
|
|
162
|
-
|
|
166
|
+
const doc = FINDING_DOC()
|
|
167
|
+
return FRAME_DELAY_MS ? setTimeout(() => res.end(doc), FRAME_DELAY_MS) : res.end(doc)
|
|
163
168
|
}
|
|
164
169
|
if (p.startsWith('/portal/report/')) {
|
|
165
170
|
res.writeHead(200, { 'content-type': 'text/html' })
|
|
@@ -433,9 +438,29 @@ const arrived = await value(PROBE)
|
|
|
433
438
|
// null origin and the point is the whole path — the injected script hears the press, posts it, and the
|
|
434
439
|
// report screen opens its control. The cards sit at fixed places in the document, so the frame's own
|
|
435
440
|
// position on the page is all that has to be measured.
|
|
441
|
+
// READY, NOT A FIXED WAIT. The press lands on a finding's button, and it does something only once the
|
|
442
|
+
// document in the frame has loaded and its bridge is listening. A fixed pause after navigating was not
|
|
443
|
+
// always that on a CI runner: the press arrived first and opened nothing (measured on beta-12, 2026-09-19,
|
|
444
|
+
// with no change to the portal or the bridge since a green run). The bridge posts the document's height
|
|
445
|
+
// from the same script that listens for the press, so a frame that has left its first-paint height has a
|
|
446
|
+
// listener. The Ask AI control must be on the header too, since the press opens it. Both are waited for,
|
|
447
|
+
// and the frame's place must hold still across two reads, so the press is aimed where the button is.
|
|
448
|
+
const FIRST_PAINT = Number(/export const FIRST_PAINT = (\d+)/.exec(readFileSync(join(DIST, '..', 'src', 'contract', 'reportFrame.ts'), 'utf8'))?.[1] ?? NaN)
|
|
449
|
+
const frameReady = async () => {
|
|
450
|
+
let last = null
|
|
451
|
+
for (let i = 0; i < 200; i++) {
|
|
452
|
+
const now = await value(`(() => { const f = document.querySelector('iframe'); if (!f || !document.querySelector('[data-ask-ai] > button')) return null;
|
|
453
|
+
const r = f.getBoundingClientRect(); return Math.round(r.top) + ':' + Math.round(r.height) })()`)
|
|
454
|
+
if (now && now === last && !now.endsWith(`:${FIRST_PAINT}`)) return true
|
|
455
|
+
last = now
|
|
456
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
457
|
+
}
|
|
458
|
+
return false
|
|
459
|
+
}
|
|
436
460
|
const pressInFrame = async (top) => {
|
|
437
461
|
await value('(() => { window.scrollTo(0, 0); return true })()')
|
|
438
462
|
await new Promise((r) => setTimeout(r, 200))
|
|
463
|
+
if (!(await frameReady())) return false
|
|
439
464
|
const at = await value(`(() => { const f = document.querySelector('iframe'); if (!f) return null; const r = f.getBoundingClientRect(); return { x: r.left, y: r.top } })()`)
|
|
440
465
|
if (!at) return false
|
|
441
466
|
const x = Math.round(at.x + 140), y = Math.round(at.y + top + 16)
|
|
@@ -79,10 +79,10 @@ export const EXEMPT_TARGETS = [
|
|
|
79
79
|
{ target: /^driver\/(x|a|f|nope)\.mjs$/, why: "planted names in contract-audit.test.mjs — the test feeds the auditor a citation to a file that does not exist ON PURPOSE" },
|
|
80
80
|
{ target: /^driver\/skills\/[ab]\/(rules|SKILL)\.md$/, why: "planted skill paths in contract-audit.test.mjs, same shape" },
|
|
81
81
|
{ target: /^planted\//, why: "THE NAMESPACE for a fixture path that must not exist. publication-scrub.test.mjs plants an undeclared path to prove the scrubber sees it, and cut-vendor-keys.test.mjs plants one to prove the record-key detector reports a location. Unlike the namespaced entries below, this one is deliberately shared: `planted/` names the class rather than one test's fixtures, so a new test planting a non-existent path uses it instead of inventing a second convention — and a bare invented name is what reports as a dangling citation to a file nobody meant to create" },
|
|
82
|
-
{ target: /^diffcase-[a-z]+\.mjs$/, why: "SYNTHETIC DIFF FIXTURE FILENAMES in pushed-content-guard.test.mjs
|
|
82
|
+
{ target: /^diffcase-[a-z]+\.mjs$/, why: "SYNTHETIC DIFF FIXTURE FILENAMES in pushed-content-guard.test.mjs. That test feeds `addedLines` a hand-written unified diff, so line 4 of `keep.mjs` is DIFF DATA the guard must parse, not a citation to anything. Same class as the contract-audit planted names directly above: a test that must contain a file reference to a file that does not exist, on purpose. NAMESPACED `diffcase-` deliberately: a first cut exempted the bare names `keep|dropped|gone|zz` and SWALLOWED two of this file's own controls, which plant line 12 of `gone.mjs` — an exemption wide enough to cover somebody else's known-bad turns their arm green while it proves nothing" },
|
|
83
83
|
{ target: /^driver\/engine\/mcp\/planted-server\.mjs$/, why: "a planted server module name in contract-audit.test.mjs" },
|
|
84
84
|
{ target: /^srv\/app\//, why: "SYNTHETIC DEPLOYMENT STACK TRACES. The house rule for fixtures is to use a `/srv/…` path and never a real `/home/<name>/` one (deployment-hostnames.test.mjs polices the other half), so a hand-written trace like \"TypeError: x is not a function at /srv/app/driver/pipeline.mjs:2411:9\" is test DATA the parser under test must read — the line number is the datum, not a claim about our tree. It passed for as long as it did by luck: this resolver strips the prefix and lands on the real driver/pipeline.mjs, so the fixture went green while whatever line it happened to name was real code, and reported BLANK the first time an unrelated edit made that line a brace. NAMESPACED to `srv/app/` for the reason the diffcase- entry gives — nothing in this repo ships under srv/, and a wider prefix would swallow a genuine citation someone writes tomorrow" },
|
|
85
|
-
{ target: /^(?:driver\/)?driftcase-[a-z-]+\.mjs$/, why: "SYNTHETIC DRIFT FIXTURE FILENAMES in citation-drift-at-the-diff.test.mjs
|
|
85
|
+
{ target: /^(?:driver\/)?driftcase-[a-z-]+\.mjs$/, why: "SYNTHETIC DRIFT FIXTURE FILENAMES in citation-drift-at-the-diff.test.mjs. That test feeds the drift report a hand-built corpus in which line 40 of `driftcase-target.mjs` is the DATUM being classified — the thing whose movement is measured — not a citation to anything. Same class as the planted names above, and NAMESPACED under `driver/` for the reason the `diffcase-` entry gives: an exemption wide enough to cover another test's known-bad turns their arm green while it proves nothing. The directory prefix is OPTIONAL because one arm cites the BARE BASENAME on purpose: that is the exact shape that made `resolveCited` short-circuit on an always-true `exists` and print a clean tick, so the arm cannot be written any other way. `driftcase-` carries the namespacing on its own — nothing else in this tree uses the prefix" },
|
|
86
86
|
{ target: /^common-law-findings\.md$/, why: "a RUN ARTIFACT, written into a run directory at execution time. It is cited by name in doubt-ledger and audit-from-spine tests because that is what the artifact is called; it is not and never will be a tracked file" },
|
|
87
87
|
// ✕ REMOVED: `codex/dist/(helper|cli).js` — a captured CODEX stack trace in
|
|
88
88
|
// exit-cause-survives-the-digest.test.mjs. Both of its citations carry a COLUMN, so `citationsIn`
|
package/scripts/dead-names.mjs
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// node scripts/dead-names.mjs report; exit 1 if a dead name is back
|
|
7
7
|
// node scripts/dead-names.mjs --json the same, as JSON
|
|
8
8
|
//
|
|
9
|
-
// ruled Lore and Aughra dead names that may appear nowhere, and the purge is done: the issue's
|
|
10
|
-
// own pattern returned 58 files when it was written and
|
|
9
|
+
// The owner ruled Lore and Aughra dead names that may appear nowhere, and the purge is done: the issue's
|
|
10
|
+
// own pattern returned 58 files when it was written and ONE when this guard landed, a dictionary snippet
|
|
11
11
|
// about hunting inside captured search results. What does not exist is anything to keep it that way. A
|
|
12
12
|
// name published once stays published, so the cost of a re-introduction is not a revert — it is a name
|
|
13
13
|
// in the history of a repository that is about to be cut in public.
|
|
@@ -42,37 +42,38 @@ export const GUARD = "dead-names (#853)";
|
|
|
42
42
|
// the issue names as deployed surfaces, and `_` is a word character, so a trailing `\b` would miss the
|
|
43
43
|
// environment variable while catching the prose. No English word begins with those six letters, so the
|
|
44
44
|
// leading boundary is all the protection the arm needs — measured: zero hits in the tree either way.
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
export const DEAD_NAME_RE = /lorestar|lore_(pool|url|flags)|clearance_lore|lorectl|loreurl|lorecontrols|trademark-lore|\blore\b|\baughra
|
|
45
|
+
// A PER-PERSON IDENTITY IS THE SAME KIND OF NAME, AND IT IS NOT WRITTEN HERE. An agent identity named
|
|
46
|
+
// after a real person must never reappear either, and the mechanism is identical. But a public guard
|
|
47
|
+
// that spells out a person's name publishes the name it exists to keep out, so no such arm is written
|
|
48
|
+
// into this file: `deadNameHits` takes its pattern as an argument, and the test drives the mechanism
|
|
49
|
+
// with an invented identity. The names the tests give agents and requesters are invented too.
|
|
50
|
+
export const DEAD_NAME_RE = /lorestar|lore_(pool|url|flags)|clearance_lore|lorectl|loreurl|lorecontrols|trademark-lore|\blore\b|\baughra/i;
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* Where a true match is somebody else's word rather than our dead name. NAMED, with the reason, and
|
|
54
54
|
* scoped to the file — never a blanket pattern, because a blanket would re-admit the name everywhere.
|
|
55
|
+
* The first exemption was a dictionary snippet in the demo's captured common-law results; the demo was
|
|
56
|
+
* re-captured without it, so that exemption was deleted rather than carried.
|
|
55
57
|
*/
|
|
56
58
|
export const EXEMPTIONS = [
|
|
57
59
|
{
|
|
58
|
-
file: "
|
|
59
|
-
why: "
|
|
60
|
-
+ "
|
|
61
|
-
+ "
|
|
62
|
-
+ "satisfy a lint, which is the one thing a receipt may never be.",
|
|
60
|
+
file: "driver/wordlists/en.txt",
|
|
61
|
+
why: "the engine's list of ordinary English words, the dictionary of the one-letter neighbourhood "
|
|
62
|
+
+ "filter. `lore` is an ordinary English word and sits on it. The list only ever removes queries, "
|
|
63
|
+
+ "so taking the word out would change which searches a run makes, to satisfy a lint.",
|
|
63
64
|
},
|
|
64
65
|
];
|
|
65
66
|
|
|
66
67
|
const exemptionFor = (file) => EXEMPTIONS.find((e) => e.file === file) ?? null;
|
|
67
68
|
|
|
68
|
-
/** Every dead-name hit in a corpus of {file, text}. PURE. */
|
|
69
|
-
export function deadNameHits(corpus) {
|
|
69
|
+
/** Every dead-name hit in a corpus of {file, text}, against DEAD_NAME_RE unless a pattern is given. PURE. */
|
|
70
|
+
export function deadNameHits(corpus, pattern = DEAD_NAME_RE) {
|
|
70
71
|
const out = [];
|
|
71
72
|
for (const { file, text } of corpus ?? []) {
|
|
72
73
|
if (exemptionFor(file)) continue;
|
|
73
74
|
const lines = String(text ?? "").split("\n");
|
|
74
75
|
for (let i = 0; i < lines.length; i++) {
|
|
75
|
-
if (
|
|
76
|
+
if (pattern.test(lines[i])) out.push({ file, line: i + 1, text: lines[i].trim().slice(0, 160) });
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
79
|
return out;
|
package/scripts/e2e.mjs
CHANGED
|
@@ -922,7 +922,7 @@ export async function enqueueViaMcp(job, { clientPrincipal = false, forwarder =
|
|
|
922
922
|
// harness named the door that BEHAVED as the defect. Two hours later the codex arm hit no 429 and the
|
|
923
923
|
// same case was clean — so the finding also reads as a flake, which is the worst of both.
|
|
924
924
|
//
|
|
925
|
-
// The rule the asymmetry check encodes
|
|
925
|
+
// The rule the door-asymmetry check encodes is sound: for a case both doors should refuse, the one
|
|
926
926
|
// that accepted is the bug. It rests on an assumption nobody wrote down — that a refusal is a
|
|
927
927
|
// PRODUCT-SCOPE JUDGMENT. A 429 or a 5xx is not a judgment. The request died in the transport, before
|
|
928
928
|
// `start_run` ran a single scope check, so that door holds no opinion about this case at all.
|
|
@@ -945,7 +945,7 @@ export function doorAnswerClass(answer) {
|
|
|
945
945
|
}
|
|
946
946
|
|
|
947
947
|
/**
|
|
948
|
-
* The
|
|
948
|
+
* The door-asymmetry verdict, over the doors that actually answered.
|
|
949
949
|
* Returns { agreed, compared, unavailable, reducedCoverage } — `agreed` is TRUE when fewer than two
|
|
950
950
|
* doors answered, because one opinion is not a disagreement and must never be reported as one.
|
|
951
951
|
*/
|
|
@@ -2141,7 +2141,7 @@ async function cmdRun(id) {
|
|
|
2141
2141
|
|
|
2142
2142
|
const jobs = s.job ? [{ id: s.id, job: s.job, kase: {} }] : (s.cases ?? []).map((c) => ({ id: c.id, job: c.job, what: c.what, oneMatterAcrossDoors: c.oneMatterAcrossDoors === true, expectTerminal: (c.expect ?? {}).terminal ?? null, kase: c }));
|
|
2143
2143
|
|
|
2144
|
-
// door: "all" is the point of R0 — a rule enforced in one door and not another is exactly the
|
|
2144
|
+
// door: "all" is the point of R0 — a rule enforced in one door and not another is exactly the door
|
|
2145
2145
|
// asymmetry. Every case goes through EVERY drivable door and the answers are compared. runner.mjs's
|
|
2146
2146
|
// claimAndPrep is the wall they all land on, so they must agree; if they ever do not, the door that
|
|
2147
2147
|
// admits is the bug, not the door that refuses. A case that declares `doors` goes through those alone
|
|
@@ -2662,7 +2662,7 @@ function printPreviousRoundNotice(s) {
|
|
|
2662
2662
|
}
|
|
2663
2663
|
|
|
2664
2664
|
// `run` records what each door answered, because `report` runs later in a different process and cannot
|
|
2665
|
-
// otherwise know. A rule enforced in one door and waved through by another is exactly the
|
|
2665
|
+
// otherwise know. A rule enforced in one door and waved through by another is exactly the door asymmetry,
|
|
2666
2666
|
// and it is invisible unless the answers are compared.
|
|
2667
2667
|
//
|
|
2668
2668
|
// — the receipt's PATH and FORMAT moved to driver/e2e-rounds.mjs (`receiptPath`, `readReceipt`),
|
|
@@ -2686,7 +2686,7 @@ function printPreviousRoundNotice(s) {
|
|
|
2686
2686
|
export function doorRefusal(receipt, ref, expect = null) {
|
|
2687
2687
|
const rc = (receipt?.cases ?? []).find((c) => c.ref === ref) ?? null;
|
|
2688
2688
|
const recorded = rc?.answers ?? [];
|
|
2689
|
-
// — ONLY THE DOORS THAT ANSWERED. `doorAsymmetry` has excluded unavailable doors from the
|
|
2689
|
+
// — ONLY THE DOORS THAT ANSWERED. `doorAsymmetry` has excluded unavailable doors from the door
|
|
2690
2690
|
// comparison since, but this function did not, and it is the one that prints
|
|
2691
2691
|
// "refused at the door by cli, ops-mcp". A door that was never asked was listed there as a refuser,
|
|
2692
2692
|
// so a case where one door refused correctly and the other did not exist read EXACTLY like a case
|
|
@@ -3175,7 +3175,7 @@ async function cmdReport(id, { round: requestedToken = null } = {}) {
|
|
|
3175
3175
|
// notice is OWED depends on the terminal, and the terminal is what could not be established.
|
|
3176
3176
|
const owesNotice = q.terminal === "clarify" || q.terminal === "duplicate" || q.terminal === "failed" || q.undetermined;
|
|
3177
3177
|
if (owesNotice) {
|
|
3178
|
-
// The contract
|
|
3178
|
+
// The contract: a refusal must PARK *and NOTIFY*, never fail silently.
|
|
3179
3179
|
console.log(` requester notified: ${
|
|
3180
3180
|
q.packets.length ? q.packets.join(", ")
|
|
3181
3181
|
: q.packetsUnreadable ? `CANNOT TELL — ${q.packetsUnreadable}`
|
|
@@ -3342,7 +3342,7 @@ async function cmdReport(id, { round: requestedToken = null } = {}) {
|
|
|
3342
3342
|
// CASE-level asserts are evaluated too. They were declared and never reached: this loop read only
|
|
3343
3343
|
// `s.expect.assert`, and `kase` was consulted solely for `terminal` and `reasonMatches`. R0 has no
|
|
3344
3344
|
// scenario-level asserts at all, so R0 ran ZERO assertions — and R0e's `profileKey == "generic"`,
|
|
3345
|
-
// the only substantive check in the whole scenario and the entire point of the
|
|
3345
|
+
// the only substantive check in the whole scenario and the entire point of the roster-blindness
|
|
3346
3346
|
// case, was silently dead. Same class of defect as the artifacts key above: declared, never asked.
|
|
3347
3347
|
for (const a of [...(s.expect?.assert ?? []), ...(kase?.expect?.assert ?? [])]) {
|
|
3348
3348
|
const r = evalAssertion(a, runDir);
|
|
@@ -3405,7 +3405,7 @@ async function cmdReport(id, { round: requestedToken = null } = {}) {
|
|
|
3405
3405
|
}
|
|
3406
3406
|
// ── did every door give the same answer? ────────────────────────────────────────────────────────────
|
|
3407
3407
|
// Only R0 drives more than one door. A rule enforced at one door and waved through by another is the
|
|
3408
|
-
//
|
|
3408
|
+
// door asymmetry, and it is invisible unless the answers are compared — so `run` records them and this
|
|
3409
3409
|
// reads the receipt. No receipt is reported as "cannot tell", never as agreement.
|
|
3410
3410
|
if (s.door === "all") {
|
|
3411
3411
|
// — read THIS ROUND's answers, and distinguish the three ways they can be missing: no receipt
|
package/scripts/env-audit.mjs
CHANGED
|
@@ -310,6 +310,12 @@ export function isProductFile(rel) {
|
|
|
310
310
|
if (/\.test\.mjs$/.test(rel)) return false;
|
|
311
311
|
if (/^e2e\//.test(rel)) return false;
|
|
312
312
|
if (/^scripts\/(e2e|test-run|ci-)/.test(rel)) return false;
|
|
313
|
+
// THE BROWSER CHECKS. CI and a developer run them against a fixture portal; no install runs one. A switch
|
|
314
|
+
// one of them reads, such as the delay that reproduces a slow runner, belongs to the check, and a row
|
|
315
|
+
// for it in `.env.example` would hand an operator a setting that changes nothing they run. Measured
|
|
316
|
+
// when this was added: HOME, PATH, TMPDIR and that delay were all the checks read, and the first three
|
|
317
|
+
// stay product, because shipping code reads them too.
|
|
318
|
+
if (/^scripts\/[a-z0-9-]+-render-check\.mjs$/.test(rel)) return false;
|
|
313
319
|
return true;
|
|
314
320
|
}
|
|
315
321
|
|