tickmarkr 1.90.8 → 1.92.0

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.
@@ -6,14 +6,18 @@ import { detectPackageManager, turboContinueFindings } from "../../gates/baselin
6
6
  import { version } from "./version.js";
7
7
  import { allAdapters, binaryShadowWarnings, detectCandidateClis, flagDriftWarnings, modelAliasExclusions, modelAliasLine, probeAll, probeModels, resolveShellBinary, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
8
8
  import { CLAUDE_ALIAS_IDENTITY_STAMPS, claudeCode, resolveClaudeAliasIdentity } from "../../adapters/claude-code.js";
9
- import { BANNER, dim, fail, kvRow, legend, ok, rule, statusRow, title } from "../../brand.js";
9
+ import { BANNER, compactTokens, dim, fail, kvRow, legend, ok, rule, statusRow, title } from "../../brand.js";
10
10
  import { tickmarkrDir, stateDirName } from "../../graph/graph.js";
11
- import { catalogModelAdvisory, declaredModelWindow, hasWindowsConfig, modelLints, suggestOverlay, ttyVisual } from "../../adapters/model-lints.js";
11
+ import { catalogModelAdvisory, catalogTierRanking, declaredModelWindow, hasWindowsConfig, modelLints, suggestOverlay, ttyVisual } from "../../adapters/model-lints.js";
12
12
  import { loadConfig, overlayPreferShapes } from "../../config/config.js";
13
13
  import { HerdrDriver } from "../../drivers/herdr.js";
14
14
  import { kimi, probeKimiDoctorTurn } from "../../adapters/kimi.js";
15
15
  import { denyPreferCollisionLine, denyPreferCollisions, disallowedBy, excludedChannels, exclusionLine, preferRanks } from "../../route/preference.js";
16
- import { readCachedCatalog, refreshCatalogCommand } from "../../adapters/catalog-remote.js";
16
+ import { LIVEBENCH_TABLE_DATE, readCachedCatalog, refreshCatalogCommand } from "../../adapters/catalog-remote.js";
17
+ /** Where a newer `table_<date>.csv` is discovered — the deployed site builds filenames by
18
+ * concatenation and publishes no index, so the release listing is the only enumerable surface. */
19
+ export const LIVEBENCH_RELEASES_URL = "https://api.github.com/repos/LiveBench/livebench.github.io/contents/public";
20
+ export const LIVEBENCH_TABLE_MAX_AGE_DAYS = 90;
17
21
  const visual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
18
22
  const alignedStatusRow = (verdict, key, value) => ` ${statusRow(verdict, kvRow(key, value).slice(2))}`;
19
23
  const attentionRow = (text) => ` ${statusRow("warn", text)}`;
@@ -205,6 +209,20 @@ export function selfShadowFinding(ownVersion, cwd = process.cwd(), resolve = res
205
209
  detail: `stale tickmarkr/tkr binaries on PATH: ${listing} (running ${ownVersion}) — a seat with different PATH order silently runs the old binary; remove the stale install root`,
206
210
  };
207
211
  }
