tickmarkr 1.91.0 → 1.92.1

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/README.md CHANGED
@@ -147,18 +147,20 @@ Configure which agent CLIs and models tickmarkr may route before your first run:
147
147
 
148
148
  ```bash
149
149
  tickmarkr doctor # probe auth + capabilities (run after install or credential changes)
150
- tickmarkr fleet # interactive editor (requires a TTY) — six steps, confirm to write
150
+ tickmarkr fleet # interactive fleet browser (requires a TTY) — review diff to write
151
151
  tickmarkr fleet --print # effective fleet state (repo > global > defaults), non-interactive
152
152
  tickmarkr plan # lint the resolved routing table against your spec
153
153
  tickmarkr run # dispatch with the fleet you confirmed
154
154
  ```
155
155
 
156
- `tickmarkr doctor` is a pure sensor; `tickmarkr fleet` is the actuator. The editor walks **six
157
- steps** — probe data, agent CLIs, model tiers, routing mode, shape routing with a **candidate
158
- picker**, and steering preferences — and ends in a unified diff of your repo config overlay.
159
- Nothing is written until you confirm; pressing Enter through every step leaves config unchanged.
160
- Step 5/6 uses an arrow-driven candidate picker ranked by the production router; step 3 may ask
161
- for a benchmark-provenance note when you classify a new model.
156
+ `tickmarkr doctor` is a pure sensor; `tickmarkr fleet` is the actuator. The browser is one
157
+ two-pane surface: the left rail lists views (**All models**, **Shapes**, **Steering**) and every
158
+ installed agent CLI with its auth state and model count; the right pane is a searchable model list
159
+ with tier, context, price, and probe-latency columns. `Space` allows/denies, `Enter` classifies an
160
+ unclassified model (with a required benchmark-provenance note) or pins a classified one to a shape,
161
+ `m` opens the routing-mode presets, and `w` renders the unified diff of your repo config overlay.
162
+ Nothing is written until you confirm the diff with `y`; quitting leaves config unchanged. The
163
+ Shapes view pins from a candidate picker ranked by the production router.
162
164
 
163
165
  Routing-mode semantics, pin/floor/prefer precedence, review and consult steering syntax,
164
166
  provenance rules, and `--quality` / `--mode` flags are documented in
@@ -166,8 +168,9 @@ provenance rules, and `--quality` / `--mode` flags are documented in
166
168
 
167
169
  ## Steering
168
170
 
169
- Fleet step 6/6 sets `review.prefer` and `consult.prefer`; routing modes (`risk-based`,
170
- `partner-led`, `staff-led`) are chosen in step 4/6. Full grammar — including when review
171
+ The fleet browser's **Steering** view sets `review.prefer` and `consult.prefer`; routing modes
172
+ (`risk-based`, `partner-led`, `staff-led`) are applied from the presets overlay (`m`). Full
173
+ grammar — including when review
171
174
  prefer may name a **bare adapter** versus `adapter:model`, and why consult prefer entries
172
175
  require **`adapter:model`** form — plus `tickmarkr run --supersedes` rerun control, is in
