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.
@@ -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
@@ -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 { DrovrConfig } from "../config/config.js";
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)})"`,
@@ -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 { DrovrConfig } from "../config/config.js";
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), repoRoot, MODEL_PROBE_TIMEOUT_MS);
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), repoRoot, MODEL_PROBE_TIMEOUT_MS);
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
- writeFileSync(doctorPath(repoRoot), JSON.stringify(health, null, 2) + "\n");
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
- return existsSync(doctorPath(repoRoot)) ? JSON.parse(readFileSync(doctorPath(repoRoot), "utf8")) : null;
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
@@ -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 modelSummary = modelStatus.length ? `\nmodel status:\n${modelStatus.join("\n")}` : "";
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
  }
@@ -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;
@@ -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"));
@@ -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;
@@ -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 entry?.prefer ?? [])
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, entry?.prefer) - preferIndex(b, entry?.prefer) || marginalCostRank(a) - marginalCostRank(b) || TIER_RANK[a.tier] - TIER_RANK[b.tier];
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 prefer = entry?.prefer ?? [];
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, prefer);
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, prefer);
164
+ const g = preferIndex(c, preferBands);
151
165
  const rep = heads.get(g);
152
- return [channelKey(c), [g < prefer.length ? 0 : 1, -bonusOf(rep), -scoreOf(rep), 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 chosenBy = learnedChosen || (entry?.prefer && preferIndex(eligible[0], entry.prefer) < entry.prefer.length
184
- ? "via prefer" : "cheapest sufficient tier");
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.35.0",
3
+ "version": "1.36.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",