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 +12 -9
- package/dist/adapters/model-lints.d.ts +1 -0
- package/dist/adapters/model-lints.js +29 -1
- package/dist/brand.d.ts +2 -0
- package/dist/brand.js +13 -0
- package/dist/cli/commands/doctor.js +34 -6
- package/dist/cli/commands/fleet-picker.js +1 -1
- package/dist/cli/commands/fleet.d.ts +4 -4
- package/dist/cli/commands/fleet.js +168 -35
- package/dist/cli/commands/init.js +6 -4
- package/dist/config/config.d.ts +34 -1
- package/dist/config/config.js +96 -5
- package/dist/config/fleet-overlay.d.ts +9 -2
- package/dist/config/fleet-overlay.js +176 -18
- package/dist/route/preference.d.ts +1 -1
- package/dist/route/preference.js +12 -2
- package/dist/route/router.js +31 -2
- package/dist/tui/ink/fleet-app.d.ts +50 -8
- package/dist/tui/ink/fleet-app.js +1210 -839
- package/dist/tui/ink/frame.d.ts +62 -0
- package/dist/tui/ink/frame.js +204 -0
- package/dist/tui/ink/init-app.d.ts +2 -1
- package/dist/tui/ink/init-app.js +28 -100
- package/package.json +1 -1
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
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
170
|
-
`partner-led`, `staff-led`) are
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
? ` ${
|
|
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
|
-
|
|
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 (
|
|
33
|
-
*
|
|
34
|
-
* and performs the one guarded
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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 (
|
|
152
|
-
*
|
|
153
|
-
* and performs the one guarded
|
|
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,
|
|
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
|
|
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]) =>
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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),
|
|
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),
|
|
355
|
+
const routed = route(previewTask(shape), cfgPreview, pool, profile, undefined, undefined, PREVIEW_EXPLORE);
|
|
285
356
|
const assignment = routed.assignment;
|
|
286
|
-
|
|
287
|
-
|
|
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 ||
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
279
|
-
//
|
|
280
|
-
//
|
|
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
|
|
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`";
|