173
176
  **[FLEET.md](https://github.com/alzahrani-khalid/tickmarkr/blob/main/FLEET.md)**.
@@ -67,6 +67,7 @@ export type RoutedAssignment = {
67
67
  /** Advisory only — absent windows config or undeclared model window ⇒ no lint. */
68
68
  export declare function contextWindowLints(tasks: ReadonlyArray<Task>, assignments: ReadonlyArray<RoutedAssignment>, cfg: TickmarkrConfig, repoRoot: string): string[];
69
69
  export declare function seedPreferLints(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], overlayPreferShapes?: ReadonlySet<string>): string[];
70
+ export declare function deadPoolLints(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[]): string[];
70
71
  export declare function preferEntryLints(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, mapShapes?: ReadonlySet<string>): string[];
71
72
  export declare function modelLints(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], opts?: {
72
73
  tty?: boolean;
@@ -3,7 +3,7 @@ import { existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { DEFAULT_CONFIG, TIER_RANK } from "../config/config.js";
5
5
  import { buildTaskPrompt } from "./prompt.js";
6
- import { MODEL_ID_RE } from "./types.js";
6
+ import { channelKey, MODEL_ID_RE } from "./types.js";
7
7
  import { resolveCatalogModel } from "./catalog-remote.js";
8
8
  export const SEED_STAMPED = "2026-07-09";
9
9
  // knowledge past this age gets a "rerun tickmarkr doctor" nudge (BLOCKED_POLL_MS-style named constant).
@@ -319,6 +319,33 @@ function collectSeedPreferLints(cfg, health, adapters, overlayPreferShapes, disp
319
319
  export function seedPreferLints(cfg, health, adapters, overlayPreferShapes = new Set()) {
320
320
  return collectSeedPreferLints(cfg, health, adapters, overlayPreferShapes, DECLARED_SEED_PREFER_DISPOSITION);
321
321
  }
322
+ // v1.92: dead-pool lint — mirrors the dead-adapter seed lint above. A pool naming zero
323
+ // doctor-found channels renders advisory here; plan/run stays the fail-loud authority (the
324
+ // router's exhaustion RoutingError). Liveness mirrors adapterHasAuthedChannel's compat rule:
325
+ // an installed adapter without per-model probe data counts live, not dead.
326
+ export function deadPoolLints(cfg, health, adapters) {
327
+ const lints = [];
328
+ for (const [shape, entry] of Object.entries(cfg.routing.map)) {
329
+ const pool = entry.pool;
330
+ if (!pool)
331
+ continue;
332
+ const live = pool.channels.some((id) => {
333
+ const a = adapters.find((x) => x.id === id.slice(0, id.indexOf(":")));
334
+ const h = a && health[a.id];
335
+ if (!a || !h?.installed || typeof a.channels !== "function")
336
+ return false;
337
+ const c = a.channels(cfg).find((ch) => channelKey(ch) === id);
338
+ if (!c)
339
+ return false;
340
+ if (!h.modelAuth || !Object.keys(h.modelAuth).length)
341
+ return true; // no per-model probe data — compat, not dead
342
+ return h.modelAuth[c.model]?.authed === true;
343
+ });
344
+ if (!live)
345
+ lints.push(`routing.map.${shape}.pool names no live channel — declared: ${pool.channels.join(", ")}`);
346
+ }
347
+ return lints;
348
+ }
322
349
  // v1.54 T3: dead-steering sweep — operator prefer entries (routing.map overlay shapes, review.prefer,
323
350
  // consult.prefer) that can never match an installed channel are named at plan time; v1.53 T2 pins the
324
351
  // no-match case as a silent no-op, which makes a typo invisible. Advisory only: reads config + doctor
@@ -397,6 +424,7 @@ export function modelLints(cfg, health, adapters, opts) {
397
424
  ? LEGACY_DOCTOR_SEED_PREFER_DISPOSITION
398
425
  : DECLARED_SEED_PREFER_DISPOSITION;
399
426
  lints.push(...collectSeedPreferLints(cfg, health, adapters, opts?.overlayPreferShapes ?? new Set(), seedDisposition));
427
+ lints.push(...deadPoolLints(cfg, health, adapters));
400
428
  return lints;
401
429
  }
402
430
  // T2/T6: one lint per exclusion, naming the probe reason and date. TTY truncates reasons to 60 chars and
package/dist/brand.d.ts CHANGED
@@ -80,3 +80,5 @@ export declare const kvRow: (key: string, value: string, keyWidth?: number) => s
80
80
  export type Verdict = "pass" | "fail" | "warn" | "neutral";
81
81
  /** Status row: verdict glyph FIRST, then the label — glyph-first, distinct shape per verdict. */
82
82
  export declare const statusRow: (verdict: Verdict, label: string) => string;
83
+ /** Compact token counts for tables: 1048576 → "1m", 262144 → "262k" (UI-free — safe for doctor). */
84
+ export declare function compactTokens(tokens: number): string;
package/dist/brand.js CHANGED
@@ -160,3 +160,16 @@ const VERDICT = {
160
160
  };
161
161
  /** Status row: verdict glyph FIRST, then the label — glyph-first, distinct shape per verdict. */
162
162
  export const statusRow = (verdict, label) => `${VERDICT[verdict]()} ${label}`;
163
+ /** Compact token counts for tables: 1048576 → "1m", 262144 → "262k" (UI-free — safe for doctor). */
164
+ export function compactTokens(tokens) {
165
+ if (!Number.isFinite(tokens) || tokens <= 0)
166
+ return "0";
167
+ if (tokens >= 1_000_000) {
168
+ const m = tokens / 1_000_000;
169
+ const rounded = m >= 10 ? Math.round(m) : Math.round(m * 10) / 10;
170
+ return `${String(rounded).replace(/\.0$/, "")}m`;
171
+ }
172
+ if (tokens >= 1000)
173
+ return `${Math.round(tokens / 1000)}k`;
174
+ return String(tokens);
175
+ }
@@ -6,7 +6,7 @@ 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
11
  import { catalogModelAdvisory, catalogTierRanking, declaredModelWindow, hasWindowsConfig, modelLints, suggestOverlay, ttyVisual } from "../../adapters/model-lints.js";
12
12
  import { loadConfig, overlayPreferShapes } from "../../config/config.js";
@@ -284,9 +284,22 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
284
284
  const modelProbeAdapters = kimiTurnEnabled && health.kimi.authed === false
285
285
  ? adapters.filter((a) => a !== kimiAdapter)
286
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;
287
291
  await probeModels(cfg, cwd, modelProbeAdapters, health, probeProgressTTY
288
- ? (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
+ }
289
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
+ }
290
303
  writeDoctor(cwd, health);
291
304
  const rows = adapters.map((a) => {
292
305
  const h = health[a.id];
@@ -333,11 +346,15 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
333
346
  // v1.22 T5: workspace-trust pre-flight — per installed adapter: trusted | seeded | action-required | n/a.
334
347
  // action-required names the exact one-time command (or dialog) the operator must run once.
335
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 = [];
336
353
  for (const a of adapters) {
337
354
  if (!health[a.id]?.installed)
338
355
  continue;
339
356
  if (!a.trust) {
340
- rows.push(` ${dim("=")} ${kvRow(a.id, "trust: n/a").slice(2)}`);
357
+ trustNa.push(a.id);
341
358
  continue;
342
359
  }
343
360
  try {
@@ -354,6 +371,8 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
354
371
  rows.push(alignedStatusRow("warn", a.id, `trust: action-required — run ONCE: (trust check failed: ${e instanceof Error ? e.message : String(e)})`));
355
372
  }
356
373
  }
374
+ if (trustNa.length)
375
+ rows.push(` ${dim("=")} ${dim(`n/a (${trustNa.length}): ${trustNa.join(", ")}`)}`);
357
376
  for (const [role, sel] of [["judge", cfg.judge], ["consult", cfg.consult]]) {
358
377
  if (!health[sel.adapter]?.installed) {
359
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}`));
@@ -462,16 +481,20 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
462
481
  const rows = [` ${dim(a.id)}`];
463
482
  for (const m of models) {
464
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`) : "";
465
487
  const auth = !v
466
488
  ? dim("unknown")
467
489
  : v.authed
468
- ? ok("authed")
490
+ ? `${ok("authed")}${probed}`
469
491
  : `${fail("unauthed:")} ${trunc(v.reason ?? "probe failed", 40)} (${dateOf(v.probedAt)})`;
470
492
  const d = disallowedBy({ adapter: a.id, model: m }, cfg.routing);
471
493
  const denied = d?.by === "deny" ? d.entry : "—";
472
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);
473
496
  const windowCol = showWindows
474
- ? ` ${String(declaredModelWindow(cfg, a.id, m) ?? "—").padEnd(8)}`
497
+ ? ` ${(window !== undefined ? compactTokens(window) : "—").padEnd(5)}`
475
498
  : "";
476
499
  rows.push(` ${m.padEnd(w)} ${classified[m].padEnd(8)}${windowCol} ${auth} ${dim("denied=")}${denied} ${dim("prefer=")}${pref}`);
477
500
  }
@@ -518,5 +541,10 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
518
541
  ].join(" and ");
519
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`;
520
543
  }
521
- return `${header}\n${[...rows, ...lintRows].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`;
522
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,15 +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 { catalogModelAdvisory, catalogTierRanking, 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
6
  import { readCachedCatalog } from "../../adapters/catalog-remote.js";
7
7
  import { CLAUDE_ALIAS_IDENTITY_STAMPS, readClaudeAliasIdentity } from "../../adapters/claude-code.js";
8
8
  import { fleetEditableFromConfig, fleetEditableEquals, formatFleetPrint, globalConfigDir, overlayBytesLoadError, renderFleetOverlayWrite, repoOverlayPath, ROUTING_MODES, unifiedYamlDiff, } from "../../config/config.js";
9
9
  import { projectFleetWhy, renderFleetWhy } from "../../config/fleet-why.js";
10
10
  import { SHAPES, TIERS } from "../../graph/schema.js";
11
+ import { doctor } from "./doctor.js";
11
12
  import { candidateRow, costSignal, shapeCandidates } from "./fleet-picker.js";
12
13
  import { route } from "../../route/router.js";
14
+ import { disallowedBy } from "../../route/preference.js";
13
15
  import { resolveRunMode } from "../../run/daemon.js";
14
16
  import { loadRoutingProfile } from "../../run/journal.js";
15
17
  const NON_TTY_MSG = "tickmarkr fleet: interactive fleet editor requires a TTY — use `tickmarkr fleet --print` for non-interactive output";
@@ -106,10 +108,18 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
106
108
  }
107
109
  if (!why && !interactive)
108
110
  return { out: NON_TTY_MSG, code: 1 };
109
- const assembled = await assembleFleetEditor(cwd, adapters, io, {
110
- globalDir,
111
- fresh: values.fresh ?? false,
112
- });
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 });
113
123
  if ("unavailable" in assembled)
114
124
  return { out: assembled.unavailable, code: 1 };
115
125
  if (why)
@@ -148,22 +158,37 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
148
158
  return assembled.commit(result);
149
159
  }
150
160
  /** Everything between the doctor-reuse gate and the Ink render, packaged for reuse: `tickmarkr init`
151
- * runs the same editor (preset-first) without re-owning the assembly or the write funnel. `commit`
152
- * remains the single config actuator — it maps editor results to the command's exact result strings
153
- * 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. */
154
165
  export async function assembleFleetEditor(cwd, adapters, io, opts) {
155
166
  const globalDir = opts.globalDir;
156
- const { reuse, health: cached } = initDoctorReuse(cwd, opts.fresh ?? false);
167
+ const { reuse, health: cached } = initDoctorReuse(cwd, false);
157
168
  if (!reuse || !cached) {
158
169
  return {
159
- 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)",
160
171
  };
161
172
  }
162
173
  const rm = resolveRunMode(cwd, { globalDir });
163
174
  const cfg = rm.cfg;
164
- const initial = fleetEditableFromConfig(cfg);
165
- const editable = structuredClone(initial);
166
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);
167
192
  // OBS-508: the same catalog evidence doctor's drift overlay prints now rides each unclassified
168
193
  // row — it prefills the classify flow and feeds the bulk `s` stage. Suggestions stay advisory:
169
194
  // only the review-diff confirm writes, so "tickmarkr never applies" holds with less typing.
@@ -181,21 +206,45 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
181
206
  const unclassifiedRows = fleetUnclassifiedModels(cfg, health, adapters)
182
207
  .map((row) => ({ ...row, resolvedModel: resolvedCatalogModel(row.adapter, row.model) }));
183
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
+ };
184
226
  const modelGroups = adapters
185
227
  .filter((adapter) => health[adapter.id]?.installed)
186
228
  .map((adapter) => {
187
229
  const unclassified = unclassifiedRows.filter((row) => row.adapter === adapter.id);
188
230
  return {
189
231
  adapter: adapter.id,
232
+ channel: cfg.tiers[adapter.id]?.channel,
190
233
  rows: [
191
- ...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
+ }),
192
239
  ...unclassified
193
240
  .filter((row) => !editable.tiers[adapter.id]?.[row.model])
194
241
  .map((row) => {
195
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);
196
244
  return {
197
245
  model: row.model,
198
246
  detectedAt: row.detectedAt,
247
+ ...(evidence ? { evidence } : {}),
199
248
  ...(advisory.suggestion
200
249
  ? { suggestion: { tier: advisory.suggestion.tier, note: advisory.suggestion.provenanceNote } }
201
250
  : {}),
@@ -212,18 +261,38 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
212
261
  ]));
213
262
  const channels = discoverChannels(cfg, adapters, health);
214
263
  const profile = loadRoutingProfile(cwd, cfg, { preview: true });
215
- const previewCfg = (mode, map) => ({
216
- ...cfg,
217
- routing: { ...cfg.routing, map, floors: modeCfgs[mode].cfg.routing.floors },
218
- });
219
- 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) => {
220
287
  const tierCount = {};
221
288
  let subs = 0;
222
289
  let apiN = 0;
223
290
  let apiUsd = 0;
291
+ const cfgPreview = previewCfg(mode, map, deny);
292
+ const pool = previewPool(cfgPreview);
224
293
  for (const shape of SHAPES) {
225
294
  try {
226
- 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;
227
296
  tierCount[assignment.tier] = (tierCount[assignment.tier] ?? 0) + 1;
228
297
  if (assignment.channel === "sub")
229
298
  subs += 1;
@@ -278,13 +347,20 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
278
347
  ? { declaredAt: `routing.floors.${shape}` }
279
348
  : { declaredAt: "routing.mode" };
280
349
  };
281
- const projectedShapeRows = (mode, map) => {
350
+ const projectedShapeRows = (mode, map, deny) => {
351
+ const cfgPreview = previewCfg(mode, map, deny);
352
+ const pool = previewPool(cfgPreview);
282
353
  const values = SHAPES.map((shape) => {
283
354
  try {
284
- 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);
285
356
  const assignment = routed.assignment;
286
- const effective = `${assignment.adapter}:${assignment.model} (${assignment.channel}, ${assignment.tier}) ${costSignal(assignment, cfg.pricing)}`;
287
- 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");
288
364
  return {
289
365
  id: shape,
290
366
  effective,
@@ -292,7 +368,8 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
292
368
  };
293
369
  }
294
370
  catch (error) {
295
- 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;
296
373
  return {
297
374
  id: shape,
298
375
  effective: error.message,
@@ -303,12 +380,49 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
303
380
  });
304
381
  return projectFleetWhy(values, { repoRoot: cwd, globalDir });
305
382
  };
306
- const routedShapeRows = (mode, map) => projectedShapeRows(mode, map).map(({ id, label }) => ({ id, label }));
307
- const candidatesForShape = (shape, mode, map) => shapeCandidates(previewTask(shape), previewCfg(mode, map), channels, profile).map((candidate) => ({
308
- id: `${candidate.assignment.adapter}:${candidate.assignment.model}`,
309
- label: candidateRow(candidate, cfg.pricing),
310
- pin: { via: candidate.assignment.adapter, model: candidate.assignment.model },
311
- }));
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
+ };
312
426
  const preferUniverse = [
313
427
  ...new Set(channels.flatMap((channel) => [
314
428
  channel.adapter,
@@ -359,6 +473,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
359
473
  const write = {
360
474
  initial,
361
475
  edited: staged,
476
+ universe,
362
477
  ...(modeChanged ? { mode: state.selectedMode } : {}),
363
478
  ...(judgeChanged && judgeSeat ? { judge: judgeSeat } : {}),
364
479
  steering: { initial: initialSteering, edited: state.steering },
@@ -389,7 +504,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
389
504
  initialMode: rm.mode.mode,
390
505
  modeOptions: ROUTING_MODES.map((mode) => ({ id: mode, gloss: MODE_GLOSS[mode] })),
391
506
  initialMap: editable.map,
392
- modePreview: (mode, map) => [modeSpend(mode, map), ...floorPreview(mode)],
507
+ modePreview: (mode, map, deny) => [modeSpend(mode, map, deny), ...floorPreview(mode)],
393
508
  shapeRows: routedShapeRows,
394
509
  candidatesForShape,
395
510
  preferOptionsForShape: (_shape, current) => [
@@ -412,7 +527,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
412
527
  if (result.kind === "quit")
413
528
  return QUIT;
414
529
  if (result.kind === "refresh") {
415
- 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)";
416
531
  }
417
532
  if (result.kind === "no-changes")
418
533
  return "fleet: no overlay changes (empty diff)";
@@ -424,11 +539,29 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
424
539
  if (!write)
425
540
  throw new Error("fleet write reached confirmation without a staged overlay mutation");
426
541
  writeFleetOverlay(result.review.path, (prior) => renderFleetOverlayWrite(prior, write));
427
- 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`;
428
561
  };
429
562
  return {
430
563
  props,
431
564
  commit,
432
- renderWhy: () => renderFleetWhy(projectedShapeRows(rm.mode.mode, editable.map)),
565
+ renderWhy: () => renderFleetWhy(projectedShapeRows(rm.mode.mode, editable.map, { adapters: editable.denyAdapters, models: editable.denyModels })),
433
566
  };
434
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`";