tickmarkr 1.35.0 → 1.36.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/claude-code.js +1 -0
- package/dist/adapters/grok.js +1 -0
- package/dist/adapters/model-lints.d.ts +3 -1
- package/dist/adapters/model-lints.js +30 -0
- package/dist/adapters/opencode.js +1 -0
- package/dist/adapters/pi.js +1 -0
- package/dist/adapters/registry.d.ts +17 -1
- package/dist/adapters/registry.js +72 -4
- package/dist/adapters/types.d.ts +1 -0
- package/dist/cli/commands/doctor.js +18 -4
- package/dist/config/config.d.ts +3 -0
- package/dist/config/config.js +13 -0
- package/dist/route/router.d.ts +9 -1
- package/dist/route/router.js +26 -9
- package/package.json +1 -1
|
@@ -38,6 +38,7 @@ export function probeVersion(bin) {
|
|
|
38
38
|
export const claudeCode = {
|
|
39
39
|
id: "claude-code",
|
|
40
40
|
vendor: "anthropic",
|
|
41
|
+
probeCwd: "neutral",
|
|
41
42
|
probe: async () => probeVersion("claude"),
|
|
42
43
|
channels: (cfg) => channelsFromConfig("claude-code", cfg),
|
|
43
44
|
// --strict-mcp-config --mcp-config '{"mcpServers":{}}': pin the MCP surface to empty so fresh-worktree
|
package/dist/adapters/grok.js
CHANGED
|
@@ -83,6 +83,7 @@ export const grok = {
|
|
|
83
83
|
// FLEET-04 cross-vendor honesty: xai is diversity-distinct from anthropic/openai/cursor/zhipu.
|
|
84
84
|
// MUST equal tiers.grok.vendor (a test pins the equality).
|
|
85
85
|
vendor: "xai",
|
|
86
|
+
probeCwd: "neutral",
|
|
86
87
|
probe: async () => {
|
|
87
88
|
const h = probeVersion("grok");
|
|
88
89
|
if (!h.installed)
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type DrovrConfig } from "../config/config.js";
|
|
2
2
|
import { type AuthHealth, type WorkerAdapter } from "./types.js";
|
|
3
3
|
export declare const SEED_STAMPED = "2026-07-09";
|
|
4
4
|
export declare const MODEL_STALE_DAYS = 30;
|
|
5
5
|
export declare const ttyVisual: () => boolean;
|
|
6
|
+
export declare function seedPreferLints(cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], overlayPreferShapes?: ReadonlySet<string>): string[];
|
|
6
7
|
export declare function modelLints(cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], opts?: {
|
|
7
8
|
tty?: boolean;
|
|
8
9
|
stateDir?: string;
|
|
10
|
+
overlayPreferShapes?: ReadonlySet<string>;
|
|
9
11
|
}): string[];
|
|
10
12
|
export declare function formatModelAuthLine(excluded: {
|
|
11
13
|
key: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DEFAULT_CONFIG, TIER_RANK } from "../config/config.js";
|
|
1
2
|
import { MODEL_ID_RE } from "./types.js";
|
|
2
3
|
// date the tiers seeds were last live-verified (Phase 6/7 reseed) — surfaced when an adapter has no list surface.
|
|
3
4
|
export const SEED_STAMPED = "2026-07-09";
|
|
@@ -13,6 +14,34 @@ const TTY_LINT_CAP = 3;
|
|
|
13
14
|
const DEFAULT_STATE_DIR = ".drovr";
|
|
14
15
|
const doctorJsonRef = (stateDir) => ` — see ${stateDir}/doctor.json`;
|
|
15
16
|
export const ttyVisual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
|
|
17
|
+
const adapterHasAuthedChannel = (adapterId, shape, cfg, health, adapters) => {
|
|
18
|
+
const entry = cfg.routing.map[shape];
|
|
19
|
+
const minTier = entry?.tier ?? cfg.routing.floors[shape] ?? "cheap";
|
|
20
|
+
const a = adapters.find((x) => x.id === adapterId);
|
|
21
|
+
const h = health[adapterId];
|
|
22
|
+
if (!a || !h?.installed || typeof a.channels !== "function")
|
|
23
|
+
return false;
|
|
24
|
+
if (!h.modelAuth || !Object.keys(h.modelAuth).length)
|
|
25
|
+
return true; // no per-model probe data — compat, not dead
|
|
26
|
+
return a.channels(cfg).some((c) => TIER_RANK[c.tier] >= TIER_RANK[minTier] && h.modelAuth?.[c.model]?.authed === true);
|
|
27
|
+
};
|
|
28
|
+
// OBS-30 T2: warn when a built-in seed prefer names an adapter with zero authed channels this probe pass.
|
|
29
|
+
export function seedPreferLints(cfg, health, adapters, overlayPreferShapes = new Set()) {
|
|
30
|
+
const lints = [];
|
|
31
|
+
for (const shape of Object.keys(cfg.routing.map)) {
|
|
32
|
+
if (overlayPreferShapes.has(shape))
|
|
33
|
+
continue;
|
|
34
|
+
for (const p of DEFAULT_CONFIG.routing.map[shape]?.prefer ?? []) {
|
|
35
|
+
const adapterId = p.includes(":") ? p.slice(0, p.indexOf(":")) : p;
|
|
36
|
+
if (!cfg.tiers[adapterId])
|
|
37
|
+
continue;
|
|
38
|
+
if (!adapterHasAuthedChannel(adapterId, shape, cfg, health, adapters)) {
|
|
39
|
+
lints.push(`routing seed names dead adapter '${adapterId}' for shape '${shape}' — auto-prefer is routing around it`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return lints;
|
|
44
|
+
}
|
|
16
45
|
// Diffs detected models (doctor.json) against configured tiers, both directions, per adapter id in cfg.tiers.
|
|
17
46
|
// No ` ! ` prefix here — the consumer (doctor rows / plan lints) owns that. Pre-v1.5 doctor.json (models:[], no
|
|
18
47
|
// modelsDetectedAt) is the compat baseline: `?.`/`?? []` everywhere, no zod (would reject old files).
|
|
@@ -54,6 +83,7 @@ export function modelLints(cfg, health, adapters, opts) {
|
|
|
54
83
|
lints.push(`${id}: model knowledge is ${days} days old — rerun tickmarkr doctor`);
|
|
55
84
|
}
|
|
56
85
|
}
|
|
86
|
+
lints.push(...seedPreferLints(cfg, health, adapters, opts?.overlayPreferShapes));
|
|
57
87
|
return lints;
|
|
58
88
|
}
|
|
59
89
|
// T2/T6: one lint per exclusion, naming the probe reason and date. TTY truncates reasons to 60 chars and
|
|
@@ -16,6 +16,7 @@ export function parseOpencodeModels(raw) {
|
|
|
16
16
|
export const opencode = {
|
|
17
17
|
id: "opencode",
|
|
18
18
|
vendor: "mixed",
|
|
19
|
+
probeCwd: "neutral",
|
|
19
20
|
probe: async () => probeVersion("opencode"),
|
|
20
21
|
channels: (cfg) => channelsFromConfig("opencode", cfg),
|
|
21
22
|
headlessCommand: (promptFile, model) => `opencode run -m ${shq(model)} "$(cat ${shq(promptFile)})"`,
|
package/dist/adapters/pi.js
CHANGED
|
@@ -37,6 +37,7 @@ export const pi = {
|
|
|
37
37
|
// FLEET-04: cross-vendor review honesty — GLM's provider (pi's own label is "zai"; either is
|
|
38
38
|
// diversity-distinct from anthropic/openai/cursor/mixed). Must equal tiers.pi.vendor.
|
|
39
39
|
vendor: "zhipu",
|
|
40
|
+
probeCwd: "neutral",
|
|
40
41
|
probe: async () => {
|
|
41
42
|
const h = probeVersion("pi");
|
|
42
43
|
if (!h.installed)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type DrovrConfig } from "../config/config.js";
|
|
2
|
+
import { type RoutingProfile } from "../route/profile.js";
|
|
2
3
|
import { type AuthHealth, type BillingChannel, type WorkerAdapter } from "./types.js";
|
|
3
4
|
export declare function allAdapters(opts?: {
|
|
4
5
|
fakeScriptPath?: string;
|
|
@@ -7,6 +8,21 @@ export declare function getAdapter(id: string, adapters: WorkerAdapter[]): Worke
|
|
|
7
8
|
export declare function probeAll(adapters: WorkerAdapter[]): Promise<Record<string, AuthHealth>>;
|
|
8
9
|
export type ProbeModelStatus = "ok" | "timeout" | "failed";
|
|
9
10
|
export type ProbeModelProgress = (adapter: string, model: string, status: ProbeModelStatus, durationMs: number) => void;
|
|
11
|
+
export interface AutoPreferDoc {
|
|
12
|
+
derivedAt: string;
|
|
13
|
+
[shape: string]: string[] | string;
|
|
14
|
+
}
|
|
15
|
+
export declare const pendingAutoPreferKey: unique symbol;
|
|
16
|
+
export declare const DOCTOR_ROUTING_STALE_MS: number;
|
|
17
|
+
export declare function deriveAutoPrefer(cfg: DrovrConfig, adapters: WorkerAdapter[], health: Record<string, AuthHealth>, profile?: RoutingProfile): AutoPreferDoc;
|
|
18
|
+
export declare function readAutoPrefer(repoRoot: string): AutoPreferDoc | null;
|
|
19
|
+
export declare function routingPreferContext(repoRoot: string, cfg: DrovrConfig, opts?: {
|
|
20
|
+
globalDir?: string;
|
|
21
|
+
}): {
|
|
22
|
+
autoPrefer?: AutoPreferDoc;
|
|
23
|
+
doctorFresh: boolean;
|
|
24
|
+
overlayPreferShapes: ReadonlySet<string>;
|
|
25
|
+
};
|
|
10
26
|
export declare function probeModels(cfg: DrovrConfig, repoRoot: string, adapters: WorkerAdapter[], health: Record<string, AuthHealth>, onProgress?: ProbeModelProgress): Promise<void>;
|
|
11
27
|
export declare function writeDoctor(repoRoot: string, health: Record<string, AuthHealth>): void;
|
|
12
28
|
export declare function readDoctor(repoRoot: string): Record<string, AuthHealth> | null;
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { existsSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { overlayPreferShapes, TIER_RANK } from "../config/config.js";
|
|
4
5
|
import { modelLints, suggestOverlay } from "./model-lints.js";
|
|
5
6
|
import { HerdrDriver } from "../drivers/herdr.js";
|
|
6
7
|
import { drovrDir, stateDirName } from "../graph/graph.js";
|
|
7
8
|
import { disallowedBy, excludedChannels, exclusionLine, preferRanks } from "../route/preference.js";
|
|
9
|
+
import { learnedScore } from "../route/profile.js";
|
|
10
|
+
import { marginalCostRank } from "../route/router.js";
|
|
8
11
|
import { sh } from "../run/git.js";
|
|
9
12
|
import { claudeCode } from "./claude-code.js";
|
|
10
13
|
import { codex } from "./codex.js";
|
|
@@ -47,6 +50,57 @@ function probeFailure(code, stdout, stderr, timedOut, timeoutMs = MODEL_PROBE_TI
|
|
|
47
50
|
? output.slice(0, 240) || `probe exited ${code}`
|
|
48
51
|
: undefined;
|
|
49
52
|
}
|
|
53
|
+
export const pendingAutoPreferKey = Symbol.for("tickmarkr.pendingAutoPrefer");
|
|
54
|
+
const median = (xs) => {
|
|
55
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
56
|
+
const m = Math.floor(s.length / 2);
|
|
57
|
+
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
58
|
+
};
|
|
59
|
+
// ponytail: 24h TTL matches plan.ts staleness — promote to config when an operator asks.
|
|
60
|
+
export const DOCTOR_ROUTING_STALE_MS = 24 * 60 * 60 * 1000;
|
|
61
|
+
export function deriveAutoPrefer(cfg, adapters, health, profile) {
|
|
62
|
+
const derivedAt = new Date().toISOString();
|
|
63
|
+
const out = { derivedAt };
|
|
64
|
+
for (const [shape, entry] of Object.entries(cfg.routing.map)) {
|
|
65
|
+
const minTier = entry?.tier ?? cfg.routing.floors[shape] ?? "cheap";
|
|
66
|
+
const ranked = [];
|
|
67
|
+
for (const a of adapters) {
|
|
68
|
+
const h = health[a.id];
|
|
69
|
+
if (!h?.installed || !h?.authed || typeof a.channels !== "function")
|
|
70
|
+
continue;
|
|
71
|
+
const qualifying = a.channels(cfg).filter((c) => TIER_RANK[c.tier] >= TIER_RANK[minTier] && h.modelAuth?.[c.model]?.authed === true);
|
|
72
|
+
if (!qualifying.length)
|
|
73
|
+
continue;
|
|
74
|
+
const durations = qualifying
|
|
75
|
+
.map((c) => h.modelAuth?.[c.model]?.durationMs)
|
|
76
|
+
.filter((d) => typeof d === "number");
|
|
77
|
+
ranked.push({
|
|
78
|
+
adapter: a.id,
|
|
79
|
+
mc: Math.min(...qualifying.map(marginalCostRank)),
|
|
80
|
+
dur: durations.length ? median(durations) : Number.POSITIVE_INFINITY,
|
|
81
|
+
learned: profile ? Math.max(...qualifying.map((c) => learnedScore(profile, shape, channelKey(c), c.channel))) : 0,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
ranked.sort((a, b) => a.mc - b.mc || a.dur - b.dur || b.learned - a.learned);
|
|
85
|
+
out[shape] = ranked.map((r) => r.adapter);
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
export function readAutoPrefer(repoRoot) {
|
|
90
|
+
const raw = readDoctorFile(repoRoot);
|
|
91
|
+
if (!raw || typeof raw.autoPrefer !== "object" || raw.autoPrefer === null)
|
|
92
|
+
return null;
|
|
93
|
+
return raw.autoPrefer;
|
|
94
|
+
}
|
|
95
|
+
export function routingPreferContext(repoRoot, cfg, opts = {}) {
|
|
96
|
+
const age = doctorAgeMs(repoRoot);
|
|
97
|
+
const doctorFresh = age !== null && age <= DOCTOR_ROUTING_STALE_MS;
|
|
98
|
+
return {
|
|
99
|
+
autoPrefer: doctorFresh ? readAutoPrefer(repoRoot) ?? undefined : undefined,
|
|
100
|
+
doctorFresh,
|
|
101
|
+
overlayPreferShapes: overlayPreferShapes(repoRoot, opts),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
50
104
|
const probeModelStatus = (v) => v.authed ? "ok" : v.reason?.includes("timed out") ? "timeout" : "failed";
|
|
51
105
|
// v1.21: one bounded, headless call per configured model; detected-but-unclassified models never enter this loop.
|
|
52
106
|
export async function probeModels(cfg, repoRoot, adapters, health, onProgress) {
|
|
@@ -62,6 +116,7 @@ export async function probeModels(cfg, repoRoot, adapters, health, onProgress) {
|
|
|
62
116
|
return;
|
|
63
117
|
const verdicts = {};
|
|
64
118
|
const priorModelAuth = priorHealth?.[a.id]?.modelAuth;
|
|
119
|
+
const probeRoot = a.probeCwd === "neutral" ? mkdtempSync(join(tmpdir(), "tickmarkr-probe-")) : repoRoot;
|
|
65
120
|
// models probe concurrently too (v1.33.5) — sequential chains made init wall time Σ(models×60s)
|
|
66
121
|
// on the slowest adapter (measured 96s); each probe is one tiny prompt, safe to overlap.
|
|
67
122
|
// Capped at MODEL_PROBE_CONCURRENCY per adapter (v1.33.5 regression: 4 concurrent codex exec
|
|
@@ -78,12 +133,12 @@ export async function probeModels(cfg, repoRoot, adapters, health, onProgress) {
|
|
|
78
133
|
else {
|
|
79
134
|
const promptFile = join(mkdtempSync(join(tmpdir(), "drovr-auth-")), "probe.md");
|
|
80
135
|
writeFileSync(promptFile, MODEL_PROBE_PROMPT);
|
|
81
|
-
const r = await sh(a.headlessCommand(promptFile, model),
|
|
136
|
+
const r = await sh(a.headlessCommand(promptFile, model), probeRoot, MODEL_PROBE_TIMEOUT_MS);
|
|
82
137
|
if (r.timedOut && priorTimedOut) {
|
|
83
138
|
verdict = { authed: false, reason: `probe timed out (repeat — retry skipped) (${MODEL_PROBE_TIMEOUT_MS}ms)`, probedAt, durationMs: Date.now() - t0 };
|
|
84
139
|
}
|
|
85
140
|
else if (r.timedOut) {
|
|
86
|
-
const r2 = await sh(a.headlessCommand(promptFile, model),
|
|
141
|
+
const r2 = await sh(a.headlessCommand(promptFile, model), probeRoot, MODEL_PROBE_TIMEOUT_MS);
|
|
87
142
|
if (r2.timedOut) {
|
|
88
143
|
verdict = { authed: false, reason: `probe timed out twice (${MODEL_PROBE_TIMEOUT_MS}ms)`, probedAt, durationMs: Date.now() - t0 };
|
|
89
144
|
}
|
|
@@ -106,6 +161,7 @@ export async function probeModels(cfg, repoRoot, adapters, health, onProgress) {
|
|
|
106
161
|
});
|
|
107
162
|
h.modelAuth = verdicts;
|
|
108
163
|
}));
|
|
164
|
+
health[pendingAutoPreferKey] = deriveAutoPrefer(cfg, adapters, health);
|
|
109
165
|
}
|
|
110
166
|
// T2: caps concurrent probes per adapter at 2 — v1.33.5 regression, 4 concurrent codex exec in one
|
|
111
167
|
// repo made ALL 4 time out where sequential passed 2/4 (suspected CLI self-contention).
|
|
@@ -121,12 +177,24 @@ async function mapLimit(items, limit, fn) {
|
|
|
121
177
|
await Promise.all(workers);
|
|
122
178
|
}
|
|
123
179
|
const doctorPath = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "doctor.json");
|
|
180
|
+
function readDoctorFile(repoRoot) {
|
|
181
|
+
return existsSync(doctorPath(repoRoot)) ? JSON.parse(readFileSync(doctorPath(repoRoot), "utf8")) : null;
|
|
182
|
+
}
|
|
124
183
|
export function writeDoctor(repoRoot, health) {
|
|
125
184
|
drovrDir(repoRoot);
|
|
126
|
-
|
|
185
|
+
const pending = health[pendingAutoPreferKey];
|
|
186
|
+
const payload = { ...health };
|
|
187
|
+
delete payload[pendingAutoPreferKey];
|
|
188
|
+
if (pending)
|
|
189
|
+
payload.autoPrefer = pending;
|
|
190
|
+
writeFileSync(doctorPath(repoRoot), JSON.stringify(payload, null, 2) + "\n");
|
|
127
191
|
}
|
|
128
192
|
export function readDoctor(repoRoot) {
|
|
129
|
-
|
|
193
|
+
const raw = readDoctorFile(repoRoot);
|
|
194
|
+
if (!raw)
|
|
195
|
+
return null;
|
|
196
|
+
const { autoPrefer: _, ...health } = raw;
|
|
197
|
+
return health;
|
|
130
198
|
}
|
|
131
199
|
export function discoverChannels(cfg, adapters, health) {
|
|
132
200
|
const base = adapters
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -72,6 +72,7 @@ export declare function matchesTrustDialog(paneText: string, dialog: TrustDialog
|
|
|
72
72
|
export interface WorkerAdapter {
|
|
73
73
|
id: string;
|
|
74
74
|
vendor: string;
|
|
75
|
+
probeCwd?: "repo" | "neutral";
|
|
75
76
|
probe(): Promise<AuthHealth>;
|
|
76
77
|
channels(cfg: DrovrConfig): BillingChannel[];
|
|
77
78
|
headlessCommand(promptFile: string, model: string): string;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { allAdapters, probeAll, probeModels, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
|
|
3
|
+
import { allAdapters, probeAll, probeModels, readAutoPrefer, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
|
|
4
4
|
import { drovrDir, stateDirName } from "../../graph/graph.js";
|
|
5
5
|
import { modelLints, suggestOverlay, ttyVisual } from "../../adapters/model-lints.js";
|
|
6
|
-
import { loadConfig } from "../../config/config.js";
|
|
6
|
+
import { DEFAULT_CONFIG, loadConfig, overlayPreferShapes } from "../../config/config.js";
|
|
7
7
|
import { HerdrDriver } from "../../drivers/herdr.js";
|
|
8
8
|
import { disallowedBy, excludedChannels, exclusionLine, preferRanks } from "../../route/preference.js";
|
|
9
9
|
// same gate + palette as status's audit-ledger frame: styled only on a real TTY, plain otherwise
|
|
@@ -91,7 +91,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
|
|
|
91
91
|
rows.push(` ! ${role} runs on ${sel.adapter}:${sel.model} — NOT installed; that gate will fail closed until you install it or remap cfg.${role}`);
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
-
rows.push(...modelLints(cfg, health, adapters, { tty: ttyVisual(), stateDir: stateDirName(cwd) }).map((l) => ` ! ${l}`));
|
|
94
|
+
rows.push(...modelLints(cfg, health, adapters, { tty: ttyVisual(), stateDir: stateDirName(cwd), overlayPreferShapes: overlayPreferShapes(cwd) }).map((l) => ` ! ${l}`));
|
|
95
95
|
const excluded = excludedChannels(cfg, adapters, health);
|
|
96
96
|
if (excluded.length)
|
|
97
97
|
rows.push(` ! ${exclusionLine(excluded)}`);
|
|
@@ -143,6 +143,20 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
|
|
|
143
143
|
rows.push(` (${unclassified.length} more listed, unclassified)`);
|
|
144
144
|
return rows;
|
|
145
145
|
});
|
|
146
|
-
const
|
|
146
|
+
const autoPrefer = readAutoPrefer(cwd);
|
|
147
|
+
const preferStatus = autoPrefer
|
|
148
|
+
? Object.keys(cfg.routing.map).flatMap((shape) => {
|
|
149
|
+
const auto = autoPrefer[shape];
|
|
150
|
+
if (!Array.isArray(auto))
|
|
151
|
+
return [];
|
|
152
|
+
const seed = DEFAULT_CONFIG.routing.map[shape]?.prefer ?? [];
|
|
153
|
+
if (!auto.length && !seed.length)
|
|
154
|
+
return []; // nothing derived, nothing seeded — an empty line is noise
|
|
155
|
+
return [` prefer ${shape} (auto): ${auto.join(" > ")} — seed was [${seed.join(", ")}]`];
|
|
156
|
+
})
|
|
157
|
+
: [];
|
|
158
|
+
const modelSummary = modelStatus.length || preferStatus.length
|
|
159
|
+
? `\nmodel status:\n${[...modelStatus, ...preferStatus].join("\n")}`
|
|
160
|
+
: "";
|
|
147
161
|
return stylize(`tickmarkr doctor — capability matrix:\n${rows.join("\n")}${modelSummary}${drift}\nwrote ${stateDirName(cwd)}/doctor.json`);
|
|
148
162
|
}
|
package/dist/config/config.d.ts
CHANGED
|
@@ -177,6 +177,9 @@ export declare class ConfigError extends Error {
|
|
|
177
177
|
}
|
|
178
178
|
export declare const DEFAULT_CONFIG: DrovrConfig;
|
|
179
179
|
export declare function globalConfigDir(): string;
|
|
180
|
+
export declare function overlayPreferShapes(repoRoot: string, opts?: {
|
|
181
|
+
globalDir?: string;
|
|
182
|
+
}): ReadonlySet<string>;
|
|
180
183
|
export declare function loadConfig(repoRoot: string, opts?: {
|
|
181
184
|
globalDir?: string;
|
|
182
185
|
}): DrovrConfig;
|
package/dist/config/config.js
CHANGED
|
@@ -262,6 +262,19 @@ export function globalConfigDir() {
|
|
|
262
262
|
const legacy = join(base, "drovr");
|
|
263
263
|
return existsSync(join(next, "config.yaml")) || !existsSync(join(legacy, "config.yaml")) ? next : legacy;
|
|
264
264
|
}
|
|
265
|
+
export function overlayPreferShapes(repoRoot, opts = {}) {
|
|
266
|
+
const shapes = new Set();
|
|
267
|
+
for (const layer of [readYaml(join(opts.globalDir ?? globalConfigDir(), "config.yaml")), readYaml(join(repoRoot, stateDirName(repoRoot), "config.yaml"))]) {
|
|
268
|
+
const map = layer?.routing?.map;
|
|
269
|
+
if (!map)
|
|
270
|
+
continue;
|
|
271
|
+
for (const [shape, entry] of Object.entries(map)) {
|
|
272
|
+
if (entry && Object.prototype.hasOwnProperty.call(entry, "prefer") && entry.prefer !== null)
|
|
273
|
+
shapes.add(shape);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return shapes;
|
|
277
|
+
}
|
|
265
278
|
export function loadConfig(repoRoot, opts = {}) {
|
|
266
279
|
const globalCfg = readYaml(join(opts.globalDir ?? globalConfigDir(), "config.yaml"));
|
|
267
280
|
const repoCfg = readYaml(join(repoRoot, stateDirName(repoRoot), "config.yaml"));
|
package/dist/route/router.d.ts
CHANGED
|
@@ -18,9 +18,17 @@ export interface Route {
|
|
|
18
18
|
provenance: string;
|
|
19
19
|
deviation?: RouteDeviation;
|
|
20
20
|
}
|
|
21
|
+
export interface RoutingPreferContext {
|
|
22
|
+
autoPrefer?: {
|
|
23
|
+
derivedAt: string;
|
|
24
|
+
[shape: string]: string[] | string;
|
|
25
|
+
};
|
|
26
|
+
doctorFresh: boolean;
|
|
27
|
+
overlayPreferShapes: ReadonlySet<string>;
|
|
28
|
+
}
|
|
21
29
|
export declare class RoutingError extends Error {
|
|
22
30
|
constructor(msg: string);
|
|
23
31
|
}
|
|
24
32
|
export declare function marginalCostRank(c: BillingChannel): number;
|
|
25
|
-
export declare function route(task: Task, cfg: DrovrConfig, channels: BillingChannel[], profile?: RoutingProfile): Route;
|
|
33
|
+
export declare function route(task: Task, cfg: DrovrConfig, channels: BillingChannel[], profile?: RoutingProfile, preferCtx?: RoutingPreferContext): Route;
|
|
26
34
|
export declare function nextChannel(current: Assignment, task: Task, cfg: DrovrConfig, channels: BillingChannel[], tried: string[], profile?: RoutingProfile): Assignment | null;
|
package/dist/route/router.js
CHANGED
|
@@ -2,6 +2,19 @@ import { channelKey, channelsFromConfig } from "../adapters/types.js";
|
|
|
2
2
|
import { TIER_RANK } from "../config/config.js";
|
|
3
3
|
import { disallowedBy } from "./preference.js";
|
|
4
4
|
import { cellOf, EXPLORE_CAP, explorationBonus, learnedScore } from "./profile.js";
|
|
5
|
+
const autoPreferList = (doc, shape) => {
|
|
6
|
+
const v = doc?.[shape];
|
|
7
|
+
return Array.isArray(v) ? v : undefined;
|
|
8
|
+
};
|
|
9
|
+
const preferFromAuto = (shape, preferCtx) => !!preferCtx?.doctorFresh && !!preferCtx.autoPrefer && !preferCtx.overlayPreferShapes.has(shape) &&
|
|
10
|
+
autoPreferList(preferCtx.autoPrefer, shape) !== undefined;
|
|
11
|
+
const effectivePrefer = (shape, entry, preferCtx) => {
|
|
12
|
+
if (preferCtx?.overlayPreferShapes.has(shape))
|
|
13
|
+
return entry?.prefer;
|
|
14
|
+
if (preferCtx?.doctorFresh && preferCtx.autoPrefer)
|
|
15
|
+
return autoPreferList(preferCtx.autoPrefer, shape) ?? entry?.prefer;
|
|
16
|
+
return entry?.prefer;
|
|
17
|
+
};
|
|
5
18
|
export class RoutingError extends Error {
|
|
6
19
|
constructor(msg) {
|
|
7
20
|
super(msg);
|
|
@@ -31,10 +44,11 @@ function ladderFor(task, entry) {
|
|
|
31
44
|
const escalate = task.routingHints?.escalate ?? entry?.escalate ?? true;
|
|
32
45
|
return escalate ? ["retry", "escalate", "consult", "human"] : ["retry", "consult", "human"];
|
|
33
46
|
}
|
|
34
|
-
export function route(task, cfg, channels, profile) {
|
|
47
|
+
export function route(task, cfg, channels, profile, preferCtx) {
|
|
35
48
|
const lints = [];
|
|
36
49
|
const floor = cfg.routing.floors[task.shape];
|
|
37
50
|
const entry = cfg.routing.map[task.shape];
|
|
51
|
+
const prefer = effectivePrefer(task.shape, entry, preferCtx);
|
|
38
52
|
const prefActive = !!(cfg.routing.allow || cfg.routing.deny);
|
|
39
53
|
const disallowedPin = (via, model, kind) => {
|
|
40
54
|
const d = disallowedBy({ adapter: via, model }, cfg.routing);
|
|
@@ -95,12 +109,12 @@ export function route(task, cfg, channels, profile) {
|
|
|
95
109
|
if (entry?.tier)
|
|
96
110
|
lintFloor(entry.tier, "map tier");
|
|
97
111
|
if (prefActive)
|
|
98
|
-
for (const p of
|
|
112
|
+
for (const p of prefer ?? [])
|
|
99
113
|
preflightPrefer(p);
|
|
100
114
|
// key order is a contract: prefer > marginal cost > tier (cheapest sufficient) > learned score (v1.6 ROUTE-06)
|
|
101
115
|
// > discovery order (same-tier fairness, D2). The learned score is the LAST key — it decides only the
|
|
102
116
|
// discovery-order tail, never a pin/floor/prefer/cost/tier boundary (they all sort above it, ROUTE-08).
|
|
103
|
-
const staticCmp = (a, b) => preferIndex(a,
|
|
117
|
+
const staticCmp = (a, b) => preferIndex(a, prefer) - preferIndex(b, prefer) || marginalCostRank(a) - marginalCostRank(b) || TIER_RANK[a.tier] - TIER_RANK[b.tier];
|
|
104
118
|
const eligibleRaw = channels.filter((c) => TIER_RANK[c.tier] >= TIER_RANK[minTier]);
|
|
105
119
|
if (!eligibleRaw.length) {
|
|
106
120
|
let msg = `${task.id}: no channel at tier>=${minTier}; available: ${channels.map(channelKey).join(", ") || "(none)"}`;
|
|
@@ -137,19 +151,19 @@ export function route(task, cfg, channels, profile) {
|
|
|
137
151
|
// entries, while intra-entry order (cost > tier > bonus > score) and every cold path stay byte-identical.
|
|
138
152
|
// Cold reduces to preferIndex || cost || tier ≡ staticCmp (proof P1); rep keys use the group HEAD so
|
|
139
153
|
// the probed group's budget self-extinguishes at EXPLORE_CAP (P4); flat lexicographic K is total (P5).
|
|
140
|
-
const
|
|
154
|
+
const preferBands = prefer ?? [];
|
|
141
155
|
const groupCmp = (a, b) => marginalCostRank(a) - marginalCostRank(b) || TIER_RANK[a.tier] - TIER_RANK[b.tier] ||
|
|
142
156
|
bonusOf(b) - bonusOf(a) || scoreOf(b) - scoreOf(a);
|
|
143
157
|
const heads = new Map();
|
|
144
158
|
for (const c of [...eligibleRaw].sort(groupCmp)) {
|
|
145
|
-
const g = preferIndex(c,
|
|
159
|
+
const g = preferIndex(c, preferBands);
|
|
146
160
|
if (!heads.has(g))
|
|
147
161
|
heads.set(g, c);
|
|
148
162
|
}
|
|
149
163
|
const keyOf = new Map(eligibleRaw.map((c) => {
|
|
150
|
-
const g = preferIndex(c,
|
|
164
|
+
const g = preferIndex(c, preferBands);
|
|
151
165
|
const rep = heads.get(g);
|
|
152
|
-
return [channelKey(c), [g <
|
|
166
|
+
return [channelKey(c), [g < preferBands.length ? 0 : 1, -bonusOf(rep), -scoreOf(rep), g,
|
|
153
167
|
marginalCostRank(c), TIER_RANK[c.tier], -bonusOf(c), -scoreOf(c)]];
|
|
154
168
|
}));
|
|
155
169
|
const kOf = (c) => keyOf.get(channelKey(c));
|
|
@@ -180,8 +194,11 @@ export function route(task, cfg, channels, profile) {
|
|
|
180
194
|
"tier cheap (default)";
|
|
181
195
|
// name the key that actually broke the tie: prefer outranks the marginal-cost/tier keys, so if the
|
|
182
196
|
// winner matched a prefer entry, prefer decided it — not "cheapest sufficient tier" (ROUTE-03, WR-01)
|
|
183
|
-
const
|
|
184
|
-
?
|
|
197
|
+
const preferVia = preferFromAuto(task.shape, preferCtx)
|
|
198
|
+
? `via prefer (auto-modernized ${preferCtx.autoPrefer.derivedAt.slice(0, 10)})`
|
|
199
|
+
: "via prefer";
|
|
200
|
+
const chosenBy = learnedChosen || (prefer && preferIndex(eligible[0], prefer) < prefer.length
|
|
201
|
+
? preferVia : "cheapest sufficient tier");
|
|
185
202
|
return { assignment: toAssignment(eligible[0]), ladder: ladderFor(task, entry), lints, provenance: `${degraded}${bound}, marginal-cost auto (${chosenBy})`, ...(deviation ? { deviation } : {}) };
|
|
186
203
|
}
|
|
187
204
|
export function nextChannel(current, task, cfg, channels, tried, profile) {
|