clearotron 0.3.0-beta.3 → 0.3.0-beta.5

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.
@@ -22,7 +22,8 @@
22
22
  // CLEAROTRON_CODEX_SANDBOX_BYPASS=1 swaps that `--sandbox workspace-write` for
23
23
  // `--dangerously-bypass-approvals-and-sandbox` — see buildCodexArgs below for why.
24
24
 
25
- import { mkdtempSync, writeFileSync, copyFileSync, existsSync, rmSync, readFileSync, readdirSync, statSync } from "node:fs";
25
+ import { mkdtempSync, writeFileSync, copyFileSync, existsSync, rmSync, readFileSync, readdirSync, statSync, symlinkSync, lstatSync, realpathSync } from "node:fs";
26
+ import { writeSecretFile } from "../../shared/secret-file.mjs"; // the rotated login goes back the way every credential is written
26
27
  import { tmpdir, homedir } from "node:os";
27
28
  import { join } from "node:path";
28
29
  import { runStreamingChild, absolutizeSkillRefs, WRITE_DISCIPLINE, buildEnvelope, resolveSpawnCwd } from "./common.mjs";
@@ -174,6 +175,49 @@ export function spawnEnv(base = process.env, codexHome) {
174
175
 
175
176
  const authFilePath = (env = process.env) => env.CLEAROTRON_OPENAI_AUTH_FILE || join(homedir(), ".codex", "auth.json");
176
177
 
178
+ // ── THE LOGIN IS THE MASTER'S, NOT A COPY'S ─────────────────────────────────────────────────────────
179
+ //
180
+ // codex refreshes its access token inside a turn and rotates the refresh token as it does, and the
181
+ // provider accepts each refresh token once. The stage home used to be seeded with a COPY of the master
182
+ // login and deleted when its ladder settled, so the rotated token was deleted with it. The master kept a
183
+ // token the provider had already consumed, and every later stage on every run failed its refresh with a
184
+ // 401 until a person signed in again. The engine's only report was an exit code.
185
+ //
186
+ // So the stage home LINKS to the master: a refresh codex writes in place lands where the next stage
187
+ // reads, including a ladder running beside this one. If codex replaces the link with a file of its own,
188
+ // `returnAuth` writes that file back.
189
+
190
+ /** Point `<codexHome>/auth.json` at the master login. Returns what the master held, for `returnAuth`. */
191
+ function seedAuth(masterPath, codexHome) {
192
+ const seat = join(codexHome, "auth.json");
193
+ // A LINK LEFT BY THIS LADDER'S PREVIOUS TURN is removed first: copying the master onto a link to
194
+ // itself would truncate the one file the login lives in.
195
+ rmSync(seat, { force: true });
196
+ symlinkSync(masterPath, seat);
197
+ return readFileSync(masterPath, "utf8");
198
+ }
199
+
200
+ /**
201
+ * After a turn: a login codex rewrote as its own file goes back to the master.
202
+ *
203
+ * ONLY WHILE THE MASTER STILL HOLDS WHAT THIS TURN WAS SEEDED WITH. A master that moved meanwhile was
204
+ * rotated by another ladder, and the provider accepts one refresh per token, so of two rotations from
205
+ * one seed only one can be live. Writing ours over theirs could put back the spent one.
206
+ */
207
+ export function returnAuth(masterPath, codexHome, seeded) { // @internal
208
+ const seat = join(codexHome, "auth.json");
209
+ let st;
210
+ try { st = lstatSync(seat); } catch { return false; }
211
+ if (st.isSymbolicLink() || !st.isFile()) return false; // written through the link, or not there
212
+ const now = readFileSync(seat, "utf8");
213
+ if (now === seeded) return false;
214
+ let master;
215
+ try { master = readFileSync(masterPath, "utf8"); } catch { return false; }
216
+ if (master !== seeded) return false;
217
+ writeSecretFile(masterPath, now);
218
+ return true;
219
+ }
220
+
177
221
  // Build the codex argv. Prompt rides STDIN via the `-` placeholder (no MAX_ARG_STRLEN ceiling on a 150KB
178
222
  // register prompt). Global flags precede the `resume` subcommand (flag-corpus ordering). config.toml
179
223
  // (mcp_servers + developer_instructions) rides CODEX_HOME, not argv.
@@ -530,6 +574,7 @@ export const openaiAgentEngine = {
530
574
  const ownHome = !providedHome;
531
575
  try { codexHome = providedHome ?? mkdtempSync(join(tmpdir(), "codex-home-")); }
532
576
  catch (e) { return errResult(t0, e, resumeRef); }
577
+ let authMaster = null, seeded = null;
533
578
  try {
534
579
  // Auth toggle (may throw loud on api-key-without-key — a config error, not a retryable failure).
535
580
  const { env, mode } = spawnEnv(process.env, codexHome);
@@ -537,7 +582,9 @@ export const openaiAgentEngine = {
537
582
  const af = authFilePath(process.env);
538
583
  if (!existsSync(af))
539
584
  throw new Error(`CLEAROTRON_AI_BILLING=subscription but no auth.json at ${af} — run \`codex login\` (or set CLEAROTRON_OPENAI_AUTH_FILE — NOT an aliased name, and deliberately left as it is), or use CLEAROTRON_AI_BILLING=api-key + CODEX_API_KEY.`);
540
- copyFileSync(af, join(codexHome, "auth.json"));
585
+ // The REAL path, so a master that is itself a link is written where its login actually lives.
586
+ authMaster = realpathSync(af);
587
+ seeded = seedAuth(authMaster, codexHome);
541
588
  }
542
589
  // Per-run config.toml: the gather MCP servers (translated from the claude-shaped mcpConfig, so
543
590
  // gather-config.mjs is untouched) + developer_instructions = WRITE_DISCIPLINE (codex's
@@ -574,6 +621,9 @@ export const openaiAgentEngine = {
574
621
  if (!ev.model) ev.model = readServedModel(codexHome, spawnedAtMs);
575
622
  return settleTuple({ r, ev, resumeRef });
576
623
  } finally {
624
+ // BEFORE the home can be deleted, here or by the ladder that owns it. A failed write-back leaves the
625
+ // turn's result as it was; the next turn is seeded from the master either way.
626
+ try { if (authMaster) returnAuth(authMaster, codexHome, seeded); } catch { /* the master is unchanged */ }
577
627
  try { if (ownHome && codexHome) rmSync(codexHome, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
578
628
  }
579
629
  },
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.0-beta.3",
5
+ "version": "0.3.0-beta.5",
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": {
@@ -420,6 +420,11 @@ export function makeUpstream({ callUpstream, callRecipes = null, recipesOff = nu
420
420
  const draft = {};
421
421
  for (const k of CREATABLE_FIELDS) if (body[k] !== undefined) draft[k] = body[k];
422
422
  const r = await call("POST", "/profiles", draft, principal);
423
+ // A STORE THAT CANNOT RECORD THE COMPANY is answered with its path and the git commands that fix it.
424
+ // That sentence is for whoever runs the installation. A manager who holds an organisation cannot act
425
+ // on it and must not be shown the server's paths, so they are told what to do instead.
426
+ if (r.status === 409 && String(r.json?.code ?? "").startsWith("store_") && !seesEverything(principal))
427
+ return { status: 409, json: { code: r.json.code, error: STORE_REFUSAL_FOR_MANAGERS } };
423
428
  if (r.status !== 201 || org == null || !fileCompany) return r;
424
429
  const key = r.json?.key ?? r.json?.profile?.key ?? draft.key;
425
430
  try { await fileCompany({ tenant: org, account: key }); }
@@ -431,6 +436,10 @@ export function makeUpstream({ callUpstream, callRecipes = null, recipesOff = nu
431
436
  };
432
437
  }
433
438
 
439
+ /** The store refusal as a manager who does not run the installation reads it: no path, no command. */
440
+ export const STORE_REFUSAL_FOR_MANAGERS = "No company was created: this installation cannot save new companies yet, "
441
+ + "and nothing was changed. Ask whoever runs it to fix the company store.";
442
+
434
443
  /**
435
444
  * What a browser may state when creating a company.
436
445
  *
@@ -135,6 +135,12 @@
135
135
  "skips": 0,
136
136
  "todos": 0
137
137
  },
138
+ "a-codex-login-survives-its-own-refresh.test.mjs": {
139
+ "tests": 3,
140
+ "asserts": 14,
141
+ "skips": 0,
142
+ "todos": 0
143
+ },
138
144
  "a-company-can-be-given-a-framework-after-it-exists.test.mjs": {
139
145
  "tests": 8,
140
146
  "asserts": 19,
@@ -381,6 +387,12 @@
381
387
  "skips": 0,
382
388
  "todos": 0
383
389
  },
390
+ "a-local-install-hands-its-stores-to-every-reader.test.mjs": {
391
+ "tests": 1,
392
+ "asserts": 10,
393
+ "skips": 0,
394
+ "todos": 0
395
+ },
384
396
  "a-mark-name-cannot-lie-about-how-it-reads.test.mjs": {
385
397
  "tests": 9,
386
398
  "asserts": 37,
@@ -747,6 +759,12 @@
747
759
  "skips": 0,
748
760
  "todos": 0
749
761
  },
762
+ "a-store-refusal-reaches-the-person-who-can-fix-it.test.mjs": {
763
+ "tests": 3,
764
+ "asserts": 11,
765
+ "skips": 0,
766
+ "todos": 0
767
+ },
750
768
  "a-stored-territory-that-does-nothing-is-named.test.mjs": {
751
769
  "tests": 6,
752
770
  "asserts": 12,
@@ -1366,8 +1384,8 @@
1366
1384
  "todos": 0
1367
1385
  },
1368
1386
  "connect-clients-are-data.test.mjs": {
1369
- "tests": 12,
1370
- "asserts": 46,
1387
+ "tests": 15,
1388
+ "asserts": 75,
1371
1389
  "skips": 0,
1372
1390
  "todos": 0
1373
1391
  },
@@ -1397,7 +1415,7 @@
1397
1415
  },
1398
1416
  "connector-annotations-and-pack.test.mjs": {
1399
1417
  "tests": 7,
1400
- "asserts": 11,
1418
+ "asserts": 12,
1401
1419
  "skips": 0,
1402
1420
  "todos": 0
1403
1421
  },
@@ -1804,8 +1822,8 @@
1804
1822
  "todos": 0
1805
1823
  },
1806
1824
  "doctor-reads-the-units-not-the-shell.test.mjs": {
1807
- "tests": 30,
1808
- "asserts": 85,
1825
+ "tests": 32,
1826
+ "asserts": 93,
1809
1827
  "skips": 0,
1810
1828
  "todos": 0
1811
1829
  },
@@ -4725,6 +4743,12 @@
4725
4743
  "skips": 0,
4726
4744
  "todos": 0
4727
4745
  },
4746
+ "the-proving-question-names-no-engines-model.test.mjs": {
4747
+ "tests": 2,
4748
+ "asserts": 5,
4749
+ "skips": 0,
4750
+ "todos": 0
4751
+ },
4728
4752
  "the-public-residue-is-a-floor.test.mjs": {
4729
4753
  "tests": 15,
4730
4754
  "asserts": 59,
@@ -5404,7 +5428,7 @@
5404
5428
  },
5405
5429
  "describe-options.test.mjs": {
5406
5430
  "tests": 23,
5407
- "asserts": 98,
5431
+ "asserts": 99,
5408
5432
  "skips": 0,
5409
5433
  "todos": 0
5410
5434
  },
@@ -5594,8 +5618,8 @@
5594
5618
  "ext": "ts",
5595
5619
  "perFile": {
5596
5620
  "api.test.ts": {
5597
- "tests": 43,
5598
- "asserts": 170,
5621
+ "tests": 44,
5622
+ "asserts": 176,
5599
5623
  "skips": 0,
5600
5624
  "todos": 0
5601
5625
  },
@@ -5774,8 +5798,8 @@
5774
5798
  "todos": 0
5775
5799
  },
5776
5800
  "newCompanyReachableFromProfile.test.ts": {
5777
- "tests": 3,
5778
- "asserts": 7,
5801
+ "tests": 5,
5802
+ "asserts": 13,
5779
5803
  "skips": 0,
5780
5804
  "todos": 0
5781
5805
  },
@@ -5913,7 +5937,7 @@
5913
5937
  },
5914
5938
  "useYourAI.test.ts": {
5915
5939
  "tests": 13,
5916
- "asserts": 46,
5940
+ "asserts": 41,
5917
5941
  "skips": 0,
5918
5942
  "todos": 0
5919
5943
  },
@@ -1,5 +1,15 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.3.0-beta.5
4
+
5
+ ### Patch Changes
6
+
7
+ - e7cd9e1: Fixed: On an install run with `clearotron start`, a connected assistant now lists the same saved searches as the portal, including Generic's. `clearotron doctor` no longer says working saved searches are off, and it names one place profiles come from.
8
+
9
+ ## 0.3.0-beta.4
10
+
11
+ No changes in this release.
12
+
3
13
  ## 0.3.0-beta.3
4
14
 
5
15
  No changes in this release.
@@ -14,11 +24,11 @@ No changes in this release.
14
24
 
15
25
  ### Patch Changes
16
26
 
17
- - b8ec213: Fixed: a company you create in the browser now appears in your assistant's list of companies straight away.
27
+ - b8ec213: Fixed: A company you create in the browser now appears in your assistant's list of companies straight away.
18
28
 
19
29
  It used to appear only after the service restarted, although a search could already be started for it. Its projects were missing from the list in the same way, and so was its account in the search options.
20
30
 
21
- The live surface check no longer reports a company outside the portal's trigger key as the service and the store disagreeing. It names the key's gap on a line of its own, with the command to widen the key.
31
+ The check comparing the running service with the configuration store no longer calls a company the portal's key does not cover a disagreement. It names that gap on a line of its own, with the command that widens the key.
22
32
 
23
33
  ## 0.2.4
24
34
 
@@ -193,7 +193,10 @@ function accountFor(key, { scope, now }) {
193
193
  profile = loadProfiles().get(key) ?? null;
194
194
  if (!profile) { fresh = loadProfiles({ force: true }); profile = fresh.get(key) ?? null; }
195
195
  } catch { return null; } // unreadable roster ⇒ say nothing
196
- if (!profile || profile.key === "generic") return null;
196
+ // GENERIC IS ANSWERED LIKE ANY COMPANY (owner ruling, 2026-09-11). It owns saved searches in the portal,
197
+ // and a fresh install holds Generic alone, so treating it as nobody's account told the assistant on
198
+ // every such install that none existed.
199
+ if (!profile) return null;
197
200
 
198
201
  let projects = [];
199
202
  try {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.3.0-beta.3",
3
+ "version": "0.3.0-beta.5",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clearotron",
3
3
  "type": "module",
4
- "version": "0.3.0-beta.3",
4
+ "version": "0.3.0-beta.5",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",
@@ -2205,127 +2205,249 @@ input.filter {
2205
2205
  }
2206
2206
 
2207
2207
 
2208
- .ai-connect-head { margin-top: 28px; }
2208
+ /* ── WHAT YOU CAN DO ─────────────────────────────────────────────────────────────────────────────── */
2209
+ .ai-can { margin-top: 20px; max-width: 860px; }
2209
2210
 
2210
- /* ── ONE QUESTION, WHEN THE DEPLOYMENT LEAVES IT OPEN ─────────────────────────── */
2211
- .ai-where { margin: 0 0 14px; max-width: var(--screen-measure); }
2211
+ .ai-screen .ai-can h2 { font-size: 14.5px; margin: 0 0 3px; }
2212
2212
 
2213
- .ai-where-q { margin: 0 0 8px; font-size: 15px; color: var(--text-strong); }
2213
+ .ai-can-list {
2214
+ list-style: none;
2215
+ margin: 8px 0 0;
2216
+ padding: 0;
2217
+ display: grid;
2218
+ grid-template-columns: 1fr 1fr;
2219
+ gap: 8px 28px;
2220
+ }
2214
2221
 
2215
- .ai-seg { display: inline-flex; border: 1px solid var(--border-hairline); border-radius: 7px; overflow: hidden; }
2222
+ @media (max-width: 700px) { .ai-can-list { grid-template-columns: 1fr; } }
2216
2223
 
2217
- .ai-seg-btn {
2218
- appearance: none;
2219
- border: 0;
2220
- background: transparent;
2221
- padding: 7px 14px;
2222
- font: inherit;
2224
+ .ai-can-list li { display: flex; gap: 9px; font-size: 14px; line-height: 1.5; color: var(--text-body); }
2225
+
2226
+ .ai-can-list li svg { margin-top: 3px; color: var(--accent-quiet); }
2227
+
2228
+ .ai-can-foot {
2229
+ margin: 13px 0 0;
2230
+ padding-top: 11px;
2231
+ border-top: 1px solid var(--border-hairline);
2232
+ font-size: 13.5px;
2223
2233
  color: var(--text-muted);
2224
- cursor: pointer;
2225
2234
  }
2226
2235
 
2227
- .ai-seg-btn + .ai-seg-btn { border-left: 1px solid var(--border-hairline); }
2228
-
2229
- .ai-seg-btn[data-on] { background: var(--surface-sunken); color: var(--text-strong); }
2236
+ .ai-can-foot strong { color: var(--text-strong); }
2230
2237
 
2231
- .ai-where-sub { margin: 10px 0 14px; color: var(--text-muted); max-width: var(--screen-measure); }
2238
+ .ai-connect { margin-top: 34px; }
2232
2239
 
2233
- .ai-none { margin: 0; max-width: var(--screen-measure); color: var(--text-muted); line-height: 1.65; }
2240
+ .ai-screen .ai-connect-head { margin: 0 0 12px; }
2234
2241
 
2235
- .ai-none strong { color: var(--text-strong); }
2242
+ /* ── THE WHERE-CHOICE ─────────────────────────────────────────────────────────────────────────────
2243
+ ONE control, the same size before and after a pick. Selection is shown the way every other choice in
2244
+ the portal shows it — accent wash, accent border, filled radio — and never a second colour. */
2245
+ .where-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
2236
2246
 
2237
- /* ── THE DESTINATION LIST ─────────────────────────────────────────────────────────────────────────── */
2238
- .ai-dests {
2239
- display: flex;
2240
- flex-direction: column;
2241
- gap: 8px;
2242
- max-width: var(--screen-measure);
2243
- }
2247
+ @media (max-width: 640px) { .where-grid { grid-template-columns: 1fr; } }
2244
2248
 
2245
- /* ONE HEIGHT AND ONE WIDTH, ALWAYS. The owner reported the old row set as "stacked, inconsistent
2246
- heights"; more importantly a row that resizes when it is pressed moves its neighbours, which is the
2247
- reflow this rebuild exists to end. */
2248
- .ai-dest {
2249
+ .where-card {
2249
2250
  display: flex;
2250
2251
  align-items: center;
2251
- justify-content: space-between;
2252
- gap: 16px;
2253
- width: 100%;
2254
- min-height: 46px;
2255
- padding: 8px 14px;
2256
- border: 1px solid var(--border-hairline);
2257
- border-radius: 8px;
2252
+ gap: 12px;
2253
+ padding: 18px;
2254
+ border: 1px solid var(--border-strong);
2255
+ border-radius: 12px;
2258
2256
  background: var(--surface-raised);
2259
2257
  font: inherit;
2258
+ font-size: 16px;
2259
+ font-weight: 700;
2260
+ line-height: 1.35;
2261
+ color: var(--text-strong);
2260
2262
  text-align: left;
2261
2263
  cursor: pointer;
2262
2264
  }
2263
2265
 
2264
- .ai-dest[data-selected] { border-color: var(--accent); }
2266
+ .where-card:hover { border-color: var(--accent-ink); }
2265
2267
 
2266
- /* THE PRESSED CONTROL IS THE CONFIRMATION. Solid accent, not a muted caption underneath — the previous
2267
- feedback was grey text below the button and the owner could not see it happen. */
2268
- .ai-dest[data-copied] {
2269
- background: var(--accent);
2270
- border-color: var(--accent);
2271
- color: var(--text-on-accent);
2268
+ .where-card[aria-pressed='true'],
2269
+ .where-card[aria-pressed='true']:hover { border-color: var(--accent); background: var(--accent-wash); }
2270
+
2271
+ .ai-none { margin: 22px 0 0; max-width: 860px; border-left-color: var(--tone-medium); }
2272
+
2273
+ .ai-none p { margin: 6px 0 0; color: var(--text-muted); font-size: 13.5px; line-height: 1.55; }
2274
+
2275
+ /* ── THE APP LIST AND THE PANEL BESIDE IT ─────────────────────────────────────────────────────── */
2276
+ .ai-connect-grid {
2277
+ display: grid;
2278
+ grid-template-columns: minmax(260px, 360px) minmax(0, 1fr);
2279
+ gap: 20px;
2280
+ margin-top: 22px;
2281
+ align-items: start;
2272
2282
  }
2273
2283
 
2274
- .ai-dest[data-copied] .ai-dest-sub { color: inherit; opacity: 0.85; }
2284
+ @media (max-width: 860px) { .ai-connect-grid { grid-template-columns: 1fr; } }
2285
+
2286
+ .ai-apps { display: flex; flex-direction: column; gap: 8px; margin-top: 6px; }
2287
+
2288
+ /* The New Clearance picker's radio-row, centred because these rows carry a name and at most one line. */
2289
+ .ai-app { width: 100%; align-items: center; font: inherit; }
2275
2290
 
2276
- .ai-dest-name { display: flex; flex-direction: column; gap: 2px; color: var(--text-strong); }
2291
+ .ai-app-label { min-width: 0; }
2277
2292
 
2278
- .ai-dest[data-copied] .ai-dest-name { color: inherit; }
2293
+ .ai-app-name { display: block; font-size: 14px; font-weight: 700; color: var(--text-strong); }
2279
2294
 
2280
- .ai-dest-sub { font-size: 12.5px; color: var(--text-faint); }
2295
+ .ai-app-sub { display: block; margin-top: 1px; font-size: 12px; line-height: 1.5; color: var(--text-muted); }
2296
+
2297
+ .ai-app-go { margin-left: auto; display: inline-flex; color: var(--text-accent); opacity: 0; }
2298
+
2299
+ .pick-row-on .ai-app-go { opacity: 1; }
2300
+
2301
+ /* ── THE RESERVED SLOT ──────────────────────────────────────────────────────────────────────────────
2302
+ A MINIMUM HEIGHT WHETHER OR NOT ANYTHING IS IN IT, and the page raises it to the tallest panel this
2303
+ card can show — so neither picking an app nor pressing Copy moves anything around it. */
2304
+ .ai-slot { position: relative; min-height: 180px; }
2305
+
2306
+ /* The hidden copy of every panel the slot's height is taken from. Laid out at the slot's own width, never
2307
+ seen, never reached by a pointer or a keyboard. */
2308
+ .ai-probe {
2309
+ position: absolute;
2310
+ top: 0;
2311
+ left: 0;
2312
+ right: 0;
2313
+ display: grid;
2314
+ align-items: start;
2315
+ visibility: hidden;
2316
+ pointer-events: none;
2317
+ }
2281
2318
 
2282
- /* THE SLOT BOTH LABELS SHARE. Laid out with a grid so the cell is as wide as the WIDER of the two, in
2283
- every row, whether or not it has been pressed — swapping the text then costs zero layout. */
2284
- .ai-dest-say { display: grid; font-size: 13px; }
2319
+ /* Stacked in one cell, so the copy is as tall as its tallest panel and never taller than the slot. */
2320
+ .ai-probe > * { grid-area: 1 / 1; }
2285
2321
 
2286
- .ai-dest-idle, .ai-dest-done { grid-area: 1 / 1; white-space: nowrap; }
2322
+ .steps-panel {
2323
+ border: 1px solid var(--border-strong);
2324
+ border-left: 3px solid var(--accent);
2325
+ border-radius: 12px;
2326
+ background: var(--surface-raised);
2327
+ padding: 18px 20px 20px;
2328
+ }
2287
2329
 
2288
- .ai-dest-idle { color: var(--text-faint); }
2330
+ .ai-slot > .steps-panel { min-height: inherit; }
2289
2331
 
2290
- .ai-dest-done { color: inherit; font-weight: 600; visibility: hidden; }
2332
+ .steps-panel-empty {
2333
+ border-left-color: var(--border-strong);
2334
+ display: flex;
2335
+ align-items: center;
2336
+ justify-content: center;
2337
+ }
2291
2338
 
2292
- .ai-dest[data-copied] .ai-dest-idle { visibility: hidden; }
2339
+ .steps-empty { display: flex; align-items: center; gap: 9px; color: var(--text-faint); font-size: 14px; }
2293
2340
 
2294
- .ai-dest[data-copied] .ai-dest-done { visibility: visible; }
2341
+ .ai-screen .steps-name { margin: 3px 0 0; font-size: 18px; font-weight: 700; color: var(--text-strong); }
2295
2342
 
2296
- /* ── THE RESERVED SLOT ────────────────────────────────────────────────────────────────────────────── */
2297
- /* A MINIMUM HEIGHT WHETHER OR NOT ANYTHING IS IN IT. This is what makes "nothing above the panel moves"
2298
- a property of the layout rather than a hope about content sizes. */
2299
- .ai-slot {
2343
+ /* ── THE NUMBERED STEPS ─────────────────────────────────────────────────────────────────────────── */
2344
+ ol.steps {
2345
+ list-style: none;
2346
+ counter-reset: step;
2300
2347
  margin: 16px 0 0;
2301
- min-height: 240px;
2302
- max-width: var(--screen-measure);
2348
+ padding: 0;
2349
+ display: flex;
2350
+ flex-direction: column;
2351
+ gap: 16px;
2303
2352
  }
2304
2353
 
2305
- .ai-slot-empty { margin: 0; color: var(--text-faint); }
2354
+ ol.steps > li {
2355
+ counter-increment: step;
2356
+ display: grid;
2357
+ grid-template-columns: 24px minmax(0, 1fr);
2358
+ gap: 11px;
2359
+ }
2306
2360
 
2307
- .ai-panel {
2308
- padding: 14px 16px;
2309
- border: 1px solid var(--border-hairline);
2310
- border-radius: 8px;
2361
+ ol.steps > li::before {
2362
+ content: counter(step);
2363
+ width: 23px;
2364
+ height: 23px;
2365
+ margin-top: -1px;
2366
+ display: flex;
2367
+ align-items: center;
2368
+ justify-content: center;
2369
+ border-radius: 50%;
2311
2370
  background: var(--surface-sunken);
2371
+ border: 1px solid var(--border-hairline);
2372
+ font-family: var(--font-mono);
2373
+ font-size: 11px;
2374
+ font-weight: 600;
2375
+ color: var(--text-muted);
2312
2376
  }
2313
2377
 
2314
- .ai-panel-head { margin: 0 0 8px; font-size: 15px; color: var(--text-strong); }
2378
+ .step-text { font-size: 14px; line-height: 1.55; color: var(--text-body); }
2379
+
2380
+ .step-text b { color: var(--text-strong); }
2381
+
2382
+ .step-hint { margin-top: 4px; font-size: 12.5px; line-height: 1.5; color: var(--text-muted); }
2315
2383
 
2316
- .ai-panel-landed { margin: 0 0 10px; font-size: 13px; color: var(--text-muted); }
2384
+ .ai-code {
2385
+ padding: 1px 5px;
2386
+ border: 1px solid var(--border-hairline);
2387
+ border-radius: 4px;
2388
+ background: var(--surface-sunken);
2389
+ font-family: var(--font-mono);
2390
+ font-size: 12px;
2391
+ color: var(--text-strong);
2392
+ overflow-wrap: anywhere;
2393
+ }
2317
2394
 
2318
- .ai-panel-steps {
2395
+ .codeblock {
2396
+ margin-top: 8px;
2397
+ display: flex;
2398
+ align-items: flex-start;
2399
+ gap: 10px;
2400
+ padding: 10px 10px 10px 13px;
2401
+ border: 1px solid var(--border-hairline);
2402
+ border-radius: 10px;
2403
+ background: var(--surface-sunken);
2404
+ }
2405
+
2406
+ .codeblock pre {
2407
+ flex: 1;
2408
+ min-width: 0;
2319
2409
  margin: 0;
2320
- padding-left: 20px;
2321
- line-height: 1.7;
2322
- color: var(--text-body);
2410
+ padding-top: 3px;
2411
+ overflow-x: auto;
2412
+ font-family: var(--font-mono);
2413
+ font-size: 12px;
2414
+ line-height: 1.6;
2415
+ color: var(--text-strong);
2323
2416
  }
2324
2417
 
2325
- .ai-panel-stamp { margin: 10px 0 0; font-size: 12.5px; color: var(--text-faint); }
2418
+ .codeblock .btn-ghost { padding: 6px 12px; white-space: nowrap; }
2419
+
2420
+ .secret-btn { margin-top: 8px; }
2421
+
2422
+ /* THE PRESSED CONTROL IS THE CONFIRMATION: solid accent on the button pressed, never a muted caption. */
2423
+ .is-copied,
2424
+ .is-copied:hover,
2425
+ .btn-primary.is-copied:hover:not(:disabled) { background: var(--accent); border-color: var(--accent); color: var(--text-on-accent); }
2426
+
2427
+ .landed {
2428
+ margin-top: 10px;
2429
+ padding: 10px 13px;
2430
+ border: 1px solid var(--border-hairline);
2431
+ border-radius: 10px;
2432
+ background: var(--surface-sunken);
2433
+ }
2434
+
2435
+ .landed-label {
2436
+ font-size: 10px;
2437
+ font-weight: 700;
2438
+ letter-spacing: 0.13em;
2439
+ text-transform: uppercase;
2440
+ color: var(--text-faint);
2441
+ }
2442
+
2443
+ .landed .mono { font-size: 12px; color: var(--text-strong); overflow-wrap: anywhere; }
2444
+
2445
+ .landed .mono + .mono { margin-top: 2px; }
2326
2446
 
2327
2447
  .ai-said { margin: 0 0 6px; color: var(--text-muted); }
2328
2448
 
2449
+ .ai-failed { margin: 10px 0 0; }
2450
+
2329
2451
  .ai-pre {
2330
2452
  margin: 0;
2331
2453
  padding: 10px;
@@ -2335,21 +2457,12 @@ input.filter {
2335
2457
  font-size: 13px;
2336
2458
  }
2337
2459
 
2338
- /* The help a person needs, on the page. The link to the document keeps an honest label beside it. */
2339
- .ai-help { margin: 30px 0 0; max-width: var(--screen-measure); }
2340
-
2341
- .ai-help > summary {
2342
- cursor: pointer;
2343
- color: var(--text-muted);
2344
- font-size: 13px;
2345
- font-style: italic;
2346
- }
2347
-
2348
- .ai-help-body { margin: 10px 0 0; color: var(--text-muted); line-height: 1.65; }
2460
+ /* The help a person needs, as a quiet notice. The link opens the document where a reader's own AI can
2461
+ read it too. */
2462
+ .ai-help { margin-top: 30px; max-width: 860px; }
2349
2463
 
2350
- .ai-help-body p { margin: 0 0 10px; }
2464
+ .ai-help p { margin: 6px 0 0; color: var(--text-muted); font-size: 13.5px; line-height: 1.55; }
2351
2465
 
2352
- .ai-help-doc { font-size: 13px; }
2353
2466
 
2354
2467
  /* ── Give someone access: the points on the tree a new person can be given ─────────────────────────────
2355
2468
  One row per point. A BUTTON, because choosing a point is an action and a keyboard has to reach it; the