clearotron 0.3.1-beta.2 → 0.3.1-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/grant.mjs +21 -22
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +33 -0
- package/driver/citation-census.json +3 -3
- package/driver/common-law-receipts.mjs +11 -1
- package/driver/connotation-search.mjs +56 -5
- package/driver/contract-e3-backlog.mjs +31 -31
- package/driver/contract-vocabulary.mjs +59 -58
- package/driver/engine/mcp/gather-config.mjs +1 -1
- package/driver/gateway.mjs +29 -2
- package/driver/matter-frame-record.mjs +54 -1
- package/driver/package.json +1 -1
- package/driver/portal-config-view.mjs +26 -1
- package/driver/portal-report.mjs +16 -2
- package/driver/portal-service.mjs +327 -8
- package/driver/publish/render.mjs +0 -45
- package/driver/publish/templates/report.css +1 -30
- package/driver/skills/knockout-assess/SKILL.md +35 -6
- package/driver/skills/knockout-frame/SKILL.md +2 -1
- package/driver/skills/prelim-search/risk-framework-triage.md +3 -3
- package/driver/stages.mjs +19 -5
- package/driver/suite-census.json +112 -16
- package/driver/verify-knockout.mjs +58 -0
- package/driver/verify.mjs +83 -7
- package/mcp-server/CHANGELOG.md +11 -0
- package/mcp-server/lib/audit.mjs +96 -3
- package/mcp-server/lib/http-handler.mjs +76 -3
- package/mcp-server/lib/runs.mjs +42 -2
- package/mcp-server/package.json +1 -1
- package/mcp-server/packs/client/CONNECT.md +10 -6
- package/mcp-server/server.mjs +37 -3
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-D8ITW-aD.js → index-ChIQsMYp.js} +1375 -817
- package/portal-ui/dist/assets/{index-D5WAoLZI.css → index-DBIs21e4.css} +37 -0
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/ask-ai-render-check.mjs +359 -0
- package/scripts/citation-line-check.mjs +53 -2
- package/scripts/deprecate-below.mjs +146 -0
- package/scripts/test-run.mjs +24 -1
- package/shared/connect-clients.mjs +52 -16
- package/shared/driver-dir.mjs +1 -1
- package/shared/grants-edit.mjs +99 -2
|
@@ -288,6 +288,21 @@ html.anon-on [data-anon='mark']:not(.anon-clear) {
|
|
|
288
288
|
border-top: 1px solid var(--border-hairline);
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
+
/* ── the page header ──────────────────────────────────────────────────────────────────────────────
|
|
292
|
+
ONE LINE, ONE SIZE, ONE PLACE. Ten screens carried `fontSize: 27` inline above an uppercase eyebrow
|
|
293
|
+
saying the same word; two had already drifted to different margins. The eyebrow class below stays,
|
|
294
|
+
because it still labels SECTIONS inside a page — a group of fields, a stop control, a set of reads —
|
|
295
|
+
which is what it was for before it became a second heading. */
|
|
296
|
+
.page-header {
|
|
297
|
+
display: flex; align-items: flex-end; gap: 20px; flex-wrap: wrap; margin: 0 0 16px;
|
|
298
|
+
}
|
|
299
|
+
.page-header-text { flex: 1 1 260px; min-width: 0; }
|
|
300
|
+
.page-title {
|
|
301
|
+
font-size: 27px; margin: 0; color: var(--text-strong); letter-spacing: -0.02em;
|
|
302
|
+
}
|
|
303
|
+
.page-lede { margin: 4px 0 0; color: var(--text-muted); font-size: 14.5px; }
|
|
304
|
+
.page-header-actions { flex: none; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
305
|
+
|
|
291
306
|
.eyebrow {
|
|
292
307
|
font-size: 9.5px;
|
|
293
308
|
font-weight: 700;
|
|
@@ -2067,6 +2082,28 @@ input.filter {
|
|
|
2067
2082
|
Quiet on purpose. These are runs the reader has already dealt with, and the whole point of the
|
|
2068
2083
|
feature is that they stop competing with live work for attention — a panel styled like the queue
|
|
2069
2084
|
would put them back in the fight it just took them out of. */
|
|
2085
|
+
/* WHAT STOPPED RECENTLY — louder than acknowledged, quieter than live work.
|
|
2086
|
+
The count carries the page's only standing signal that something failed, so it is readable rather
|
|
2087
|
+
than faint; the fold below it is the part that keeps the dashboard about what is happening now. */
|
|
2088
|
+
.home2-stopped { margin-top: 18px; }
|
|
2089
|
+
.home2-stopped-toggle {
|
|
2090
|
+
padding: 0; background: none; border: 0; cursor: pointer; font: inherit; font-size: 13.5px;
|
|
2091
|
+
font-weight: 600; color: var(--text-strong); text-decoration: underline;
|
|
2092
|
+
}
|
|
2093
|
+
.home2-stopped-toggle:hover { color: var(--text-accent); }
|
|
2094
|
+
.home2-stopped .home2-cards { margin-top: 11px; }
|
|
2095
|
+
|
|
2096
|
+
/* THE UNKNOWN DOOR — a sentence the reader must not skip, and the alternative under its own heading.
|
|
2097
|
+
Both are quiet relative to the steps themselves: the primary set is still what to try first. */
|
|
2098
|
+
.steps-unknown {
|
|
2099
|
+
margin: 0 0 12px; font-size: 12.5px; line-height: 1.5; color: var(--text-muted);
|
|
2100
|
+
border-left: 2px solid var(--border-hairline); padding-left: 10px;
|
|
2101
|
+
}
|
|
2102
|
+
.steps-alt { margin-top: 20px; padding-top: 14px; border-top: 1px solid var(--border-hairline); }
|
|
2103
|
+
.steps-alt-head {
|
|
2104
|
+
margin: 0 0 10px; font-size: 12.5px; font-weight: 600; letter-spacing: .02em; color: var(--text-muted);
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2070
2107
|
.home2-acked { margin-top: 13px; }
|
|
2071
2108
|
.home2-acked-toggle {
|
|
2072
2109
|
padding: 0; background: none; border: 0; cursor: pointer; font: inherit; font-size: 12.5px;
|
|
@@ -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-ChIQsMYp.js"></script>
|
|
53
|
+
<link rel="stylesheet" crossorigin href="/portal/assets/index-DBIs21e4.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.1-beta.
|
|
5
|
+
"version": "0.3.1-beta.4",
|
|
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": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-oauth-mcp-bridge",
|
|
3
|
-
"version": "0.3.1-beta.
|
|
3
|
+
"version": "0.3.1-beta.4",
|
|
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).",
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
4
|
+
// Ask AI on a report, pressed in a real browser, in the three states a reader actually meets.
|
|
5
|
+
//
|
|
6
|
+
// node scripts/ask-ai-render-check.mjs [--keep] [--shots <dir>] [--width 1280]
|
|
7
|
+
//
|
|
8
|
+
// ── why a browser, and why a separate one ────────────────────────────────────────────────────────────
|
|
9
|
+
//
|
|
10
|
+
// The owner watched a lawyer press this button and give up on what opened. Nothing in portal-ui can ask
|
|
11
|
+
// what a press leaves a reader looking at: `node --test` with type stripping carries no DOM, so the
|
|
12
|
+
// source-text arms over Result.tsx can prove a string is in a file and no more. They cannot tell a menu
|
|
13
|
+
// that renders from one that renders behind a closed panel, and they cannot tell a link that carries the
|
|
14
|
+
// question from one that carries an empty `q=`.
|
|
15
|
+
//
|
|
16
|
+
// Separate from revisit-render-check.mjs, which drives the same screen: that one counts REQUESTS, and
|
|
17
|
+
// opening menus inside its measurement windows would change the number it exists to measure.
|
|
18
|
+
//
|
|
19
|
+
// ── the three states, and why the third is the one to get wrong ──────────────────────────────────────
|
|
20
|
+
//
|
|
21
|
+
// connected a menu — Ask Claude, Ask ChatGPT, a divider, Connect another AI
|
|
22
|
+
// never connected a panel — what to do once, Set it up, and "Already connected? Ask anyway"
|
|
23
|
+
// no connector nothing is drawn. A button that can do nothing is not a button.
|
|
24
|
+
//
|
|
25
|
+
// The server answers `aiConnected` as true, false, or NULL — null meaning the installation could not be
|
|
26
|
+
// asked. A null draws the panel, the same as false, and this check drives it as its own deck rather than
|
|
27
|
+
// trusting that they look alike: they render the same by a decision about which way to be wrong, and a
|
|
28
|
+
// deck that only ever saw `false` would go green on a build that crashed on null.
|
|
29
|
+
//
|
|
30
|
+
// MUST NOT run as a user with a virtual-memory ulimit (`ulimit -v`) — Chrome dumps core under one.
|
|
31
|
+
|
|
32
|
+
import { navigateOrRefuse } from './headless-page.mjs' // Page.navigate returns an errorText, and nothing read it
|
|
33
|
+
import { reapOnExit } from "../shared/reap-on-exit.mjs"; // a detached group dies with this script
|
|
34
|
+
import { browserRun } from "../shared/browser-temp-root.mjs";
|
|
35
|
+
import { createServer } from 'node:http'
|
|
36
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
|
|
37
|
+
import { join, extname, dirname } from 'node:path'
|
|
38
|
+
import { fileURLToPath } from 'node:url'
|
|
39
|
+
import { spawn } from 'node:child_process'
|
|
40
|
+
|
|
41
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
42
|
+
const DIST = join(HERE, '..', 'portal-ui', 'dist')
|
|
43
|
+
const keep = process.argv.includes('--keep')
|
|
44
|
+
const shotsDir = process.argv.includes('--shots') ? process.argv[process.argv.indexOf('--shots') + 1] : null
|
|
45
|
+
const WIDTH = process.argv.includes('--width') ? Number(process.argv[process.argv.indexOf('--width') + 1]) : 1280
|
|
46
|
+
|
|
47
|
+
if (!existsSync(join(DIST, 'index.html'))) {
|
|
48
|
+
console.error(`no build at ${DIST} — run: npm run build:ui`)
|
|
49
|
+
process.exit(2)
|
|
50
|
+
}
|
|
51
|
+
if (shotsDir) mkdirSync(shotsDir, { recursive: true })
|
|
52
|
+
|
|
53
|
+
// ── the fixture ─────────────────────────────────────────────────────────────────────────────────────
|
|
54
|
+
//
|
|
55
|
+
// One delivered clearance. The mark and the date are what the question is built from, so they are the
|
|
56
|
+
// two facts this file cares about — and the date is deliberately one whose ISO spelling and its readable
|
|
57
|
+
// form differ in both fields, so a formatter that printed the wrong part could not pass by coincidence.
|
|
58
|
+
//
|
|
59
|
+
// `productName` is resolved through the same expression portal-service.mjs evaluates, never typed: the
|
|
60
|
+
// sibling instruments' rule, and it holds here for the same reason.
|
|
61
|
+
const { reportIdentityFor } = await import('../driver/search-policy.mjs')
|
|
62
|
+
|
|
63
|
+
const KEY = 'acme'
|
|
64
|
+
const NAME = 'Acme'
|
|
65
|
+
const RUN_ID = 'tmpa-askai-1'
|
|
66
|
+
const MARK = 'VENZY'
|
|
67
|
+
const ISO_DATE = '2026-09-14'
|
|
68
|
+
const READABLE_DATE = '14 September'
|
|
69
|
+
const EXPECTED_QUESTION = `Brief me on the ${MARK} clearance from ${READABLE_DATE}.`
|
|
70
|
+
|
|
71
|
+
const PRODUCT = 'global-preliminary-search'
|
|
72
|
+
const RUN = {
|
|
73
|
+
account: KEY, kind: 'clearance', state: 'delivered', band: 'Manageable', tone: 'low',
|
|
74
|
+
bands: [{ label: 'Severe', tone: 'severe' }, { label: 'Medium', tone: 'medium' }, { label: 'Manageable', tone: 'low' }],
|
|
75
|
+
marks: [], reportSchema: 2, held: false, report: `/portal/report/${RUN_ID}/`,
|
|
76
|
+
step: null, stepN: null, stepTotal: null, reason: null, failedStage: null,
|
|
77
|
+
pausedKind: null, resetsAt: null, startedAt: null, queuePos: null,
|
|
78
|
+
projectKey: null, projectName: null, markName: MARK, title: MARK, date: ISO_DATE,
|
|
79
|
+
product: PRODUCT, stageLabel: 'Depth 4', productName: reportIdentityFor(PRODUCT).identity, runId: RUN_ID,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── the decks ───────────────────────────────────────────────────────────────────────────────────────
|
|
83
|
+
//
|
|
84
|
+
// Only the mcp-access body varies. Everything else about the screen is held still, so a difference in
|
|
85
|
+
// what the button does is a difference in what the deployment answered and nothing else.
|
|
86
|
+
const WIRED = { url: 'https://clients-mcp.example/mcp', keyUrl: null, email: 'reader@example-firm.com' }
|
|
87
|
+
const DECKS = {
|
|
88
|
+
connected: { ...WIRED, enabled: true, stdio: null, aiConnected: true },
|
|
89
|
+
neverBefore: { ...WIRED, enabled: true, stdio: null, aiConnected: false },
|
|
90
|
+
cannotTell: { ...WIRED, enabled: true, stdio: null, aiConnected: null },
|
|
91
|
+
noConnector: { url: null, keyUrl: null, email: 'reader@example-firm.com', enabled: false, stdio: null, aiConnected: null },
|
|
92
|
+
// A laptop install: no published door, but a local route. This reader must be offered the control —
|
|
93
|
+
// it is the case the old panel stranded, because every address it could show was null.
|
|
94
|
+
localOnly: { url: null, keyUrl: null, email: 'reader@example-firm.com', enabled: false, aiConnected: true,
|
|
95
|
+
stdio: { command: 'node server.mjs', note: 'from this install', verify: 'it prints ready' } },
|
|
96
|
+
}
|
|
97
|
+
let deck = 'connected'
|
|
98
|
+
|
|
99
|
+
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.json': 'application/json' }
|
|
100
|
+
const json = (res, body) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)) }
|
|
101
|
+
|
|
102
|
+
const server = createServer((req, res) => {
|
|
103
|
+
const p = new URL(req.url, 'http://localhost').pathname
|
|
104
|
+
if (p === '/portal/api/me') return json(res, { email: 'reader@example-firm.com', permissions: { run: true, manage: false }, access: [{ kind: 'account', account: KEY }], accounts: [KEY], accountNames: { [KEY]: NAME }, allowance: null })
|
|
105
|
+
if (p === '/portal/admin/roster') return json(res, { customers: [{ key: KEY, name: NAME }] })
|
|
106
|
+
if (p === '/portal/admin/families') return json(res, { of: {}, names: {} })
|
|
107
|
+
if (p === '/portal/api/runs') return json(res, { runs: [RUN] })
|
|
108
|
+
if (p === '/portal/api/searches') return json(res, { account: KEY, products: [], recipes: [], read: { available: false, maxBrief: 0, note: null } })
|
|
109
|
+
if (p === '/portal/api/mcp-access') return json(res, { ...DECKS[deck], offers: [] })
|
|
110
|
+
if (/^\/portal\/api\/run\/[^/]+\/summary$/.test(p)) return json(res, { summary: [] })
|
|
111
|
+
// A real document, because a 404 in the frame changes what Result draws around it. Its CONTENT is not
|
|
112
|
+
// this file's subject — render-check.mjs owns the report's own layout.
|
|
113
|
+
if (p.startsWith('/portal/report/')) {
|
|
114
|
+
res.writeHead(200, { 'content-type': 'text/html' })
|
|
115
|
+
return res.end(`<!doctype html><meta charset="utf-8"><title>report</title><body><h1>${MARK}</h1><p>fixture report.</p></body>`)
|
|
116
|
+
}
|
|
117
|
+
const base = p.split('?')[0]
|
|
118
|
+
const file = base === '/' || (base.startsWith('/portal') && !base.includes('.')) ? '/index.html' : base.replace(/^\/portal/, '')
|
|
119
|
+
const full = join(DIST, file)
|
|
120
|
+
if (!existsSync(full)) { res.writeHead(404); res.end('no'); return }
|
|
121
|
+
res.writeHead(200, { 'content-type': MIME[extname(full)] ?? 'application/octet-stream' })
|
|
122
|
+
res.end(readFileSync(full))
|
|
123
|
+
})
|
|
124
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r))
|
|
125
|
+
const origin = `http://127.0.0.1:${server.address().port}`
|
|
126
|
+
|
|
127
|
+
// ── chrome ──────────────────────────────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
const { profile, env: chromeEnv, keep: keepRoot } = browserRun("ask-ai-check-")
|
|
130
|
+
if (keep) keepRoot()
|
|
131
|
+
const chrome = spawn('google-chrome', [
|
|
132
|
+
'--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
|
|
133
|
+
'--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1',
|
|
134
|
+
`--user-data-dir=${profile}`, `--window-size=${WIDTH},900`, '--remote-debugging-port=0', 'about:blank',
|
|
135
|
+
], { stdio: ['ignore', 'ignore', 'pipe'], detached: true, env: chromeEnv })
|
|
136
|
+
reapOnExit(chrome)
|
|
137
|
+
|
|
138
|
+
const wsUrl = await new Promise((resolve, reject) => {
|
|
139
|
+
let devtools = ''
|
|
140
|
+
const t = setTimeout(
|
|
141
|
+
() => reject(new Error(`chrome did not report a devtools endpoint within 60s. Its stderr was:\n${devtools || '(nothing)'}`)),
|
|
142
|
+
60000,
|
|
143
|
+
)
|
|
144
|
+
chrome.stderr.on('data', (c) => {
|
|
145
|
+
devtools += c
|
|
146
|
+
const m = devtools.match(/ws:\/\/[^\s]+/)
|
|
147
|
+
if (m) { clearTimeout(t); resolve(m[0]) }
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const ws = new WebSocket(wsUrl)
|
|
152
|
+
let id = 0
|
|
153
|
+
const pending = new Map()
|
|
154
|
+
ws.addEventListener('message', (e) => {
|
|
155
|
+
const msg = JSON.parse(e.data)
|
|
156
|
+
if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id) }
|
|
157
|
+
})
|
|
158
|
+
await new Promise((r) => ws.addEventListener('open', r))
|
|
159
|
+
const send = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })) })
|
|
160
|
+
const { result: targets } = await send('Target.getTargets')
|
|
161
|
+
const page = targets.targetInfos.find((t) => t.type === 'page')
|
|
162
|
+
const { result: sess } = await send('Target.attachToTarget', { targetId: page.targetId, flatten: true })
|
|
163
|
+
const sessionId = sess.sessionId
|
|
164
|
+
const cmd = (method, params) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, sessionId, method, params })) })
|
|
165
|
+
const value = async (expr) => (await cmd('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })).result?.result?.value ?? null
|
|
166
|
+
|
|
167
|
+
// ── what is measured, in the page ───────────────────────────────────────────────────────────────────
|
|
168
|
+
//
|
|
169
|
+
// READ OFF THE RENDERED DOM, never off a class name. A menu item that exists behind `display:none` and
|
|
170
|
+
// one a reader can press look identical to a selector; `offsetParent` and a non-zero rect are what tell
|
|
171
|
+
// them apart, and that difference is the whole subject of this file.
|
|
172
|
+
//
|
|
173
|
+
// WINDOW.OPEN IS REPLACED, NOT FOLLOWED. Pressing Ask Claude opens claude.ai, which this browser has no
|
|
174
|
+
// route to (`--host-resolver-rules`) — and following it would leave the drive on a page that is not the
|
|
175
|
+
// one being measured. The stub records the href instead, so the assertion is about the link the button
|
|
176
|
+
// actually carries rather than about a string in the source.
|
|
177
|
+
const PROBE = `(async () => {
|
|
178
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
179
|
+
window.__opened = [];
|
|
180
|
+
if (!window.__openStubbed) { window.open = (href) => { window.__opened.push(href); return null; }; window.__openStubbed = true; }
|
|
181
|
+
|
|
182
|
+
const visible = (el) => !!el && !!el.offsetParent && el.getBoundingClientRect().width > 0;
|
|
183
|
+
const anchor = document.querySelector('[data-ask-ai]');
|
|
184
|
+
const button = [...document.querySelectorAll('button')].find(b => /Ask AI/.test(b.textContent || ''));
|
|
185
|
+
if (!button || !visible(button)) return { drawn: false, anchorState: anchor ? anchor.getAttribute('data-ask-ai') : null };
|
|
186
|
+
|
|
187
|
+
button.click();
|
|
188
|
+
await sleep(220);
|
|
189
|
+
|
|
190
|
+
const float = document.querySelector('[data-ask-ai] .float');
|
|
191
|
+
if (!float) return { drawn: true, opened: false, anchorState: anchor ? anchor.getAttribute('data-ask-ai') : null };
|
|
192
|
+
|
|
193
|
+
const role = float.getAttribute('role');
|
|
194
|
+
const items = [...float.querySelectorAll('button')].filter(visible).map(b => (b.textContent || '').trim());
|
|
195
|
+
const text = (float.innerText || '').trim();
|
|
196
|
+
const width = Math.round(float.getBoundingClientRect().width);
|
|
197
|
+
// A divider is a one-pixel-high element between the assistants and the quiet item. Measured, not
|
|
198
|
+
// matched by class: the mockup's separator is a rule, and a rule is a height.
|
|
199
|
+
const seps = [...float.querySelectorAll('div')].filter(d => {
|
|
200
|
+
const r = d.getBoundingClientRect();
|
|
201
|
+
return r.height <= 2 && r.height > 0 && r.width > 40;
|
|
202
|
+
}).length;
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
drawn: true, opened: true, role, items, text, width, seps,
|
|
206
|
+
anchorState: anchor ? anchor.getAttribute('data-ask-ai') : null,
|
|
207
|
+
hasAddress: /mcp|https?:\\/\\//i.test(text),
|
|
208
|
+
hasCopy: /copy/i.test(text),
|
|
209
|
+
};
|
|
210
|
+
})()`
|
|
211
|
+
|
|
212
|
+
// PRESSING CLOSES THE MENU, which is correct and is why this re-opens it first: the second press found
|
|
213
|
+
// nothing, reported an empty href, and the assertion read as "ChatGPT was opened with nothing" — a true
|
|
214
|
+
// statement about a drive that never pressed anything.
|
|
215
|
+
const PRESS = `(async () => {
|
|
216
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
217
|
+
if (!document.querySelector('[data-ask-ai] .float')) {
|
|
218
|
+
const opener = [...document.querySelectorAll('button')].find(b => /Ask AI/.test(b.textContent || ''));
|
|
219
|
+
if (!opener) return { fatal: 'the control is gone from the header' };
|
|
220
|
+
opener.click();
|
|
221
|
+
await sleep(220);
|
|
222
|
+
}
|
|
223
|
+
const float = document.querySelector('[data-ask-ai] .float');
|
|
224
|
+
if (!float) return { fatal: 'nothing open to press' };
|
|
225
|
+
const btn = [...float.querySelectorAll('button')].find(b => /__LABEL__/.test(b.textContent || ''));
|
|
226
|
+
if (!btn) return { fatal: 'no such item: __LABEL__' };
|
|
227
|
+
btn.click();
|
|
228
|
+
await sleep(200);
|
|
229
|
+
return { opened: window.__opened || [] };
|
|
230
|
+
})()`
|
|
231
|
+
|
|
232
|
+
const ASK_ANYWAY = `(async () => {
|
|
233
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
234
|
+
const float = document.querySelector('[data-ask-ai] .float');
|
|
235
|
+
if (!float) return { fatal: 'no panel open' };
|
|
236
|
+
const link = [...float.querySelectorAll('button')].find(b => /Ask anyway/.test(b.textContent || ''));
|
|
237
|
+
if (!link) return { fatal: 'the panel carries no way past it' };
|
|
238
|
+
link.click();
|
|
239
|
+
await sleep(250);
|
|
240
|
+
const after = document.querySelector('[data-ask-ai] .float');
|
|
241
|
+
return {
|
|
242
|
+
role: after ? after.getAttribute('role') : null,
|
|
243
|
+
items: after ? [...after.querySelectorAll('button')].filter(b => !!b.offsetParent).map(b => (b.textContent || '').trim()) : [],
|
|
244
|
+
};
|
|
245
|
+
})()`
|
|
246
|
+
|
|
247
|
+
const shoot = async (name) => {
|
|
248
|
+
if (!shotsDir) return
|
|
249
|
+
const shot = await cmd('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true })
|
|
250
|
+
const data = shot.result?.result?.data ?? shot.result?.data
|
|
251
|
+
if (data) writeFileSync(join(shotsDir, `${name}.png`), Buffer.from(data, 'base64'))
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const visit = async () => {
|
|
255
|
+
await navigateOrRefuse(cmd, `${origin}/portal/result/${RUN_ID}`, { what: 'ask-ai-render-check' })
|
|
256
|
+
await new Promise((r) => setTimeout(r, 1400))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── the drive ───────────────────────────────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
deck = 'connected'; await visit(); const connected = await value(PROBE); await shoot('ask-ai-menu')
|
|
262
|
+
const pressClaude = await value(PRESS.replace('__LABEL__', 'Ask Claude'))
|
|
263
|
+
const pressChatGpt = await value(PRESS.replace('__LABEL__', 'Ask ChatGPT'))
|
|
264
|
+
|
|
265
|
+
deck = 'neverBefore'; await visit(); const never = await value(PROBE); await shoot('ask-ai-connect')
|
|
266
|
+
const anyway = await value(ASK_ANYWAY)
|
|
267
|
+
|
|
268
|
+
deck = 'cannotTell'; await visit(); const unknown = await value(PROBE)
|
|
269
|
+
deck = 'noConnector'; await visit(); const none = await value(PROBE)
|
|
270
|
+
deck = 'localOnly'; await visit(); const local = await value(PROBE)
|
|
271
|
+
|
|
272
|
+
try { process.kill(-chrome.pid, 'SIGKILL') }
|
|
273
|
+
catch { try { chrome.kill('SIGKILL') } catch { /* already gone */ } }
|
|
274
|
+
server.close()
|
|
275
|
+
await new Promise((r) => setTimeout(r, 400))
|
|
276
|
+
if (!keep) { try { rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) } catch { /* a stray profile dir is not a result */ } }
|
|
277
|
+
|
|
278
|
+
// ── verdict ─────────────────────────────────────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
const fail = []
|
|
281
|
+
const ok = (cond, msg) => { if (!cond) fail.push(msg) }
|
|
282
|
+
const show = (o) => JSON.stringify(o)
|
|
283
|
+
|
|
284
|
+
// A FLOOR FIRST. Every assertion below reads a field off one of these objects, and a deck that failed to
|
|
285
|
+
// render returns null — which would make `!x.hasAddress` true and half of this file pass on a blank page.
|
|
286
|
+
for (const [name, r] of [['connected', connected], ['never connected', never], ['could not tell', unknown], ['no connector', none], ['local only', local]]) {
|
|
287
|
+
ok(r && typeof r === 'object', `the ${name} deck measured nothing at all — the screen did not render`)
|
|
288
|
+
}
|
|
289
|
+
if (fail.length) { for (const f of fail) console.error(` ✗ ${f}`); console.error('ask-ai-render-check: the drive did not reach the screen'); process.exit(1) }
|
|
290
|
+
|
|
291
|
+
// ── connected: the menu ──
|
|
292
|
+
ok(connected.drawn && connected.opened, `connected: the button did not open anything — ${show(connected)}`)
|
|
293
|
+
ok(connected.role === 'menu', `connected: the float is role="${connected.role}", not a menu`)
|
|
294
|
+
ok(connected.anchorState === 'menu', `connected: the control reports state "${connected.anchorState}"`)
|
|
295
|
+
ok(connected.items.length === 3, `connected: ${connected.items.length} item(s), expected three — ${show(connected.items)}`)
|
|
296
|
+
ok(connected.items[0] === 'Ask Claude' && connected.items[1] === 'Ask ChatGPT',
|
|
297
|
+
`connected: the two assistants are not the first two items — ${show(connected.items)}`)
|
|
298
|
+
ok(connected.items[2] === 'Connect another AI', `connected: the quiet item reads ${show(connected.items[2])}`)
|
|
299
|
+
ok(connected.seps >= 1, 'connected: no divider between the assistants and the quiet item')
|
|
300
|
+
ok(connected.width >= 200 && connected.width <= 280, `connected: the menu is ${connected.width}px wide; the design is 236`)
|
|
301
|
+
|
|
302
|
+
// ── connected: what a press actually opens ──
|
|
303
|
+
//
|
|
304
|
+
// THE QUESTION HAS TO BE IN THE LINK, not merely in the source. This is the assertion the whole change
|
|
305
|
+
// is for: a press that opened claude.ai with an empty `q=` would look identical on screen.
|
|
306
|
+
const claudeHref = (pressClaude?.opened ?? []).at(-1) ?? ''
|
|
307
|
+
const chatHref = (pressChatGpt?.opened ?? []).at(-1) ?? ''
|
|
308
|
+
ok(claudeHref.startsWith('https://claude.ai/new?q='), `Ask Claude opened ${show(claudeHref)}`)
|
|
309
|
+
ok(chatHref.startsWith('https://chatgpt.com/?q='), `Ask ChatGPT opened ${show(chatHref)}`)
|
|
310
|
+
for (const [who, href] of [['Claude', claudeHref], ['ChatGPT', chatHref]]) {
|
|
311
|
+
const q = (() => { try { return new URL(href).searchParams.get('q') ?? '' } catch { return '' } })()
|
|
312
|
+
ok(q === EXPECTED_QUESTION, `${who} was opened with ${show(q)}, not ${show(EXPECTED_QUESTION)}`)
|
|
313
|
+
ok(!/noref|tmpa-|run /i.test(q), `${who}'s question carries a run identifier: ${show(q)}`)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── never connected: the panel ──
|
|
317
|
+
ok(never.drawn && never.opened, `never connected: the button did not open anything — ${show(never)}`)
|
|
318
|
+
ok(never.role === 'dialog', `never connected: the float is role="${never.role}", not a dialog`)
|
|
319
|
+
ok(never.anchorState === 'connect', `never connected: the control reports state "${never.anchorState}"`)
|
|
320
|
+
ok(/Connect your AI first/.test(never.text), `never connected: the panel does not say what it is — ${show(never.text)}`)
|
|
321
|
+
ok(/Connect Claude or ChatGPT once/.test(never.text), 'never connected: the explaining line is missing')
|
|
322
|
+
ok(never.items.some((t) => /^Set it up/.test(t)), `never connected: no Set it up — ${show(never.items)}`)
|
|
323
|
+
ok(never.items.some((t) => /Already connected\? Ask anyway/.test(t)), `never connected: no way past it — ${show(never.items)}`)
|
|
324
|
+
ok(!never.items.some((t) => /^Ask Claude$|^Ask ChatGPT$/.test(t)),
|
|
325
|
+
`never connected: the assistants are on the panel, so the setup step is decoration — ${show(never.items)}`)
|
|
326
|
+
ok(never.width >= 240 && never.width <= 320, `never connected: the panel is ${never.width}px wide; the design is 276`)
|
|
327
|
+
|
|
328
|
+
// ── the way past a measurement that is behind ──
|
|
329
|
+
ok(anyway?.role === 'menu', `"Ask anyway" left the reader on role="${anyway?.role}" — ${show(anyway)}`)
|
|
330
|
+
ok((anyway?.items ?? []).includes('Ask Claude'), `"Ask anyway" did not reach the assistants — ${show(anyway?.items)}`)
|
|
331
|
+
|
|
332
|
+
// ── could not tell: the third state renders, and renders as the panel ──
|
|
333
|
+
//
|
|
334
|
+
// DRIVEN AS ITS OWN DECK. It reaching the same screen as `false` is a decision about which way to be
|
|
335
|
+
// wrong, not an identity — and a build that threw on a null would fail here and nowhere else.
|
|
336
|
+
ok(unknown.drawn && unknown.opened, `could not tell: nothing opened — ${show(unknown)}`)
|
|
337
|
+
ok(unknown.role === 'dialog', `could not tell: a null was rendered as role="${unknown.role}", so an unmeasured reader was handed the menu`)
|
|
338
|
+
ok(/Connect your AI first/.test(unknown.text), 'could not tell: the panel did not draw')
|
|
339
|
+
|
|
340
|
+
// ── no connector: nothing is drawn ──
|
|
341
|
+
ok(none.drawn === false, `no connector: a button was drawn that can do nothing — ${show(none)}`)
|
|
342
|
+
|
|
343
|
+
// ── a laptop install: a local route IS a connector ──
|
|
344
|
+
ok(local.drawn && local.opened, `local only: the case the old panel stranded is stranded again — ${show(local)}`)
|
|
345
|
+
ok(local.role === 'menu', `local only: role="${local.role}"`)
|
|
346
|
+
|
|
347
|
+
// ── NO ADDRESS AND NO COPY LINK, in any state that draws ──
|
|
348
|
+
for (const [name, r] of [['connected', connected], ['never connected', never], ['could not tell', unknown], ['local only', local]]) {
|
|
349
|
+
ok(!r.hasAddress, `${name}: a connector address is on the report again — ${show(r.text)}`)
|
|
350
|
+
ok(!r.hasCopy, `${name}: a Copy link is back on the report — ${show(r.text)}`)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (fail.length) {
|
|
354
|
+
for (const f of fail) console.error(` ✗ ${f}`)
|
|
355
|
+
console.error(`ask-ai-render-check: ${fail.length} thing(s) a reader would meet`)
|
|
356
|
+
process.exit(1)
|
|
357
|
+
}
|
|
358
|
+
console.log(`ask-ai-render-check: the menu, the panel and the unmeasured state all render; both links carry ${JSON.stringify(EXPECTED_QUESTION)}; no button where there is no connector`)
|
|
359
|
+
if (shotsDir) console.log(`ask-ai-render-check: shots in ${shotsDir}`)
|
|
@@ -713,12 +713,63 @@ const OWN_NUMBER = /^\s*(?:at\s+\d|:\d)/;
|
|
|
713
713
|
// The cost of naming a file is that a REAL bare citation added there also escapes. That is the same trade
|
|
714
714
|
// the reference-strip signatures make for the same reason, and it is preferable to a pattern, which would
|
|
715
715
|
// quietly widen to files nobody considered.
|
|
716
|
-
|
|
716
|
+
// ── EXEMPT FROM THE RATCHET, AND ONLY WHERE SOMETHING STRONGER CHECKS THE SAME CITATIONS ───────────
|
|
717
|
+
//
|
|
718
|
+
// The ratchet refuses a NEW bare citation, which is right everywhere it is the only check. It is not the
|
|
719
|
+
// only check on `contract-e3-backlog.mjs`: `contract-audit.test.mjs` reads each backlog row's own
|
|
720
|
+
// evidence, finds where that dictation now starts, and refuses a row whose number points away from it.
|
|
721
|
+
// That is a stronger guarantee than "a symbol sits beside the number" — it verifies the number itself.
|
|
722
|
+
//
|
|
723
|
+
// THE TWO ARE IN DIRECT CONFLICT ON THAT FILE, and the conflict is not hypothetical: an edit to
|
|
724
|
+
// `stages.mjs` moves those rows, the evidence guard then DEMANDS they be repointed, and every repointed
|
|
725
|
+
// row reads to the ratchet as a newly added bare citation. Under both rules at once the only permitted
|
|
726
|
+
// change to that file is no change at all. Measured on the change that found this: 31 rows repointed at
|
|
727
|
+
// the evidence guard's own instruction, 31 refused by the ratchet.
|
|
728
|
+
//
|
|
729
|
+
// So the file is exempt from the weaker check and stays under the stronger one. Anything NOT covered by
|
|
730
|
+
// an evidence guard does not belong on this list — the exemption is earned by being checked better, not
|
|
731
|
+
// by being inconvenient.
|
|
732
|
+
//
|
|
733
|
+
// `contract-vocabulary.mjs` is the same conflict one file over, and it arrives by the same route. Each
|
|
734
|
+
// INNER_CODES row carries a `mints` array naming the line that WRITES its code, and
|
|
735
|
+
// `contract-audit.test.mjs` re-reads every one of them on every run: it checks the cited line still
|
|
736
|
+
// mints that code, and when it does not it prints where the code is NOW and refuses. That arm's own
|
|
737
|
+
// message says "Do not delete the citation: it is what makes the ruling checkable rather than merely
|
|
738
|
+
// written down" — so the ratchet's remedy, dropping the number, is the one repair that file forbids.
|
|
739
|
+
//
|
|
740
|
+
// Measured on the change that added this: a 41-line insertion into `connotation-search.mjs` moved 12
|
|
741
|
+
// mint sites, the audit demanded all 12 be repointed, and the ratchet then read all 12 as newly added
|
|
742
|
+
// bare citations. Under both rules at once the only permitted change is no change at all.
|
|
743
|
+
//
|
|
744
|
+
// The alternative considered and rejected: teach the audit's `m.split(":")` to accept `file:line symbol`
|
|
745
|
+
// and tag the 12. It widens a parser every INNER_CODES row depends on, to buy a weaker guarantee than
|
|
746
|
+
// the one already in place — the audit verifies the NUMBER, which is what a symbol only approximates.
|
|
747
|
+
//
|
|
748
|
+
// SO THE EXEMPTION IS SCOPED TO THE LINES THE AUDIT ACTUALLY COVERS, not to the file. Measured on this
|
|
749
|
+
// tree: `contract-vocabulary.mjs` carries 117 numbered citations, 21 of them inside `mints` arrays and 96
|
|
750
|
+
// outside. A file-wide entry would have taken the ratchet off all 117 to protect 21 — and the 96 are
|
|
751
|
+
// precisely the prose and `site:` citations nothing else checks. Every `mints` array is written on one
|
|
752
|
+
// line, so a `when` pattern matching that line is exact rather than approximate.
|
|
753
|
+
//
|
|
754
|
+
// An entry is either a bare path (the whole file, for the two cases where the whole file earns it) or
|
|
755
|
+
// `{ file, when }`, where `when` must match the LINE the citation sits on. A `when` that stops matching
|
|
756
|
+
// re-arms the ratchet over those lines, which is the failure direction worth having.
|
|
757
|
+
export const RATCHET_EXEMPT = [
|
|
758
|
+
"driver/test/a-new-citation-carries-something-that-can-be-checked.test.mjs",
|
|
759
|
+
"driver/contract-e3-backlog.mjs",
|
|
760
|
+
// The `mints` lines only: `contract-audit.test.mjs` verifies each of those numbers against the line
|
|
761
|
+
// that writes the code. Everything else in this file stays under the ratchet.
|
|
762
|
+
{ file: "driver/contract-vocabulary.mjs", when: /mints:\s*\[/ },
|
|
763
|
+
];
|
|
764
|
+
|
|
765
|
+
/** Whether a citation on `text` in `file` is exempt from the ratchet. PURE. */
|
|
766
|
+
export const ratchetExempt = (file, text) => RATCHET_EXEMPT.some((e) =>
|
|
767
|
+
(typeof e === "string" ? e === file : e.file === file && e.when.test(String(text ?? ""))));
|
|
717
768
|
|
|
718
769
|
export function newBareCitations(addedLines) {
|
|
719
770
|
const out = [];
|
|
720
771
|
for (const { file, line, text } of addedLines ?? []) {
|
|
721
|
-
if (
|
|
772
|
+
if (ratchetExempt(file, text)) continue;
|
|
722
773
|
for (const m of String(text ?? "").matchAll(CITE_RE)) {
|
|
723
774
|
// The same discriminator the corpus scan uses: a second `:number` closing a paren is a V8 stack
|
|
724
775
|
// frame, not a citation. Reading them as citations would refuse a fixture that captured a trace.
|