212
+ /**
213
+ * §4.4: `LIVEBENCH_TABLE_DATE` is a hand-bumped constant, and a constant nobody lints is a stale
214
+ * constant — the pinned table ages silently while every tier suggestion keeps citing it. Warns past
215
+ * 90 days, naming the pinned date and the listing where a newer table is discovered.
216
+ * ponytail: a LINT, never a fetch — doctor stays cache-only (T17); the operator (or RELEASING.md's
217
+ * release-time check) reads the listing and bumps the constant.
218
+ */
219
+ export function liveBenchStalenessFinding(now) {
220
+ const pinnedMs = Date.parse(`${LIVEBENCH_TABLE_DATE.replace(/_/g, "-")}T00:00:00Z`);
221
+ const days = Math.floor((now.getTime() - pinnedMs) / 86_400_000);
222
+ if (!Number.isFinite(days) || days <= LIVEBENCH_TABLE_MAX_AGE_DAYS)
223
+ return undefined;
224
+ return `LiveBench table pinned at ${LIVEBENCH_TABLE_DATE} is ${days} days old (>${LIVEBENCH_TABLE_MAX_AGE_DAYS}d) — list ${LIVEBENCH_RELEASES_URL} for a newer table_<date>.csv and bump LIVEBENCH_TABLE_DATE (advisory — tickmarkr never fetches that listing)`;
225
+ }
208
226
  export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(), opts = {}) {
209
227
  if (_argv.length === 1 && _argv[0] === "--refresh-catalog") {
210
228
  const refreshed = await refreshCatalogCommand({ repoRoot: cwd, now: opts.catalogNow });
@@ -266,13 +284,27 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
266
284
  const modelProbeAdapters = kimiTurnEnabled && health.kimi.authed === false
267
285
  ? adapters.filter((a) => a !== kimiAdapter)
268
286
  : adapters;
287
+ // One rewriting stderr line instead of a scrolling line per model (operator report 2026-08-14:
288
+ // 20 probe lines buried the journey). Totals print once at the end; non-TTY stays silent.
289
+ let probeOk = 0;
290
+ let probeBad = 0;
269
291
  await probeModels(cfg, cwd, modelProbeAdapters, health, probeProgressTTY
270
- ? (adapter, model, status, durationMs) => console.error(` ${adapter}:${model} ${status} (${(durationMs / 1000).toFixed(1)}s)`)
292
+ ? (adapter, model, status, durationMs) => {
293
+ if (status === "ok")
294
+ probeOk += 1;
295
+ else
296
+ probeBad += 1;
297
+ process.stderr.write(`\r\x1b[2K probed ${probeOk + probeBad} channels · ${probeOk} ok${probeBad ? ` · ${probeBad} failed` : ""} · ${adapter}:${model} ${status} (${(durationMs / 1000).toFixed(1)}s)`);
298
+ }
271
299
  : undefined);
300
+ if (probeProgressTTY && probeOk + probeBad > 0) {
301
+ process.stderr.write(`\r\x1b[2K probed ${probeOk + probeBad} channels · ${probeOk} ok${probeBad ? ` · ${probeBad} failed` : ""}\n`);
302
+ }
272
303
  writeDoctor(cwd, health);
273
304
  const rows = adapters.map((a) => {
274
305
  const h = health[a.id];
275
- const state = !h.installed ? "not installed" : `${h.version ?? "installed"}${h.note ? ` (${h.note})` : ""}`;
306
+ // OBS-503: a crash-on-version verdict carries its reason in note — print it instead of the lie.
307
+ const state = !h.installed ? (h.note ?? "not installed") : `${h.version ?? "installed"}${h.note ? ` (${h.note})` : ""}`;
276
308
  const healthy = h.installed && (a.id !== kimi.id || h.authed);
277
309
  return alignedStatusRow(healthy ? "pass" : "fail", a.id, state);
278
310
  });
@@ -314,11 +346,15 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
314
346
  // v1.22 T5: workspace-trust pre-flight — per installed adapter: trusted | seeded | action-required | n/a.
315
347
  // action-required names the exact one-time command (or dialog) the operator must run once.
316
348
  rows.push(legend("workspace trust:"));
349
+ // Declutter (operator report 2026-08-14): n/a trust rows carried zero decisions — nine of them
350
+ // drowned the one action-required line. They compress to a single dim roster; actionable rows keep
351
+ // their own verdict lines.
352
+ const trustNa = [];
317
353
  for (const a of adapters) {
318
354
  if (!health[a.id]?.installed)
319
355
  continue;
320
356
  if (!a.trust) {
321
- rows.push(` ${dim("=")} ${kvRow(a.id, "trust: n/a").slice(2)}`);
357
+ trustNa.push(a.id);
322
358
  continue;
323
359
  }
324
360
  try {
@@ -335,27 +371,36 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
335
371
  rows.push(alignedStatusRow("warn", a.id, `trust: action-required — run ONCE: (trust check failed: ${e instanceof Error ? e.message : String(e)})`));
336
372
  }
337
373
  }
374
+ if (trustNa.length)
375
+ rows.push(` ${dim("=")} ${dim(`n/a (${trustNa.length}): ${trustNa.join(", ")}`)}`);
338
376
  for (const [role, sel] of [["judge", cfg.judge], ["consult", cfg.consult]]) {
339
377
  if (!health[sel.adapter]?.installed) {
340
378
  rows.push(attentionRow(`${role} runs on ${sel.adapter}:${sel.model} — NOT installed; that gate will fail closed until you install it or remap cfg.${role}`));
341
379
  }
342
380
  }
343
- rows.push(...modelLints(cfg, health, adapters, { tty: ttyVisual(), stateDir: stateDirName(cwd), overlayPreferShapes: overlayPreferShapes(cwd) }).map(attentionRow));
381
+ // OBS-505 residue: advisory lints collect apart from the capability/trust rows so the compact
382
+ // init journey can elide them behind the doctor pointer (one measured lint line ran 282 chars —
383
+ // pure wrap noise between two TUIs). Full doctor output is byte-identical: they rejoin in order.
384
+ const lintRows = [];
385
+ const liveBenchStale = liveBenchStalenessFinding(opts.catalogNow?.() ?? new Date());
386
+ if (liveBenchStale)
387
+ lintRows.push(attentionRow(liveBenchStale));
388
+ lintRows.push(...modelLints(cfg, health, adapters, { tty: ttyVisual(), stateDir: stateDirName(cwd), overlayPreferShapes: overlayPreferShapes(cwd) }).map(attentionRow));
344
389
  const excluded = excludedChannels(cfg, adapters, health);
345
390
  if (excluded.length)
346
- rows.push(attentionRow(exclusionLine(excluded)));
347
- rows.push(...denyPreferCollisions(cfg).map((c) => attentionRow(denyPreferCollisionLine(c))));
391
+ lintRows.push(attentionRow(exclusionLine(excluded)));
392
+ lintRows.push(...denyPreferCollisions(cfg).map((c) => attentionRow(denyPreferCollisionLine(c))));
348
393
  const aliasExcluded = modelAliasExclusions(cfg, adapters, health);
349
394
  if (aliasExcluded.length)
350
- rows.push(attentionRow(modelAliasLine(aliasExcluded)));
351
- rows.push(...binaryShadowWarnings(adapters, health, cwd).map(attentionRow));
395
+ lintRows.push(attentionRow(modelAliasLine(aliasExcluded)));
396
+ lintRows.push(...binaryShadowWarnings(adapters, health, cwd).map(attentionRow));
352
397
  // HYG-07(a): doctor just probed fresh (probeAll above), so servability attribution is current by construction.
353
398
  const servable = servableExclusions(cfg, adapters, health);
354
399
  if (servable.length)
355
- rows.push(attentionRow(servabilityLine(servable)));
400
+ lintRows.push(attentionRow(servabilityLine(servable)));
356
401
  // v1.65 T3: hardcoded-flag drift — advisory warn rows only. Runs AFTER writeDoctor so the verdicts
357
402
  // can never leak into doctor.json, and discoverChannels/routing never read them.
358
- rows.push(...flagDriftWarnings(adapters, health).map(attentionRow));
403
+ lintRows.push(...flagDriftWarnings(adapters, health).map(attentionRow));
359
404
  // OBS-145: resolved-identity drift is the same class of display-only doctor warning. Stamps live
360
405
  // beside the alias-owning adapter; the comparison runs only for configured floating aliases and
361
406
  // never enters health/doctor.json, config, channel discovery, learned profiles, or route().
@@ -374,7 +419,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
374
419
  continue; // advisory source failure is unknown, never a doctor failure
375
420
  }
376
421
  if (resolvedIdentity && resolvedIdentity !== stampedIdentity) {
377
- rows.push(attentionRow(`resolved-identity drift: claude-code:${alias} resolved to ${resolvedIdentity}, stamped identity ${stampedIdentity} — reclassify per benchmark policy (advisory — routing unchanged)`));
422
+ lintRows.push(attentionRow(`resolved-identity drift: claude-code:${alias} resolved to ${resolvedIdentity}, stamped identity ${stampedIdentity} — reclassify per benchmark policy (advisory — routing unchanged)`));
378
423
  }
379
424
  }
