tickmarkr 1.90.7 → 1.91.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.
- package/dist/adapters/catalog-remote.d.ts +11 -1
- package/dist/adapters/catalog-remote.js +169 -19
- package/dist/adapters/catalog.js +59 -0
- package/dist/adapters/model-lints.d.ts +36 -2
- package/dist/adapters/model-lints.js +151 -35
- package/dist/adapters/registry.js +32 -6
- package/dist/cli/commands/doctor.d.ts +15 -0
- package/dist/cli/commands/doctor.js +61 -14
- package/dist/cli/commands/fleet.js +31 -3
- package/dist/cli/commands/init.js +18 -16
- package/dist/compile/native.js +5 -4
- package/dist/config/config.js +5 -0
- package/dist/config/fleet-overlay.js +20 -4
- package/dist/gates/acceptance.js +13 -4
- package/dist/run/daemon.js +1 -1
- package/dist/tui/ink/components.d.ts +16 -1
- package/dist/tui/ink/components.js +16 -2
- package/dist/tui/ink/fleet-app.d.ts +8 -1
- package/dist/tui/ink/fleet-app.js +194 -55
- package/dist/tui/ink/init-app.js +2 -2
- package/package.json +1 -1
|
@@ -15,6 +15,29 @@ const DAY_MS = 86400000;
|
|
|
15
15
|
// 2026-08-12 (D-OBS-11: cursor's residual lint list was still mostly effort variants of configured bases).
|
|
16
16
|
const LINT_VARIANT_RE = /^auto$|-(fast|minimal|none|low|medium|high|xhigh|max|thinking)$/;
|
|
17
17
|
const LINT_CAP = 5;
|
|
18
|
+
/**
|
|
19
|
+
* Operator directive 2026-08-13 ("we should exclude all retired models"): the fleet models
|
|
20
|
+
* screen hides these classes BY DEFAULT — omp alone reports 218 ids, most of them dated
|
|
21
|
+
* snapshots, previews, and SKUs that can never carry a worker. Shape-based on purpose: a
|
|
22
|
+
* knowledge list of retired families rots, a suffix grammar does not. Hidden is never gone —
|
|
23
|
+
* the screen counts what it hid and one key reveals it, and CLASSIFIED models are never hidden
|
|
24
|
+
* regardless of shape (an operator who tiered a dated snapshot meant it).
|
|
25
|
+
*/
|
|
26
|
+
export function retiredModelReason(model) {
|
|
27
|
+
const bare = model.includes("/") ? model.slice(model.lastIndexOf("/") + 1) : model;
|
|
28
|
+
// vendor-dated pins: claude-3-5-sonnet-20241022, claude-opus-4-1-20250805, *-preview-10-2025
|
|
29
|
+
if (/-20\d{6}$/.test(bare))
|
|
30
|
+
return "dated snapshot";
|
|
31
|
+
if (/-preview(?:-\d{2}-20\d{2})?$/.test(bare))
|
|
32
|
+
return "preview";
|
|
33
|
+
// SKUs that cannot carry a worker seat: media, embedding, retrieval, and browsing surfaces
|
|
34
|
+
if (/(?:^|-)(?:embed(?:ding)?s?|tts|whisper|audio|image|vision|ocr|rerank|moderation|computer-use|deep-research)(?:-|$)/.test(bare))
|
|
35
|
+
return "non-worker";
|
|
36
|
+
// superseded generations still reported by CLIs long after retirement
|
|
37
|
+
if (/^(?:gemini-1\.|claude-3-|gpt-3\.|gpt-4-)/.test(bare))
|
|
38
|
+
return "legacy family";
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
18
41
|
const TTY_LINT_CAP = 3;
|
|
19
42
|
const DEFAULT_STATE_DIR = ".tickmarkr";
|
|
20
43
|
const doctorJsonRef = (stateDir) => ` — see ${stateDir}/doctor.json`;
|
|
@@ -23,33 +46,100 @@ const LEGACY_DOCTOR_SEED_PREFER_DISPOSITION = "auto-prefer is routing around it"
|
|
|
23
46
|
export const ttyVisual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
|
|
24
47
|
// ponytail: chars/4 token heuristic — good enough for advisory plan lint; no tokenizer dep.
|
|
25
48
|
const CHARS_PER_TOKEN = 4;
|
|
26
|
-
|
|
49
|
+
/** Bases that band FLEET-RELATIVELY, most-trusted first (2026-08-13 ranking-sites assessment §4.3). */
|
|
50
|
+
const RANKED_BASES = ["agentic-coding", "intelligence"];
|
|
51
|
+
/**
|
|
52
|
+
* D-2 (2026-08-13 ranking-sites assessment): the retired `index >= 65 → frontier` cut was calibrated
|
|
53
|
+
* against an older, higher-ceilinged AA index. After the v4.x rescale the best model in existence
|
|
54
|
+
* scored 63.05, so NOTHING could ever be frontier. An absolute cut against a vendor index that gets
|
|
55
|
+
* rescaled is the defect CLASS — the fix is a rank among the models we actually hold same-basis
|
|
56
|
+
* evidence for, not a new magic number. Three is the floor: thirds over two models is a coin toss.
|
|
57
|
+
*/
|
|
58
|
+
const MIN_RANKED_MODELS = 3;
|
|
59
|
+
/** Rank r of n, 0-based: top third → frontier, middle third → mid, bottom third → cheap. */
|
|
60
|
+
const bandOfRank = (rank, total) => rank * 3 < total ? "frontier" : rank * 3 < total * 2 ? "mid" : "cheap";
|
|
61
|
+
const basisScore = (evidence, basis) => basis === "agentic-coding" ? evidence.agenticCodingScore : evidence.intelligenceIndex;
|
|
27
62
|
const priceTier = (outputPerMtok) => outputPerMtok >= 12 ? "frontier" : outputPerMtok >= 2 ? "mid" : "cheap";
|
|
63
|
+
const round1 = (n) => Math.round(n * 10) / 10;
|
|
64
|
+
function catalogEvidenceFor(cfg, catalog, row) {
|
|
65
|
+
return resolveCatalogModel(catalog.catalog, {
|
|
66
|
+
// A tier vendor is NULLABLE (config.ts:64) — null means "declared as having none at tier level, so
|
|
67
|
+
// each model must declare its own". `resolveCatalogModel` takes an optional provider HINT
|
|
68
|
+
// (catalog-remote.ts:218), and "no hint" is `undefined` there, so null normalizes to undefined rather
|
|
69
|
+
// than travelling as a third state. Neither T17 nor T19 could see this seam: their files[] are
|
|
70
|
+
// disjoint, so no ordering edge was derived, they dispatched 7ms apart, and each worktree was cut
|
|
71
|
+
// before the other's change existed. Both gates passed truthfully; only the merged pair fails to
|
|
72
|
+
// compile (OBS-371).
|
|
73
|
+
provider: cfg.tiers[row.adapter]?.vendor ?? undefined,
|
|
74
|
+
model: row.model,
|
|
75
|
+
...(row.resolvedModel ? { resolvedModel: row.resolvedModel } : {}),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The ranking universe: configured tier models ∪ the rows under advisory at THIS call site,
|
|
80
|
+
* restricted per basis to those that resolve it. Build it once per call — a band that depended on
|
|
81
|
+
* which adapter happened to be iterated first would not be a band. `resolvedModel` is the SAME
|
|
82
|
+
* resolver the call site hands its advisory rows: a configured claude alias (`opus`) is absent from
|
|
83
|
+
* models.dev under that spelling, so without it the fleet's own frontier models silently drop out
|
|
84
|
+
* of the universe they are supposed to anchor.
|
|
85
|
+
*/
|
|
86
|
+
export function catalogTierRanking(cfg, catalog, rows = [], resolvedModel) {
|
|
87
|
+
// Orca import #1: the vendored catalog is a shipped default (`catalogOrigin: spec` in orca's terms),
|
|
88
|
+
// never a fetched observation — it may not seed a ranking any more than it may seed a suggestion.
|
|
89
|
+
if (catalog.source === "vendored")
|
|
90
|
+
return [];
|
|
91
|
+
const configured = Object.entries(cfg.tiers).flatMap(([adapter, entry]) => Object.keys(entry.models).map((model) => ({ adapter, model, resolvedModel: resolvedModel?.(adapter, model) })));
|
|
92
|
+
// The resolver supplies the matched `<providerKey>/<recordId>`: caller spellings never define
|
|
93
|
+
// identity, aliases of one record collapse, and equal bare ids under distinct providers do not.
|
|
94
|
+
const universe = new Map();
|
|
95
|
+
for (const row of [...configured, ...rows]) {
|
|
96
|
+
const evidence = catalogEvidenceFor(cfg, catalog, row);
|
|
97
|
+
if (evidence)
|
|
98
|
+
universe.set(evidence.catalogId, evidence);
|
|
99
|
+
}
|
|
100
|
+
const members = [...universe.values()];
|
|
101
|
+
return RANKED_BASES.flatMap((basis) => {
|
|
102
|
+
const scores = members
|
|
103
|
+
.map((member) => basisScore(member, basis))
|
|
104
|
+
.filter((score) => score !== undefined)
|
|
105
|
+
.sort((a, b) => b - a);
|
|
106
|
+
return scores.length >= MIN_RANKED_MODELS ? [{ basis, scores }] : [];
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/** First basis that both clears the floor AND can place this model; ties share the better band. */
|
|
110
|
+
function rankedSuggestion(ranking, evidence) {
|
|
111
|
+
for (const { basis, scores } of ranking) {
|
|
112
|
+
const score = basisScore(evidence, basis);
|
|
113
|
+
if (score === undefined)
|
|
114
|
+
continue;
|
|
115
|
+
const rank = scores.filter((other) => other > score).length;
|
|
116
|
+
// Every suggestion names its evidence source AND its date: an AA band that omits the index
|
|
117
|
+
// version is exactly the silent-rescale defect D-2 shipped to everyone.
|
|
118
|
+
const source = basis === "agentic-coding"
|
|
119
|
+
? `LiveBench Agentic Coding ${round1(score)} (LiveBench table ${evidence.evidenceDate ?? "date not reported"})`
|
|
120
|
+
: `Artificial Analysis Intelligence Index ${round1(score)} (intelligence index version ${evidence.intelligenceIndexVersion ?? "not reported"})`;
|
|
121
|
+
return { basis, tier: bandOfRank(rank, scores.length), basisNote: `fleet-relative rank ${rank + 1}/${scores.length} by ${source}` };
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
28
125
|
function catalogEvidenceNote(evidence, catalog) {
|
|
29
126
|
const cost = evidence.inputCostPerMtok !== undefined && evidence.outputCostPerMtok !== undefined
|
|
30
127
|
? `$${evidence.inputCostPerMtok}/$${evidence.outputCostPerMtok} per Mtok`
|
|
31
128
|
: "not reported";
|
|
32
129
|
const features = evidence.features.length ? evidence.features.join(",") : "none reported";
|
|
130
|
+
const agentic = evidence.agenticCodingScore !== undefined
|
|
131
|
+
? `; LiveBench Agentic Coding=${round1(evidence.agenticCodingScore)}${evidence.evidenceDate ? ` (table ${evidence.evidenceDate})` : ""}`
|
|
132
|
+
: "";
|
|
33
133
|
const intelligence = evidence.intelligenceIndex !== undefined
|
|
34
134
|
? `; Artificial Analysis Intelligence Index=${evidence.intelligenceIndex}${evidence.intelligenceIndexVersion ? ` (version ${evidence.intelligenceIndexVersion})` : ""}`
|
|
35
135
|
: "";
|
|
36
136
|
const freshness = catalog.stale ? `${catalog.source} stale cache` : catalog.source;
|
|
37
|
-
return `models.dev id=${evidence.modelId}; cost=${cost}; context=${evidence.contextWindow ?? "not reported"}; features=${features}${intelligence}; catalog=${freshness}; fetchedAt=${catalog.catalog.fetchedAt}`;
|
|
137
|
+
return `models.dev id=${evidence.modelId}; cost=${cost}; context=${evidence.contextWindow ?? "not reported"}; features=${features}${agentic}${intelligence}; catalog=${freshness}; fetchedAt=${catalog.catalog.fetchedAt}`;
|
|
38
138
|
}
|
|
39
|
-
export function catalogModelAdvisory(cfg, catalog, adapter, model, resolvedModel) {
|
|
139
|
+
export function catalogModelAdvisory(cfg, catalog, adapter, model, resolvedModel, ranking) {
|
|
40
140
|
const entry = cfg.tiers[adapter];
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
// each model must declare its own". `resolveCatalogModel` takes an optional provider HINT
|
|
44
|
-
// (catalog-remote.ts:218), and "no hint" is `undefined` there, so null normalizes to undefined rather
|
|
45
|
-
// than travelling as a third state. Neither T17 nor T19 could see this seam: their files[] are
|
|
46
|
-
// disjoint, so no ordering edge was derived, they dispatched 7ms apart, and each worktree was cut
|
|
47
|
-
// before the other's change existed. Both gates passed truthfully; only the merged pair fails to
|
|
48
|
-
// compile (OBS-371).
|
|
49
|
-
provider: entry?.vendor ?? undefined,
|
|
50
|
-
model,
|
|
51
|
-
...(resolvedModel ? { resolvedModel } : {}),
|
|
52
|
-
});
|
|
141
|
+
const row = { adapter, model, resolvedModel };
|
|
142
|
+
const evidence = catalogEvidenceFor(cfg, catalog, row);
|
|
53
143
|
if (!evidence) {
|
|
54
144
|
return {
|
|
55
145
|
coverage: "uncovered",
|
|
@@ -57,18 +147,28 @@ export function catalogModelAdvisory(cfg, catalog, adapter, model, resolvedModel
|
|
|
57
147
|
};
|
|
58
148
|
}
|
|
59
149
|
const note = catalogEvidenceNote(evidence, catalog);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
150
|
+
// Orca import #1, read site: a shipped default must never launder into a fleet-reported suggestion.
|
|
151
|
+
if (catalog.source === "vendored") {
|
|
152
|
+
return {
|
|
153
|
+
coverage: "covered",
|
|
154
|
+
evidence,
|
|
155
|
+
display: `${model} → ${note}; the vendored catalog is a shipped default, not fetched evidence — no tier suggestion (run tickmarkr doctor --refresh-catalog)`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const ranked = rankedSuggestion(ranking ?? catalogTierRanking(cfg, catalog, [row]), evidence);
|
|
159
|
+
const basis = ranked
|
|
160
|
+
?? (entry?.channel === "api" && evidence.outputCostPerMtok !== undefined
|
|
161
|
+
// composer-2.5's permanent home: it has zero public benchmark evidence anywhere, so price is
|
|
162
|
+
// the only basis it will ever have. Unchanged absolute thresholds — price is not rescaled.
|
|
163
|
+
? { basis: "price", tier: priceTier(evidence.outputCostPerMtok), basisNote: "price-derived fallback — no fleet-relative ranking basis covers this model" }
|
|
164
|
+
: undefined);
|
|
65
165
|
if (!basis) {
|
|
66
166
|
const reason = entry?.channel === "sub" && evidence.outputCostPerMtok !== undefined
|
|
67
167
|
? "subscription billing; no price-derived suggestion"
|
|
68
168
|
: "no tier suggestion from available evidence";
|
|
69
169
|
return { coverage: "covered", evidence, display: `${model} → ${note}; ${reason}` };
|
|
70
170
|
}
|
|
71
|
-
const provenanceNote = `SUGGESTED ${basis.tier} (${basis.basis} inference, not a measurement) — ${note}; operator confirmation required`;
|
|
171
|
+
const provenanceNote = `SUGGESTED ${basis.tier} (${basis.basis} inference, not a measurement) — ${basis.basisNote}; ${note}; operator confirmation required`;
|
|
72
172
|
return {
|
|
73
173
|
coverage: "covered",
|
|
74
174
|
evidence,
|
|
@@ -256,7 +356,12 @@ export function modelLints(cfg, health, adapters, opts) {
|
|
|
256
356
|
for (const adapter of adapters) {
|
|
257
357
|
const id = adapter.id;
|
|
258
358
|
if (!adapter.listModels) {
|
|
259
|
-
|
|
359
|
+
// v1.90 / OBS-504: the seeds-stamped wording presumes a seeded tier table. agy ships routable
|
|
360
|
+
// but UNCLASSIFIED (no listModels, no seed models) — for that shape the honest sentence names
|
|
361
|
+
// the classification debt, not a seed stamp with nothing behind it.
|
|
362
|
+
lints.push(Object.keys(cfg.tiers[id]?.models ?? {}).length > 0
|
|
363
|
+
? `${id}: no model-list surface — seeds stamped ${SEED_STAMPED}; verify manually`
|
|
364
|
+
: `${id}: no model-list surface and no configured models — classify per benchmark policy to open channels`);
|
|
260
365
|
continue;
|
|
261
366
|
}
|
|
262
367
|
const h = health[id];
|
|
@@ -312,25 +417,20 @@ export function formatModelAuthLine(excluded, tty, stateDir = DEFAULT_STATE_DIR)
|
|
|
312
417
|
// `<id>: null` tombstones (deepMerge deletes the key). Pure function: no fs, no routing contact.
|
|
313
418
|
// Returns "" when no adapter has a delta. Mirrors modelLints' per-adapter guards exactly.
|
|
314
419
|
export function suggestOverlay(cfg, health, adapters, stateDir = DEFAULT_STATE_DIR, opts = {}) {
|
|
315
|
-
|
|
316
|
-
|
|
420
|
+
// Pass 1 — decide WHAT each adapter drifts by. Pass 2 renders, after every addition this call
|
|
421
|
+
// will print has joined one ranking universe: a fleet-relative band must not depend on which
|
|
422
|
+
// adapter happened to be iterated first.
|
|
423
|
+
const drifts = adapters.flatMap((adapter) => {
|
|
317
424
|
const id = adapter.id;
|
|
318
425
|
if (!adapter.listModels)
|
|
319
|
-
|
|
426
|
+
return []; // no list surface → nothing to diff (mirror modelLints)
|
|
320
427
|
const h = health[id];
|
|
321
428
|
const detected = h?.models ?? [];
|
|
322
429
|
if (detected.length === 0)
|
|
323
|
-
|
|
430
|
+
return []; // no detection data → don't guess a delta
|
|
324
431
|
const configured = Object.keys(cfg.tiers[id]?.models ?? {});
|
|
325
|
-
const date = h?.modelsDetectedAt?.split("T")[0]; // best-effort day stamp
|
|
326
|
-
const detNote = date ? ` (detected ${date})` : "";
|
|
327
|
-
const lines = [];
|
|
328
432
|
// Tombstones: configured ids the CLI no longer reports. Ids are operator-authored (from cfg) → MODEL_ID_RE only.
|
|
329
|
-
|
|
330
|
-
if (detected.includes(model) || !MODEL_ID_RE.test(model))
|
|
331
|
-
continue;
|
|
332
|
-
lines.push(` ${model}: null # tombstone: ${id} no longer reports this id${detNote}${referenceWarning(cfg, id, model)}`);
|
|
333
|
-
}
|
|
433
|
+
const tombstones = configured.filter((model) => !detected.includes(model) && MODEL_ID_RE.test(model));
|
|
334
434
|
// Additions: detected ids not in cfg. WHOLE line commented, no tier (MODEL-06). Ids come from an external
|
|
335
435
|
// CLI → MODEL_ID_RE (defense-in-depth, T-21-01) + the variant filter (cursor's ~193 parameterized ids).
|
|
336
436
|
// RELATIONAL gate (no capability judgment — "looks like an embedding model" is auto-tiering's cousin, the
|
|
@@ -344,6 +444,7 @@ export function suggestOverlay(cfg, health, adapters, stateDir = DEFAULT_STATE_D
|
|
|
344
444
|
// not silently. ponytail: not worth a per-adapter heuristic to quiet cursor at the price of codex signal.
|
|
345
445
|
const cfgPrefixes = new Set(configured.map(providerPrefix));
|
|
346
446
|
const cfgCanon = new Set(configured.map(canonical));
|
|
447
|
+
const additions = [];
|
|
347
448
|
let omitted = 0;
|
|
348
449
|
for (const model of detected) {
|
|
349
450
|
if (configured.includes(model) || !MODEL_ID_RE.test(model) || LINT_VARIANT_RE.test(model))
|
|
@@ -352,8 +453,23 @@ export function suggestOverlay(cfg, health, adapters, stateDir = DEFAULT_STATE_D
|
|
|
352
453
|
omitted++;
|
|
353
454
|
continue;
|
|
354
455
|
}
|
|
456
|
+
additions.push(model);
|
|
457
|
+
}
|
|
458
|
+
return [{ id, date: h?.modelsDetectedAt?.split("T")[0], tombstones, additions, omitted }]; // date: best-effort day stamp
|
|
459
|
+
});
|
|
460
|
+
const ranking = opts.catalog
|
|
461
|
+
? catalogTierRanking(cfg, opts.catalog, drifts.flatMap(({ id, additions }) => additions.map((model) => ({ adapter: id, model, resolvedModel: opts.resolvedModel?.(id, model) }))), opts.resolvedModel)
|
|
462
|
+
: [];
|
|
463
|
+
const blocks = [];
|
|
464
|
+
for (const { id, date, tombstones, additions, omitted } of drifts) {
|
|
465
|
+
const detNote = date ? ` (detected ${date})` : "";
|
|
466
|
+
const lines = [];
|
|
467
|
+
for (const model of tombstones) {
|
|
468
|
+
lines.push(` ${model}: null # tombstone: ${id} no longer reports this id${detNote}${referenceWarning(cfg, id, model)}`);
|
|
469
|
+
}
|
|
470
|
+
for (const model of additions) {
|
|
355
471
|
const advisory = opts.catalog
|
|
356
|
-
? catalogModelAdvisory(cfg, opts.catalog, id, model, opts.resolvedModel?.(id, model))
|
|
472
|
+
? catalogModelAdvisory(cfg, opts.catalog, id, model, opts.resolvedModel?.(id, model), ranking)
|
|
357
473
|
: undefined;
|
|
358
474
|
const guidance = advisory?.suggestion
|
|
359
475
|
? `provenance note (operator confirmation required): ${advisory.suggestion.provenanceNote}; choose a tier, then uncomment`
|
|
@@ -65,8 +65,21 @@ export function probeVersionShell(bin, cwd = process.cwd()) {
|
|
|
65
65
|
if (!resolved)
|
|
66
66
|
return { installed: false, authed: false, models: [] };
|
|
67
67
|
const r = shellSpawnSync(`${shq(resolved)} --version`, cwd);
|
|
68
|
-
if (r.code !== 0)
|
|
69
|
-
|
|
68
|
+
if (r.code !== 0) {
|
|
69
|
+
// OBS-503: a resolved binary whose --version CRASHES under the worker shell is not "not
|
|
70
|
+
// installed" — a stale node on the login-shell PATH (bash -lc re-derives PATH) crashes every
|
|
71
|
+
// `#!/usr/bin/env node` CLI at dispatch while the operator's interactive shell runs it fine.
|
|
72
|
+
// installed stays false (dispatch runs through this same shell and would crash identically —
|
|
73
|
+
// fail closed), but the note names the real failure so doctor never prints a lie.
|
|
74
|
+
const reason = `${r.stderr}\n${r.stdout}`.replace(/\s+/g, " ").trim();
|
|
75
|
+
return {
|
|
76
|
+
installed: false,
|
|
77
|
+
authed: false,
|
|
78
|
+
models: [],
|
|
79
|
+
binaryPaths: all,
|
|
80
|
+
note: `found at ${resolved} but \`--version\` exited ${r.code} under the worker shell (bash -lc)${reason ? ` — ${reasonTail(reason)}` : ""}`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
70
83
|
return {
|
|
71
84
|
installed: true,
|
|
72
85
|
authed: true,
|
|
@@ -241,7 +254,13 @@ export function parseDeclaredModels(raw, parser, path, field) {
|
|
|
241
254
|
else if (parser === "pi-table") {
|
|
242
255
|
const lines = raw.trim().split("\n");
|
|
243
256
|
const header = lines.findIndex((line) => /^provider\s+model\b/i.test(line.trim()));
|
|
244
|
-
|
|
257
|
+
// OBS-506: a missing header previously fell through to a REASONLESS empty result, so the
|
|
258
|
+
// "never silently" warning below the parser could not fire — prime-agent's stderr-routed
|
|
259
|
+
// table produced a silent zero for a whole doctor run. An absent table names itself.
|
|
260
|
+
if (header === -1) {
|
|
261
|
+
return { models: [], reason: `no \`provider model …\` table header in ${raw.trim() ? `${lines.length} line(s) of output` : "empty output"}` };
|
|
262
|
+
}
|
|
263
|
+
values = lines.slice(header + 1).map((line) => {
|
|
245
264
|
const [provider, model] = line.trim().split(/\s+/);
|
|
246
265
|
return provider && model ? `${provider}/${model}` : "";
|
|
247
266
|
});
|
|
@@ -301,7 +320,11 @@ function declarativeAdapter(entry) {
|
|
|
301
320
|
const result = shellSpawnSync(cmd, process.cwd(), 15000);
|
|
302
321
|
if (result.code !== 0)
|
|
303
322
|
return [];
|
|
304
|
-
const { models, reason } = parseDeclaredModels(
|
|
323
|
+
const { models, reason } = parseDeclaredModels(
|
|
324
|
+
// OBS-506: prime-agent prints its model table to STDERR (stdout 0 bytes, live-verified
|
|
325
|
+
// 2026-08-13) — the same stream convention probeVersionShell already tolerates for
|
|
326
|
+
// version banners. Fall back only when stdout is EMPTY, never merge the streams.
|
|
327
|
+
result.stdout.trim() ? result.stdout : result.stderr, drive.listModels.parser, drive.listModels.path, drive.listModels.field);
|
|
305
328
|
// Listing stays advisory (fail-open to []), but never silently: an empty projection names why.
|
|
306
329
|
if (models.length === 0 && reason)
|
|
307
330
|
console.warn(`tickmarkr: ${entry.id} listed no models — ${reason}`);
|
|
@@ -344,8 +367,10 @@ export async function probeAll(adapters, opts = {}) {
|
|
|
344
367
|
if (bin) {
|
|
345
368
|
const shell = probeVersionShell(bin, cwd);
|
|
346
369
|
h = { ...h, installed: shell.installed, version: shell.version ?? h.version };
|
|
370
|
+
// OBS-503: the shell probe's note replaces the adapter probe's own — keeping a bare-spawn
|
|
371
|
+
// success note ("auth verified via …") next to installed:false stores a self-contradiction.
|
|
347
372
|
if (!shell.installed)
|
|
348
|
-
h = { ...h, installed: false, authed: false };
|
|
373
|
+
h = { ...h, installed: false, authed: false, note: shell.note };
|
|
349
374
|
}
|
|
350
375
|
out[a.id] = h;
|
|
351
376
|
}));
|
|
@@ -618,7 +643,8 @@ export function initDoctorReuse(repoRoot, fresh) {
|
|
|
618
643
|
export function formatDoctorReport(cwd, cfg, health, adapters, opts = {}) {
|
|
619
644
|
const rows = adapters.map((a) => {
|
|
620
645
|
const h = health[a.id];
|
|
621
|
-
|
|
646
|
+
// OBS-503: a crash-on-version verdict carries its reason in note — print it instead of the lie.
|
|
647
|
+
const state = !h?.installed ? (h?.note ?? "not installed") : `${h.version ?? "installed"}${h.note ? ` (${h.note})` : ""}`;
|
|
622
648
|
return ` ${h?.installed ? "✓" : "✗"} ${a.id.padEnd(14)} ${state}`;
|
|
623
649
|
});
|
|
624
650
|
rows.push(` ${HerdrDriver.available() ? "✓" : "✗"} herdr ${HerdrDriver.available() ? "driver available (HERDR_ENV=1)" : "not detected — subprocess driver will be used"}`);
|
|
@@ -2,12 +2,19 @@ import { type ClaudeAlias } from "../../adapters/claude-code.js";
|
|
|
2
2
|
import type { WorkerAdapter } from "../../adapters/types.js";
|
|
3
3
|
import { type KimiDoctorTurnResult } from "../../adapters/kimi.js";
|
|
4
4
|
import { type CatalogReadResult } from "../../adapters/catalog-remote.js";
|
|
5
|
+
/** Where a newer `table_<date>.csv` is discovered — the deployed site builds filenames by
|
|
6
|
+
* concatenation and publishes no index, so the release listing is the only enumerable surface. */
|
|
7
|
+
export declare const LIVEBENCH_RELEASES_URL = "https://api.github.com/repos/LiveBench/livebench.github.io/contents/public";
|
|
8
|
+
export declare const LIVEBENCH_TABLE_MAX_AGE_DAYS = 90;
|
|
5
9
|
export type DoctorOpts = {
|
|
6
10
|
banner?: boolean;
|
|
7
11
|
kimiTurnProbe?: (cwd: string) => Promise<KimiDoctorTurnResult>;
|
|
8
12
|
resolveClaudeAliasIdentity?: (cwd: string, alias: ClaudeAlias) => string | undefined;
|
|
9
13
|
catalog?: CatalogReadResult;
|
|
10
14
|
catalogNow?: () => Date;
|
|
15
|
+
/** init's between-acts surface: status rows only — the model matrix and inline drift stay
|
|
16
|
+
* behind `tickmarkr doctor` (files are still written; only the RETURNED string shrinks). */
|
|
17
|
+
compact?: boolean;
|
|
11
18
|
};
|
|
12
19
|
export declare function runnerIgnoreFinding(cwd: string): {
|
|
13
20
|
verdict: "pass" | "warn";
|
|
@@ -55,4 +62,12 @@ export declare function selfShadowFinding(ownVersion: string, cwd?: string, reso
|
|
|
55
62
|
verdict: "pass" | "fail";
|
|
56
63
|
detail: string;
|
|
57
64
|
} | undefined;
|
|
65
|
+
/**
|
|
66
|
+
* §4.4: `LIVEBENCH_TABLE_DATE` is a hand-bumped constant, and a constant nobody lints is a stale
|
|
67
|
+
* constant — the pinned table ages silently while every tier suggestion keeps citing it. Warns past
|
|
68
|
+
* 90 days, naming the pinned date and the listing where a newer table is discovered.
|
|
69
|
+
* ponytail: a LINT, never a fetch — doctor stays cache-only (T17); the operator (or RELEASING.md's
|
|
70
|
+
* release-time check) reads the listing and bumps the constant.
|
|
71
|
+
*/
|
|
72
|
+
export declare function liveBenchStalenessFinding(now: Date): string | undefined;
|
|
58
73
|
export declare function doctor(_argv: string[], cwd?: string, adapters?: WorkerAdapter[], opts?: DoctorOpts): Promise<string>;
|
|
@@ -8,12 +8,16 @@ import { allAdapters, binaryShadowWarnings, detectCandidateClis, flagDriftWarnin
|
|
|
8
8
|
import { CLAUDE_ALIAS_IDENTITY_STAMPS, claudeCode, resolveClaudeAliasIdentity } from "../../adapters/claude-code.js";
|
|
9
9
|
import { BANNER, 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 });
|
|
@@ -272,7 +290,8 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
|
|
|
272
290
|
writeDoctor(cwd, health);
|
|
273
291
|
const rows = adapters.map((a) => {
|
|
274
292
|
const h = health[a.id];
|
|
275
|
-
|
|
293
|
+
// OBS-503: a crash-on-version verdict carries its reason in note — print it instead of the lie.
|
|
294
|
+
const state = !h.installed ? (h.note ?? "not installed") : `${h.version ?? "installed"}${h.note ? ` (${h.note})` : ""}`;
|
|
276
295
|
const healthy = h.installed && (a.id !== kimi.id || h.authed);
|
|
277
296
|
return alignedStatusRow(healthy ? "pass" : "fail", a.id, state);
|
|
278
297
|
});
|
|
@@ -340,22 +359,29 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
|
|
|
340
359
|
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
360
|
}
|
|
342
361
|
}
|
|
343
|
-
|
|
362
|
+
// OBS-505 residue: advisory lints collect apart from the capability/trust rows so the compact
|
|
363
|
+
// init journey can elide them behind the doctor pointer (one measured lint line ran 282 chars —
|
|
364
|
+
// pure wrap noise between two TUIs). Full doctor output is byte-identical: they rejoin in order.
|
|
365
|
+
const lintRows = [];
|
|
366
|
+
const liveBenchStale = liveBenchStalenessFinding(opts.catalogNow?.() ?? new Date());
|
|
367
|
+
if (liveBenchStale)
|
|
368
|
+
lintRows.push(attentionRow(liveBenchStale));
|
|
369
|
+
lintRows.push(...modelLints(cfg, health, adapters, { tty: ttyVisual(), stateDir: stateDirName(cwd), overlayPreferShapes: overlayPreferShapes(cwd) }).map(attentionRow));
|
|
344
370
|
const excluded = excludedChannels(cfg, adapters, health);
|
|
345
371
|
if (excluded.length)
|
|
346
|
-
|
|
347
|
-
|
|
372
|
+
lintRows.push(attentionRow(exclusionLine(excluded)));
|
|
373
|
+
lintRows.push(...denyPreferCollisions(cfg).map((c) => attentionRow(denyPreferCollisionLine(c))));
|
|
348
374
|
const aliasExcluded = modelAliasExclusions(cfg, adapters, health);
|
|
349
375
|
if (aliasExcluded.length)
|
|
350
|
-
|
|
351
|
-
|
|
376
|
+
lintRows.push(attentionRow(modelAliasLine(aliasExcluded)));
|
|
377
|
+
lintRows.push(...binaryShadowWarnings(adapters, health, cwd).map(attentionRow));
|
|
352
378
|
// HYG-07(a): doctor just probed fresh (probeAll above), so servability attribution is current by construction.
|
|
353
379
|
const servable = servableExclusions(cfg, adapters, health);
|
|
354
380
|
if (servable.length)
|
|
355
|
-
|
|
381
|
+
lintRows.push(attentionRow(servabilityLine(servable)));
|
|
356
382
|
// v1.65 T3: hardcoded-flag drift — advisory warn rows only. Runs AFTER writeDoctor so the verdicts
|
|
357
383
|
// can never leak into doctor.json, and discoverChannels/routing never read them.
|
|
358
|
-
|
|
384
|
+
lintRows.push(...flagDriftWarnings(adapters, health).map(attentionRow));
|
|
359
385
|
// OBS-145: resolved-identity drift is the same class of display-only doctor warning. Stamps live
|
|
360
386
|
// beside the alias-owning adapter; the comparison runs only for configured floating aliases and
|
|
361
387
|
// never enters health/doctor.json, config, channel discovery, learned profiles, or route().
|
|
@@ -374,7 +400,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
|
|
|
374
400
|
continue; // advisory source failure is unknown, never a doctor failure
|
|
375
401
|
}
|
|
376
402
|
if (resolvedIdentity && resolvedIdentity !== stampedIdentity) {
|
|
377
|
-
|
|
403
|
+
lintRows.push(attentionRow(`resolved-identity drift: claude-code:${alias} resolved to ${resolvedIdentity}, stamped identity ${stampedIdentity} — reclassify per benchmark policy (advisory — routing unchanged)`));
|
|
378
404
|
}
|
|
379
405
|
}
|
|
380
406
|
}
|
|
@@ -413,13 +439,23 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
|
|
|
413
439
|
const trunc = (s, n) => (s.length <= n ? s : `${s.slice(0, n - 1)}…`);
|
|
414
440
|
const dateOf = (iso) => iso.slice(0, 10);
|
|
415
441
|
const showWindows = hasWindowsConfig(cfg);
|
|
442
|
+
const unclassifiedByAdapter = new Map(adapters.flatMap((a) => {
|
|
443
|
+
const h = health[a.id];
|
|
444
|
+
if (!h?.installed)
|
|
445
|
+
return [];
|
|
446
|
+
const classified = cfg.tiers[a.id]?.models ?? {};
|
|
447
|
+
return [[a.id, (h.models ?? []).filter((m) => !(m in classified))]];
|
|
448
|
+
}));
|
|
449
|
+
// Every model under advisory in THIS report bands against one universe, built before any row
|
|
450
|
+
// renders — otherwise an adapter's band would depend on where it sits in the iteration.
|
|
451
|
+
const catalogRanking = catalogTierRanking(cfg, catalog, [...unclassifiedByAdapter].flatMap(([adapter, models]) => models.map((model) => ({ adapter, model, resolvedModel: resolvedCatalogModel(adapter, model) }))), resolvedCatalogModel);
|
|
416
452
|
const modelStatus = adapters.flatMap((a) => {
|
|
417
453
|
const h = health[a.id];
|
|
418
454
|
if (!h?.installed)
|
|
419
455
|
return [];
|
|
420
456
|
const classified = cfg.tiers[a.id]?.models ?? {};
|
|
421
457
|
const models = Object.keys(classified);
|
|
422
|
-
const unclassified = (
|
|
458
|
+
const unclassified = unclassifiedByAdapter.get(a.id) ?? [];
|
|
423
459
|
if (!models.length && !unclassified.length)
|
|
424
460
|
return [];
|
|
425
461
|
const w = Math.max(8, ...models.map((m) => m.length));
|
|
@@ -443,7 +479,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
|
|
|
443
479
|
// Keep the historical count as an index, but it is no longer the whole report: every model gets
|
|
444
480
|
// a cache-backed evidence or explicit uncovered line. Neither branch changes cfg, health, or route().
|
|
445
481
|
rows.push(` ${dim(`(${unclassified.length} more listed, unclassified)`)}`);
|
|
446
|
-
const advisories = unclassified.map((model) => catalogModelAdvisory(cfg, catalog, a.id, model, resolvedCatalogModel(a.id, model)));
|
|
482
|
+
const advisories = unclassified.map((model) => catalogModelAdvisory(cfg, catalog, a.id, model, resolvedCatalogModel(a.id, model), catalogRanking));
|
|
447
483
|
// Q128s exhibit #4 / operator directive 2026-08-12: ~600 per-model advisory rows buried the six
|
|
448
484
|
// decision-grade flags on a real machine. Default view keeps ONLY rows that ask the operator for a
|
|
449
485
|
// decision (tier suggestions); the no-decision bulk (uncovered + covered evidence-only) compresses
|
|
@@ -471,5 +507,16 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
|
|
|
471
507
|
const header = visual()
|
|
472
508
|
? `${statusRow("pass", `${title("tickmarkr doctor")} ${legend("· capability matrix")}`)}\n${rule()}`
|
|
473
509
|
: "tickmarkr doctor — capability matrix:";
|
|
474
|
-
|
|
510
|
+
if (opts.compact) {
|
|
511
|
+
// Operator field report 2026-08-13: the full matrix mid-init-journey is clutter between two
|
|
512
|
+
// TUIs. Every file side effect above already happened; the pointer line is the contract.
|
|
513
|
+
// Advisory lints elide with the matrix (OBS-505 residue) — capability, trust, and fail-closed
|
|
514
|
+
// judge/consult rows stay; the counted pointer names what moved behind `tickmarkr doctor`.
|
|
515
|
+
const elided = [
|
|
516
|
+
...(lintRows.length ? [`${lintRows.length} advisory lint${lintRows.length === 1 ? "" : "s"}`] : []),
|
|
517
|
+
"model matrix",
|
|
518
|
+
].join(" and ");
|
|
519
|
+
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
|
+
}
|
|
521
|
+
return `${header}\n${[...rows, ...lintRows].join("\n")}${modelSummary}${drift}\nwrote ${stateDirName(cwd)}/doctor.json`;
|
|
475
522
|
}
|
|
@@ -2,7 +2,9 @@ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileS
|
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
4
|
import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse } from "../../adapters/registry.js";
|
|
5
|
-
import { fleetUnclassifiedModels } from "../../adapters/model-lints.js";
|
|
5
|
+
import { catalogModelAdvisory, catalogTierRanking, 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";
|
|
@@ -162,17 +164,43 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
162
164
|
const initial = fleetEditableFromConfig(cfg);
|
|
163
165
|
const editable = structuredClone(initial);
|
|
164
166
|
const health = cached;
|
|
167
|
+
// OBS-508: the same catalog evidence doctor's drift overlay prints now rides each unclassified
|
|
168
|
+
// row — it prefills the classify flow and feeds the bulk `s` stage. Suggestions stay advisory:
|
|
169
|
+
// only the review-diff confirm writes, so "tickmarkr never applies" holds with less typing.
|
|
170
|
+
const catalog = readCachedCatalog(cwd);
|
|
171
|
+
// The same alias→identity resolution doctor hands its advisory rows: models.dev has never heard of
|
|
172
|
+
// `opus`, so without it the fleet's own frontier models drop out of the universe they are supposed
|
|
173
|
+
// to anchor and fleet bands a different set than doctor for one fleet. The stored identity wins;
|
|
174
|
+
// the stamped one backs it up. No probe — fleet never re-probes, doctor is the sensor (and doctor
|
|
175
|
+
// is the seat that lints a stamp the live identity has drifted away from).
|
|
176
|
+
const resolvedCatalogModel = (adapter, model) => adapter !== "claude-code" || !(model in CLAUDE_ALIAS_IDENTITY_STAMPS)
|
|
177
|
+
? undefined
|
|
178
|
+
: readClaudeAliasIdentity(cwd, model) ?? CLAUDE_ALIAS_IDENTITY_STAMPS[model];
|
|
179
|
+
// One ranking universe for the whole screen: every unclassified row bands fleet-relatively
|
|
180
|
+
// against the same set, so a suggestion never depends on which adapter group renders first.
|
|
181
|
+
const unclassifiedRows = fleetUnclassifiedModels(cfg, health, adapters)
|
|
182
|
+
.map((row) => ({ ...row, resolvedModel: resolvedCatalogModel(row.adapter, row.model) }));
|
|
183
|
+
const catalogRanking = catalogTierRanking(cfg, catalog, unclassifiedRows, resolvedCatalogModel);
|
|
165
184
|
const modelGroups = adapters
|
|
166
185
|
.filter((adapter) => health[adapter.id]?.installed)
|
|
167
186
|
.map((adapter) => {
|
|
168
|
-
const unclassified =
|
|
187
|
+
const unclassified = unclassifiedRows.filter((row) => row.adapter === adapter.id);
|
|
169
188
|
return {
|
|
170
189
|
adapter: adapter.id,
|
|
171
190
|
rows: [
|
|
172
191
|
...Object.entries(editable.tiers[adapter.id] ?? {}).map(([model, value]) => ({ model, tier: value?.tier })),
|
|
173
192
|
...unclassified
|
|
174
193
|
.filter((row) => !editable.tiers[adapter.id]?.[row.model])
|
|
175
|
-
.map((row) =>
|
|
194
|
+
.map((row) => {
|
|
195
|
+
const advisory = catalogModelAdvisory(cfg, catalog, adapter.id, row.model, row.resolvedModel, catalogRanking);
|
|
196
|
+
return {
|
|
197
|
+
model: row.model,
|
|
198
|
+
detectedAt: row.detectedAt,
|
|
199
|
+
...(advisory.suggestion
|
|
200
|
+
? { suggestion: { tier: advisory.suggestion.tier, note: advisory.suggestion.provenanceNote } }
|
|
201
|
+
: {}),
|
|
202
|
+
};
|
|
203
|
+
}),
|
|
176
204
|
],
|
|
177
205
|
};
|
|
178
206
|
});
|