380
425
  }
@@ -413,29 +458,43 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
413
458
  const trunc = (s, n) => (s.length <= n ? s : `${s.slice(0, n - 1)}…`);
414
459
  const dateOf = (iso) => iso.slice(0, 10);
415
460
  const showWindows = hasWindowsConfig(cfg);
461
+ const unclassifiedByAdapter = new Map(adapters.flatMap((a) => {
462
+ const h = health[a.id];
463
+ if (!h?.installed)
464
+ return [];
465
+ const classified = cfg.tiers[a.id]?.models ?? {};
466
+ return [[a.id, (h.models ?? []).filter((m) => !(m in classified))]];
467
+ }));
468
+ // Every model under advisory in THIS report bands against one universe, built before any row
469
+ // renders — otherwise an adapter's band would depend on where it sits in the iteration.
470
+ const catalogRanking = catalogTierRanking(cfg, catalog, [...unclassifiedByAdapter].flatMap(([adapter, models]) => models.map((model) => ({ adapter, model, resolvedModel: resolvedCatalogModel(adapter, model) }))), resolvedCatalogModel);
416
471
  const modelStatus = adapters.flatMap((a) => {
417
472
  const h = health[a.id];
418
473
  if (!h?.installed)
419
474
  return [];
420
475
  const classified = cfg.tiers[a.id]?.models ?? {};
421
476
  const models = Object.keys(classified);
422
- const unclassified = (h.models ?? []).filter((m) => !(m in classified));
477
+ const unclassified = unclassifiedByAdapter.get(a.id) ?? [];
423
478
  if (!models.length && !unclassified.length)
424
479
  return [];
425
480
  const w = Math.max(8, ...models.map((m) => m.length));
426
481
  const rows = [` ${dim(a.id)}`];
427
482
  for (const m of models) {
428
483
  const v = h.modelAuth?.[m];
484
+ // probe wall clock (ProbedModelAuth.durationMs) — pre-v1.91 doctor.json rows lack it
485
+ const probedMs = v?.durationMs;
486
+ const probed = probedMs !== undefined ? dim(` ${(probedMs / 1000).toFixed(1)}s`) : "";
429
487
  const auth = !v
430
488
  ? dim("unknown")
431
489
  : v.authed
432
- ? ok("authed")
490
+ ? `${ok("authed")}${probed}`
433
491
  : `${fail("unauthed:")} ${trunc(v.reason ?? "probe failed", 40)} (${dateOf(v.probedAt)})`;
434
492
  const d = disallowedBy({ adapter: a.id, model: m }, cfg.routing);
435
493
  const denied = d?.by === "deny" ? d.entry : "—";
436
494
  const pref = preferRanks({ adapter: a.id, model: m }, cfg).map((p) => `${p.shape}#${p.rank}`).join(",") || "—";
495
+ const window = declaredModelWindow(cfg, a.id, m);
437
496
  const windowCol = showWindows
438
- ? ` ${String(declaredModelWindow(cfg, a.id, m) ?? "—").padEnd(8)}`
497
+ ? ` ${(window !== undefined ? compactTokens(window) : "—").padEnd(5)}`
439
498
  : "";
440
499
  rows.push(` ${m.padEnd(w)} ${classified[m].padEnd(8)}${windowCol} ${auth} ${dim("denied=")}${denied} ${dim("prefer=")}${pref}`);
441
500
  }
@@ -443,7 +502,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
443
502
  // Keep the historical count as an index, but it is no longer the whole report: every model gets
444
503
  // a cache-backed evidence or explicit uncovered line. Neither branch changes cfg, health, or route().
445
504
  rows.push(` ${dim(`(${unclassified.length} more listed, unclassified)`)}`);
446
- const advisories = unclassified.map((model) => catalogModelAdvisory(cfg, catalog, a.id, model, resolvedCatalogModel(a.id, model)));
505
+ const advisories = unclassified.map((model) => catalogModelAdvisory(cfg, catalog, a.id, model, resolvedCatalogModel(a.id, model), catalogRanking));
447
506
  // Q128s exhibit #4 / operator directive 2026-08-12: ~600 per-model advisory rows buried the six
448
507
  // decision-grade flags on a real machine. Default view keeps ONLY rows that ask the operator for a
449
508
  // decision (tier suggestions); the no-decision bulk (uncovered + covered evidence-only) compresses
@@ -474,7 +533,18 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
474
533
  if (opts.compact) {
475
534
  // Operator field report 2026-08-13: the full matrix mid-init-journey is clutter between two
476
535
  // TUIs. Every file side effect above already happened; the pointer line is the contract.
477
- return `${header}\n${rows.join("\n")}\n ${dim(`model matrix elided — \`tickmarkr doctor\` prints it in full${drift ? "; drift overlay written" : ""}`)}\nwrote ${stateDirName(cwd)}/doctor.json`;
536
+ // Advisory lints elide with the matrix (OBS-505 residue) — capability, trust, and fail-closed
537
+ // judge/consult rows stay; the counted pointer names what moved behind `tickmarkr doctor`.
538
+ const elided = [
539
+ ...(lintRows.length ? [`${lintRows.length} advisory lint${lintRows.length === 1 ? "" : "s"}`] : []),
540
+ "model matrix",
541
+ ].join(" and ");
542
+ return `${header}\n${rows.join("\n")}\n ${dim(`${elided} elided — \`tickmarkr doctor\` prints them in full${drift ? "; drift overlay written" : ""}`)}\nwrote ${stateDirName(cwd)}/doctor.json`;
478
543
  }
479
- return `${header}\n${rows.join("\n")}${modelSummary}${drift}\nwrote ${stateDirName(cwd)}/doctor.json`;
544
+ // Attention rows earn a counted section header so the matrix, the lints, and the model table
545
+ // read as three sections instead of one undifferentiated wall (operator report 2026-08-14).
546
+ const lintSection = lintRows.length
547
+ ? `\n${legend(`attention (${lintRows.length}):`)}\n${lintRows.join("\n")}`
548
+ : "";
549
+ return `${header}\n${rows.join("\n")}${lintSection}${modelSummary}${drift}\nwrote ${stateDirName(cwd)}/doctor.json`;
480
550
  }
@@ -19,7 +19,7 @@ export function candidateRow(c, pricing) {
19
19
  // order — route() returns a map pin first and fail-louds on its exclusion, which would collapse
20
20
  // the picker to a single row. No hidden mutation: fresh cfg objects only.
21
21
  export function shapeCandidates(task, cfg, channels, profile) {
22
- const { pin: _pin, ...entry } = cfg.routing.map[task.shape] ?? {};
22
+ const { pin: _pin, pool: _pool, ...entry } = cfg.routing.map[task.shape] ?? {};
23
23
  const map = { ...cfg.routing.map, [task.shape]: entry };
24
24
  return rankCandidates(task, { ...cfg, routing: { ...cfg.routing, map } }, channels, profile);
25
25
  }
@@ -29,13 +29,13 @@ export declare function fleet(argv: string[], cwd?: string, adapters?: WorkerAda
29
29
  code: number;
30
30
  }>;
31
31
  /** Everything between the doctor-reuse gate and the Ink render, packaged for reuse: `tickmarkr init`
32
- * runs the same editor (preset-first) without re-owning the assembly or the write funnel. `commit`
33
- * remains the single config actuator — it maps editor results to the command's exact result strings
34
- * and performs the one guarded overlay write. */
32
+ * runs the same editor (entry="presets": Esc is HOME to the preset overlay, never a bare quit)
33
+ * without re-owning the assembly or the write funnel. `commit` remains the single config actuator —
34
+ * it maps editor results to the command's exact result strings and performs the one guarded
35
+ * overlay write. */
35
36
  export declare function assembleFleetEditor(cwd: string, adapters: WorkerAdapter[], io: FleetIO, opts: {
36
37
  globalDir: string;
37
38
  entry?: "presets" | "probe";
38
- fresh?: boolean;
39
39
  }): Promise<{
40
40
  props: FleetEditorProps;
41
41
  commit: (result: FleetEditorResult) => string;
@@ -1,13 +1,17 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { parseArgs } from "node:util";
4
- import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse } from "../../adapters/registry.js";
5
- import { fleetUnclassifiedModels } from "../../adapters/model-lints.js";
4
+ import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, modelAuthExclusions } from "../../adapters/registry.js";
5
+ import { catalogModelAdvisory, catalogTierRanking, declaredModelWindow, fleetUnclassifiedModels } from "../../adapters/model-lints.js";
6
+ import { readCachedCatalog } from "../../adapters/catalog-remote.js";
7
+ import { CLAUDE_ALIAS_IDENTITY_STAMPS, readClaudeAliasIdentity } from "../../adapters/claude-code.js";
6
8
  import { fleetEditableFromConfig, fleetEditableEquals, formatFleetPrint, globalConfigDir, overlayBytesLoadError, renderFleetOverlayWrite, repoOverlayPath, ROUTING_MODES, unifiedYamlDiff, } from "../../config/config.js";
7
9
  import { projectFleetWhy, renderFleetWhy } from "../../config/fleet-why.js";
8
10
  import { SHAPES, TIERS } from "../../graph/schema.js";
11
+ import { doctor } from "./doctor.js";
9
12
  import { candidateRow, costSignal, shapeCandidates } from "./fleet-picker.js";
10
13
  import { route } from "../../route/router.js";
14
+ import { disallowedBy } from "../../route/preference.js";
11
15
  import { resolveRunMode } from "../../run/daemon.js";
12
16
  import { loadRoutingProfile } from "../../run/journal.js";
13
17
  const NON_TTY_MSG = "tickmarkr fleet: interactive fleet editor requires a TTY — use `tickmarkr fleet --print` for non-interactive output";
@@ -104,10 +108,18 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
104
108
  }
105
109
  if (!why && !interactive)
106
110
  return { out: NON_TTY_MSG, code: 1 };
107
- const assembled = await assembleFleetEditor(cwd, adapters, io, {
108
- globalDir,
109
- fresh: values.fresh ?? false,
110
- });
111
+ // OBS-528: `--fresh` parsed since v1.92 but nothing ever RAN the probe — it forced the reuse
112
+ // gate false and guaranteed the "probe data missing or stale" refusal. The law stands — the
113
+ // EDITOR never probes (previews stay cache-only, `r` still exits to the operator) — but the
114
+ // command may run the sensor up front, exactly like init's act 2: a stale cache (or --fresh)
115
+ // probes first, visibly, then the editor assembles from what the probe just recorded.
116
+ if (!why && interactive) {
117
+ const { reuse } = initDoctorReuse(cwd, values.fresh ?? false);
118
+ if (!reuse) {
119
+ output.write(`${await doctor([], cwd, adapters, { banner: false, compact: true })}\n`);
120
+ }
121
+ }
122
+ const assembled = await assembleFleetEditor(cwd, adapters, io, { globalDir });
111
123
  if ("unavailable" in assembled)
112
124
  return { out: assembled.unavailable, code: 1 };
113
125
  if (why)
@@ -146,33 +158,98 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
146
158
  return assembled.commit(result);
147
159
  }
148
160
  /** Everything between the doctor-reuse gate and the Ink render, packaged for reuse: `tickmarkr init`
149
- * runs the same editor (preset-first) without re-owning the assembly or the write funnel. `commit`
150
- * remains the single config actuator — it maps editor results to the command's exact result strings
151
- * and performs the one guarded overlay write. */
161
+ * runs the same editor (entry="presets": Esc is HOME to the preset overlay, never a bare quit)
162
+ * without re-owning the assembly or the write funnel. `commit` remains the single config actuator —
163
+ * it maps editor results to the command's exact result strings and performs the one guarded
164
+ * overlay write. */
152
165
  export async function assembleFleetEditor(cwd, adapters, io, opts) {
153
166
  const globalDir = opts.globalDir;
154
- const { reuse, health: cached } = initDoctorReuse(cwd, opts.fresh ?? false);
167
+ const { reuse, health: cached } = initDoctorReuse(cwd, false);
155
168
  if (!reuse || !cached) {
156
169
  return {
157
- unavailable: "tickmarkr fleet: probe data missing or stale — run `tickmarkr doctor` first (fleet never re-probes; doctor is the sensor)",
170
+ unavailable: "tickmarkr fleet: probe data missing or stale — run `tickmarkr doctor` first, or `tickmarkr fleet` interactively (it runs the probe itself when the cache is stale; --fresh forces one)",
158
171
  };
159
172
  }
160
173
  const rm = resolveRunMode(cwd, { globalDir });
161
174
  const cfg = rm.cfg;
162
- const initial = fleetEditableFromConfig(cfg);
163
- const editable = structuredClone(initial);
164
175
  const health = cached;
176
+ // v1.92: the discovered fleet universe — every channel the adapters actually SERVE, discovered
177
+ // blind to both membership scopes (deny and allow). Membership is an allow-complement, which is
178
+ // only computable against the served universe: cfg.tiers alone would drop served-but-unclassified
179
+ // channels (adapter-declared extras) out of the fleet the moment any exclusion is staged.
180
+ // fleetEditableFromConfig folds routing.allow into the editor's exclusion sets with it, and the
181
+ // writer emits the minimal allow form from it. The same pool later ranks every preview.
182
+ const { deny: _diskDeny, allow: _diskAllow, ...routingScopeBlind } = cfg.routing;
183
+ const previewChannels = discoverChannels({ ...cfg, routing: routingScopeBlind }, adapters, health);
184
+ const universe = adapters
185
+ .filter((adapter) => health[adapter.id]?.installed)
186
+ .map((adapter) => ({
187
+ adapter: adapter.id,
188
+ models: [...new Set(previewChannels.filter((c) => c.adapter === adapter.id).map((c) => c.model))],
189
+ }));
190
+ const initial = fleetEditableFromConfig(cfg, universe);
191
+ const editable = structuredClone(initial);
192
+ // OBS-508: the same catalog evidence doctor's drift overlay prints now rides each unclassified
193
+ // row — it prefills the classify flow and feeds the bulk `s` stage. Suggestions stay advisory:
194
+ // only the review-diff confirm writes, so "tickmarkr never applies" holds with less typing.
195
+ const catalog = readCachedCatalog(cwd);
196
+ // The same alias→identity resolution doctor hands its advisory rows: models.dev has never heard of
197
+ // `opus`, so without it the fleet's own frontier models drop out of the universe they are supposed
198
+ // to anchor and fleet bands a different set than doctor for one fleet. The stored identity wins;
199
+ // the stamped one backs it up. No probe — fleet never re-probes, doctor is the sensor (and doctor
200
+ // is the seat that lints a stamp the live identity has drifted away from).
201
+ const resolvedCatalogModel = (adapter, model) => adapter !== "claude-code" || !(model in CLAUDE_ALIAS_IDENTITY_STAMPS)
202
+ ? undefined
203
+ : readClaudeAliasIdentity(cwd, model) ?? CLAUDE_ALIAS_IDENTITY_STAMPS[model];
204
+ // One ranking universe for the whole screen: every unclassified row bands fleet-relatively
205
+ // against the same set, so a suggestion never depends on which adapter group renders first.
206
+ const unclassifiedRows = fleetUnclassifiedModels(cfg, health, adapters)
207
+ .map((row) => ({ ...row, resolvedModel: resolvedCatalogModel(row.adapter, row.model) }));
208
+ const catalogRanking = catalogTierRanking(cfg, catalog, unclassifiedRows, resolvedCatalogModel);
209
+ // OBS-508 follow-through: the browser renders the metadata the assembler always had — catalog
210
+ // ctx/price evidence and doctor's model-probe wall clock — as columns instead of dropping them.
211
+ const rowEvidence = (adapter, model, evidence) => {
212
+ const probed = health[adapter]?.modelAuth?.[model];
213
+ const contextWindow = evidence?.contextWindow ?? declaredModelWindow(cfg, adapter, model);
214
+ const out = {
215
+ ...(contextWindow !== undefined ? { contextWindow } : {}),
216
+ ...(evidence?.outputWindow !== undefined ? { outputWindow: evidence.outputWindow } : {}),
217
+ ...(evidence?.inputCostPerMtok !== undefined ? { inputCostPerMtok: evidence.inputCostPerMtok } : {}),
218
+ ...(evidence?.outputCostPerMtok !== undefined ? { outputCostPerMtok: evidence.outputCostPerMtok } : {}),
219
+ ...(probed?.durationMs !== undefined ? { probeMs: probed.durationMs } : {}),
220
+ // OBS-519: doctor already recorded the failed verdict — a rate-limited/unauthed model must
221
+ // not render identically to a healthy row on the surface that edits its fleet membership.
222
+ ...(probed?.authed === false ? { unauthed: probed.reason ?? "probe failed" } : {}),
223
+ };
224
+ return Object.keys(out).length > 0 ? out : undefined;
225
+ };
165
226
  const modelGroups = adapters
166
227
  .filter((adapter) => health[adapter.id]?.installed)
167
228
  .map((adapter) => {
168
- const unclassified = fleetUnclassifiedModels(cfg, health, adapters).filter((row) => row.adapter === adapter.id);
229
+ const unclassified = unclassifiedRows.filter((row) => row.adapter === adapter.id);
169
230
  return {
170
231
  adapter: adapter.id,
232
+ channel: cfg.tiers[adapter.id]?.channel,
171
233
  rows: [
172
- ...Object.entries(editable.tiers[adapter.id] ?? {}).map(([model, value]) => ({ model, tier: value?.tier })),
234
+ ...Object.entries(editable.tiers[adapter.id] ?? {}).map(([model, value]) => {
235
+ const advisory = catalogModelAdvisory(cfg, catalog, adapter.id, model, resolvedCatalogModel(adapter.id, model), catalogRanking);
236
+ const evidence = rowEvidence(adapter.id, model, advisory.coverage === "covered" ? advisory.evidence : undefined);
237
+ return { model, tier: value?.tier, ...(evidence ? { evidence } : {}) };
238
+ }),
173
239
  ...unclassified
174
240
  .filter((row) => !editable.tiers[adapter.id]?.[row.model])
175
- .map((row) => ({ model: row.model, detectedAt: row.detectedAt })),
241
+ .map((row) => {
242
+ const advisory = catalogModelAdvisory(cfg, catalog, adapter.id, row.model, row.resolvedModel, catalogRanking);
243
+ const evidence = rowEvidence(adapter.id, row.model, advisory.coverage === "covered" ? advisory.evidence : undefined);
244
+ return {
245
+ model: row.model,
246
+ detectedAt: row.detectedAt,
247
+ ...(evidence ? { evidence } : {}),
248
+ ...(advisory.suggestion
249
+ ? { suggestion: { tier: advisory.suggestion.tier, note: advisory.suggestion.provenanceNote } }
250
+ : {}),
251
+ };
252
+ }),
176
253
  ],
177
254
  };
178
255
  });
@@ -184,18 +261,38 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
184
261
  ]));
185
262
  const channels = discoverChannels(cfg, adapters, health);
186
263
  const profile = loadRoutingProfile(cwd, cfg, { preview: true });
187
- const previewCfg = (mode, map) => ({
188
- ...cfg,
189
- routing: { ...cfg.routing, map, floors: modeCfgs[mode].cfg.routing.floors },
190
- });
191
- const modeSpend = (mode, map) => {
264
+ const previewCfg = (mode, map, deny) => {
265
+ // mirror the writer: only the adapters/models scopes are fleet-editable; workers deny survives
266
+ const nextDeny = { ...cfg.routing.deny };
267
+ if (deny.adapters.length)
268
+ nextDeny.adapters = deny.adapters;
269
+ else
270
+ delete nextDeny.adapters;
271
+ if (deny.models.length)
272
+ nextDeny.models = deny.models;
273
+ else
274
+ delete nextDeny.models;
275
+ const routing = {
276
+ ...routingScopeBlind,
277
+ map,
278
+ floors: modeCfgs[mode].cfg.routing.floors,
279
+ ...(Object.keys(nextDeny).length ? { deny: nextDeny } : {}),
280
+ };
281
+ return { ...cfg, routing };
282
+ };
283
+ // route() never deny-filters its pool — that contract lives in discoverChannels — so every
284
+ // preview call rebuilds the pool the staged deny would discover
285
+ const previewPool = (cfgPreview) => previewChannels.filter((c) => disallowedBy(c, cfgPreview.routing) === null);
286
+ const modeSpend = (mode, map, deny) => {
192
287
  const tierCount = {};
193
288
  let subs = 0;
194
289
  let apiN = 0;
195
290
  let apiUsd = 0;
291
+ const cfgPreview = previewCfg(mode, map, deny);
292
+ const pool = previewPool(cfgPreview);
196
293
  for (const shape of SHAPES) {
197
294
  try {
198
- const assignment = route(previewTask(shape), previewCfg(mode, map), channels, profile, undefined, undefined, PREVIEW_EXPLORE).assignment;
295
+ const assignment = route(previewTask(shape), cfgPreview, pool, profile, undefined, undefined, PREVIEW_EXPLORE).assignment;
199
296
  tierCount[assignment.tier] = (tierCount[assignment.tier] ?? 0) + 1;
200
297
  if (assignment.channel === "sub")
201
298
  subs += 1;
@@ -250,13 +347,20 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
250
347
  ? { declaredAt: `routing.floors.${shape}` }
251
348
  : { declaredAt: "routing.mode" };
252
349
  };
253
- const projectedShapeRows = (mode, map) => {
350
+ const projectedShapeRows = (mode, map, deny) => {
351
+ const cfgPreview = previewCfg(mode, map, deny);
352
+ const pool = previewPool(cfgPreview);
254
353
  const values = SHAPES.map((shape) => {
255
354
  try {
256
- const routed = route(previewTask(shape), previewCfg(mode, map), channels, profile, undefined, undefined, PREVIEW_EXPLORE);
355
+ const routed = route(previewTask(shape), cfgPreview, pool, profile, undefined, undefined, PREVIEW_EXPLORE);
257
356
  const assignment = routed.assignment;
258
- const effective = `${assignment.adapter}:${assignment.model} (${assignment.channel}, ${assignment.tier}) ${costSignal(assignment, cfg.pricing)}`;
259
- const mapProducedValue = map[shape]?.pin !== undefined || routed.provenance.includes("via prefer");
357
+ // OBS-531: a pooled shape used to render only the routed WINNER — indistinguishable from
358
+ // a pin. The declaration is the operator's decision; the winner is today's dice.
359
+ const declaredPool = map[shape]?.pool;
360
+ const poolPrefix = declaredPool ? `pool(${declaredPool.mode}·${declaredPool.channels.length}) → ` : "";
361
+ const effective = `${poolPrefix}${assignment.adapter}:${assignment.model} (${assignment.channel}, ${assignment.tier}) ${costSignal(assignment, cfg.pricing)}`;
362
+ const mapProducedValue = map[shape]?.pin !== undefined || map[shape]?.pool !== undefined
363
+ || routed.provenance.includes("via prefer");
260
364
  return {
261
365
  id: shape,
262
366
  effective,
@@ -264,7 +368,8 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
264
368
  };
265
369
  }
266
370
  catch (error) {
267
- const mapProducedValue = map[shape]?.pin !== undefined || (map[shape]?.prefer?.length ?? 0) > 0;
371
+ const mapProducedValue = map[shape]?.pin !== undefined || map[shape]?.pool !== undefined
372
+ || (map[shape]?.prefer?.length ?? 0) > 0;
268
373
  return {
269
374
  id: shape,
270
375
  effective: error.message,
@@ -275,12 +380,49 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
275
380
  });
276
381
  return projectFleetWhy(values, { repoRoot: cwd, globalDir });
277
382
  };
278
- const routedShapeRows = (mode, map) => projectedShapeRows(mode, map).map(({ id, label }) => ({ id, label }));
279
- const candidatesForShape = (shape, mode, map) => shapeCandidates(previewTask(shape), previewCfg(mode, map), channels, profile).map((candidate) => ({
280
- id: `${candidate.assignment.adapter}:${candidate.assignment.model}`,
281
- label: candidateRow(candidate, cfg.pricing),
282
- pin: { via: candidate.assignment.adapter, model: candidate.assignment.model },
283
- }));
383
+ const routedShapeRows = (mode, map, deny) => projectedShapeRows(mode, map, deny).map(({ id, label }) => ({ id, label }));
384
+ // OBS-530: the picker silently omitted every excluded channel — the operator asked "why is X
385
+ // missing" three times in one session and the answer lived only in config archaeology. One dim
386
+ // line now names each bucket. Static buckets (unauthed, unclassified) computed once; the
387
+ // staged-vs-disk split recomputed per open because it ranks against the SESSION's deny state.
388
+ const unauthedClis = adapters
389
+ .filter((adapter) => health[adapter.id]?.installed && health[adapter.id]?.authed === false)
390
+ .map((adapter) => adapter.id);
391
+ const unauthedModelCount = modelAuthExclusions(cfg, adapters, health).length;
392
+ const excludedNoteFor = (deny) => {
393
+ let stagedOut = 0;
394
+ let denied = 0;
395
+ const stagedRouting = previewCfg(rm.mode.mode, {}, deny).routing;
396
+ for (const channel of previewChannels) {
397
+ if (disallowedBy(channel, stagedRouting) === null)
398
+ continue;
399
+ if (disallowedBy(channel, cfg.routing) === null)
400
+ stagedOut += 1;
401
+ else
402
+ denied += 1;
403
+ }
404
+ const parts = [];
405
+ if (stagedOut)
406
+ parts.push(`${stagedOut} staged out this session`);
407
+ if (denied)
408
+ parts.push(`${denied} denied in config`);
409
+ if (unauthedModelCount)
410
+ parts.push(`${unauthedModelCount} unauthed`);
411
+ if (unauthedClis.length)
412
+ parts.push(`${unauthedClis.join("/")} CLI unauthed`);
413
+ if (unclassifiedRows.length)
414
+ parts.push(`${unclassifiedRows.length} unclassified (never routed)`);
415
+ return parts.length ? `not offered: ${parts.join(" · ")} — the models view explains each row` : undefined;
416
+ };
417
+ const candidatesForShape = (shape, mode, map, deny) => {
418
+ const cfgPreview = previewCfg(mode, map, deny);
419
+ const rows = shapeCandidates(previewTask(shape), cfgPreview, previewPool(cfgPreview), profile).map((candidate) => ({
420
+ id: `${candidate.assignment.adapter}:${candidate.assignment.model}`,
421
+ label: candidateRow(candidate, cfg.pricing),
422
+ pin: { via: candidate.assignment.adapter, model: candidate.assignment.model },
423
+ }));
424
+ return { rows, excludedNote: excludedNoteFor(deny) };
425
+ };
284
426
  const preferUniverse = [
285
427
  ...new Set(channels.flatMap((channel) => [
286
428
  channel.adapter,
@@ -331,6 +473,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
331
473
  const write = {
332
474
  initial,
333
475
  edited: staged,
476
+ universe,
334
477
  ...(modeChanged ? { mode: state.selectedMode } : {}),
335
478
  ...(judgeChanged && judgeSeat ? { judge: judgeSeat } : {}),
336
479
  steering: { initial: initialSteering, edited: state.steering },
@@ -361,7 +504,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
361
504
  initialMode: rm.mode.mode,
362
505
  modeOptions: ROUTING_MODES.map((mode) => ({ id: mode, gloss: MODE_GLOSS[mode] })),
363
506
  initialMap: editable.map,
364
- modePreview: (mode, map) => [modeSpend(mode, map), ...floorPreview(mode)],
507
+ modePreview: (mode, map, deny) => [modeSpend(mode, map, deny), ...floorPreview(mode)],
365
508
  shapeRows: routedShapeRows,
366
509
  candidatesForShape,
367
510
  preferOptionsForShape: (_shape, current) => [
@@ -384,7 +527,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
384
527
  if (result.kind === "quit")
385
528
  return QUIT;
386
529
  if (result.kind === "refresh") {
387
- return "fleet: run `tickmarkr doctor` to refresh probe data, then re-run `tickmarkr fleet` (doctor is the sensor; fleet never re-probes)";
530
+ return "fleet: probe refresh requested — re-run `tickmarkr fleet --fresh` (doctor is the sensor; the editor itself never re-probes)";
388
531
  }
389
532
  if (result.kind === "no-changes")
390
533
  return "fleet: no overlay changes (empty diff)";
@@ -396,11 +539,29 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
396
539
  if (!write)
397
540
  throw new Error("fleet write reached confirmation without a staged overlay mutation");
398
541
  writeFleetOverlay(result.review.path, (prior) => renderFleetOverlayWrite(prior, write));
399
- return `fleet: wrote ${result.review.path}`;
542
+ // OBS-529: a freshly classified model has no probe verdict (doctor probes CONFIGURED models,
543
+ // and it was not configured at probe time), so it stays unroutable — invisible in every
544
+ // picker — until the next probe. Name the step, or the classify flow reads as broken.
545
+ const unprobed = [];
546
+ for (const [adapter, models] of Object.entries(write.edited.tiers)) {
547
+ for (const [model, assigned] of Object.entries(models)) {
548
+ if (assigned === null || assigned === undefined)
549
+ continue;
550
+ if (JSON.stringify(write.initial.tiers[adapter]?.[model]) === JSON.stringify(assigned))
551
+ continue;
552
+ if (health[adapter]?.modelAuth?.[model] === undefined)
553
+ unprobed.push(`${adapter}:${model}`);
554
+ }
555
+ }
556
+ const head = `fleet: wrote ${result.review.path}`;
557
+ if (!unprobed.length)
558
+ return head;
559
+ const named = unprobed.slice(0, 3).join(", ") + (unprobed.length > 3 ? `, +${unprobed.length - 3} more` : "");
560
+ return `${head}\nfleet: ${unprobed.length} newly classified model(s) have no probe verdict yet (${named}) — run \`tickmarkr fleet --fresh\` to probe them; unverified models stay unroutable`;
400
561
  };
401
562
  return {
402
563
  props,
403
564
  commit,
404
- renderWhy: () => renderFleetWhy(projectedShapeRows(rm.mode.mode, editable.map)),
565
+ renderWhy: () => renderFleetWhy(projectedShapeRows(rm.mode.mode, editable.map, { adapters: editable.denyAdapters, models: editable.denyModels })),
405
566
  };
406
567
  }
@@ -275,9 +275,10 @@ async function installAgentFiles(cwd, force, docs, notes) {
275
275
  }
276
276
  }
277
277
  // The consolidated wizard (operator directive 2026-08-12): init walks three acts in one
278
- // command — preferences (Ink form) → discovery (doctor, the sensor) → fleet (presets or the
279
- // full editor). The readline asks this replaced live on in git history; the Ink act keeps the
280
- // same four questions and the same defaults, so `--yes` and non-TTY paths are unchanged.
278
+ // command — preferences (Ink form) → discovery (doctor, the sensor) → fleet (the browser opens
279
+ // on the models view; the presets overlay raises on the first Shapes entry). The readline asks
280
+ // this replaced live on in git history; the Ink act keeps the same four questions and the same
281
+ // defaults, so `--yes` and non-TTY paths are unchanged.
281
282
  async function runInitWizard(cwd, input, output) {
282
283
  // Dynamic import, deliberately (same seam as fleet.ts): the Ink/React runtime must stay out of
283
284
  // the non-TTY and --yes paths — init runs in CI and agent shells where importing Ink at module
@@ -395,7 +396,8 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
395
396
  : `using probe results from ${formatDoctorAgeForInit(ageMs)} ago — run tickmarkr doctor to refresh (or init --fresh)\n${formatDoctorReport(cwd, loadConfig(cwd), health, allAdapters(), { wrote: false })}`
396
397
  : await doctor([], cwd, undefined, { banner: false, compact: interactive });
397
398
  // Act 3 — fleet. The compact discovery surface prints BETWEEN the acts so the operator reads
398
- // what the presets screen ranks with; the final summary then carries a pointer, not a repeat.
399
+ // what the fleet browser (and its presets overlay, raised on the first Shapes entry) ranks
400
+ // with; the final summary then carries a pointer, not a repeat.
399
401
  if (interactive) {
400
402
  output.write(`${doc}\n`);
401
403
  doc = "discovery shown above — full matrix any time with `tickmarkr doctor`";