tickmarkr 1.46.0 → 1.47.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.
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { parse } from "yaml";
4
+ import { parse, stringify } from "yaml";
5
5
  import { z } from "zod";
6
6
  import { stateDirName } from "../graph/graph.js";
7
7
  import { SHAPES, TIERS } from "../graph/schema.js";
@@ -20,6 +20,8 @@ export const TierEntrySchema = z.object({
20
20
  vendor: z.string(),
21
21
  channel: z.enum(["sub", "api"]),
22
22
  models: z.record(z.string(), TierEnum),
23
+ // v1.47 T3: optional per-model context-window sizes (tokens). Absent block ⇒ no doctor column, no plan lint.
24
+ windows: z.record(z.string(), z.number().int().positive()).optional(),
23
25
  });
24
26
  // v1.10 FLEET-06: optional routing.allow/deny fleet preference; absent blocks ⇒ byte-identical routing/discovery.
25
27
  // deny wins on conflict; presence of allow (even {} / empty arrays) activates allowlist (fail-closed).
@@ -87,6 +89,15 @@ export const TickmarkrConfigSchema = z.object({
87
89
  halfLifeRuns: z.number().positive().optional(),
88
90
  availWeight: z.number().nonnegative().optional(),
89
91
  }).optional(),
92
+ // E3 (v1.45-T3): optional exploration fence — absent block ⇒ byte-identical to pre-v1.45 routing.
93
+ explore: z.object({
94
+ mode: z.enum(["on", "off"]).optional(),
95
+ excludeShapes: z.array(z.string()).optional(),
96
+ excludeComplexityAtOrAbove: z.number().int().min(1).max(10).nullable().optional(),
97
+ cap: z.number().int().positive().optional(),
98
+ }).optional(),
99
+ // v1.47 T4: per-shape time SLA in minutes — advisory plan lint only; absent ⇒ byte-identical routing.
100
+ sla: z.record(z.string(), z.number().positive()).optional(),
90
101
  // Pre-v1.21 doctor.json lacks per-model verdicts. Keep legacy unknown-is-routable behavior opt-in.
91
102
  allowUnverifiedModels: z.boolean(),
92
103
  allow: PrefBlockSchema.optional(),
@@ -305,6 +316,8 @@ export function configTemplate(overlay) {
305
316
  # migration: frontier
306
317
  # learned: on # default ON (ROUTE-14); cold profile = exact v1.5 static routing, warms per workspace. Set 'off' to pin static routing; preview with 'tickmarkr plan'
307
318
  # learnedTuning: { halfLifeRuns: 5, availWeight: 0.05 } # optional; defaults byte-identical
319
+ # explore: { mode: on, excludeShapes: [], excludeComplexityAtOrAbove: null, cap: 5 } # optional; absent ⇒ byte-identical
320
+ # sla: { implement: 15 } # optional per-shape minutes — advisory plan lint only; absent ⇒ no lint
308
321
  # allow: { adapters: [claude-code, codex] } # optional fleet allowlist; presence activates even if empty (fail-closed)
309
322
  # deny: { models: [codex:gpt-5.5] } # optional fleet denylist; deny beats allow on conflict
310
323
  # # entry grammar: adapter id | model id | adapter:model (entries in either list accept all three forms)
@@ -365,3 +378,277 @@ export function configTemplate(overlay) {
365
378
  const nl = base.indexOf("\n");
366
379
  return `${base.slice(0, nl + 1)}${lines.join("\n")}\n${base.slice(nl + 1)}`;
367
380
  }
381
+ /** Fleet-owned overlay keys — the only config surface `tickmarkr fleet` may write. */
382
+ export const FLEET_OVERLAY_KEYS = ["routing", "tiers"];
383
+ export function repoOverlayPath(repoRoot) {
384
+ return join(repoRoot, stateDirName(repoRoot), "config.yaml");
385
+ }
386
+ export function readOverlayFile(path) {
387
+ const raw = readYaml(path);
388
+ if (raw === undefined || raw === null)
389
+ return {};
390
+ if (typeof raw !== "object" || Array.isArray(raw))
391
+ throw new ConfigError(`invalid overlay at ${path}`);
392
+ return raw;
393
+ }
394
+ export function fleetEditableFromConfig(cfg) {
395
+ const tiers = {};
396
+ for (const [adapter, entry] of Object.entries(cfg.tiers)) {
397
+ tiers[adapter] = {};
398
+ for (const [model, tier] of Object.entries(entry.models))
399
+ tiers[adapter][model] = { tier };
400
+ }
401
+ return {
402
+ denyAdapters: [...(cfg.routing.deny?.adapters ?? [])].sort(),
403
+ denyModels: [...(cfg.routing.deny?.models ?? [])].sort(),
404
+ tiers,
405
+ map: structuredClone(cfg.routing.map),
406
+ floors: { ...cfg.routing.floors },
407
+ };
408
+ }
409
+ function fleetSubset(obj) {
410
+ const out = {};
411
+ for (const k of FLEET_OVERLAY_KEYS) {
412
+ if (obj[k] !== undefined)
413
+ out[k] = obj[k];
414
+ }
415
+ return out;
416
+ }
417
+ /** Resolve which layer last set a dotted fleet path (defaults < global < repo). */
418
+ export function fleetKeyLayer(repoRoot, dotted, opts = {}) {
419
+ const gdir = opts.globalDir ?? globalConfigDir();
420
+ const globalRaw = readOverlayFile(join(gdir, "config.yaml"));
421
+ const repoRaw = readOverlayFile(repoOverlayPath(repoRoot));
422
+ const parts = dotted.split(".");
423
+ const at = (layer) => {
424
+ let cur = layer;
425
+ for (const p of parts) {
426
+ if (cur === undefined || cur === null || typeof cur !== "object" || Array.isArray(cur))
427
+ return undefined;
428
+ cur = cur[p];
429
+ }
430
+ return cur;
431
+ };
432
+ if (at(repoRaw) !== undefined)
433
+ return "repo";
434
+ if (at(globalRaw) !== undefined)
435
+ return "global";
436
+ return "defaults";
437
+ }
438
+ /** Non-interactive fleet state for CI drift checks (`tickmarkr fleet --print`). */
439
+ export function formatFleetPrint(repoRoot, opts = {}) {
440
+ const gdir = opts.globalDir ?? globalConfigDir();
441
+ const effective = loadConfig(repoRoot, { globalDir: gdir });
442
+ const editable = fleetEditableFromConfig(effective);
443
+ const lines = ["# tickmarkr fleet — effective state (repo > global > defaults)"];
444
+ const annotate = (dotted, yaml) => `${yaml} # ${fleetKeyLayer(repoRoot, dotted, opts)}`;
445
+ const denyAdapters = editable.denyAdapters;
446
+ const denyModels = editable.denyModels;
447
+ if (denyAdapters.length || denyModels.length) {
448
+ lines.push("routing:");
449
+ lines.push(" deny:");
450
+ if (denyAdapters.length)
451
+ lines.push(annotate("routing.deny.adapters", ` adapters: ${stringify(denyAdapters).trim()}`));
452
+ if (denyModels.length)
453
+ lines.push(annotate("routing.deny.models", ` models: ${stringify(denyModels).trim()}`));
454
+ }
455
+ const mapKeys = Object.keys(editable.map).sort();
456
+ if (mapKeys.length) {
457
+ if (!lines.some((l) => l === "routing:"))
458
+ lines.push("routing:");
459
+ lines.push(" map:");
460
+ for (const shape of mapKeys) {
461
+ const entry = editable.map[shape];
462
+ const body = stringify(entry, { indent: 4 }).trim().split("\n").map((l) => ` ${l}`).join("\n");
463
+ lines.push(annotate(`routing.map.${shape}`, ` ${shape}:`));
464
+ lines.push(body.split("\n").slice(1).join("\n"));
465
+ }
466
+ }
467
+ const floorKeys = Object.keys(editable.floors).sort();
468
+ if (floorKeys.length) {
469
+ if (!lines.some((l) => l === "routing:"))
470
+ lines.push("routing:");
471
+ lines.push(annotate("routing.floors", ` floors: ${stringify(Object.fromEntries(floorKeys.map((k) => [k, editable.floors[k]]))).trim().replace(/^/gm, " ")}`));
472
+ }
473
+ const tierAdapters = Object.keys(editable.tiers).sort();
474
+ if (tierAdapters.length) {
475
+ lines.push("tiers:");
476
+ for (const adapter of tierAdapters) {
477
+ const models = editable.tiers[adapter];
478
+ const modelIds = Object.keys(models).sort();
479
+ if (!modelIds.length)
480
+ continue;
481
+ lines.push(` ${adapter}:`);
482
+ lines.push(" models:");
483
+ for (const model of modelIds) {
484
+ const v = models[model];
485
+ if (v === null)
486
+ lines.push(` ${model}: null`);
487
+ else
488
+ lines.push(annotate(`tiers.${adapter}.models.${model}`, ` ${model}: ${v.tier}`));
489
+ }
490
+ }
491
+ }
492
+ return `${lines.join("\n")}\n`;
493
+ }
494
+ function sortedUnique(xs) {
495
+ return [...new Set(xs)].sort();
496
+ }
497
+ /** Build the repo overlay fragment fleet would write for edits since session start. */
498
+ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}) {
499
+ if (fleetEditableEquals(initial, edited))
500
+ return {};
501
+ const out = structuredClone(existingRepo);
502
+ const routing = { ...out.routing };
503
+ let routingTouched = false;
504
+ const denyChanged = sortedUnique(initial.denyAdapters).join() !== sortedUnique(edited.denyAdapters).join()
505
+ || sortedUnique(initial.denyModels).join() !== sortedUnique(edited.denyModels).join();
506
+ if (denyChanged) {
507
+ routing.deny = {
508
+ adapters: edited.denyAdapters.length ? edited.denyAdapters : null,
509
+ models: edited.denyModels.length ? edited.denyModels : null,
510
+ };
511
+ routingTouched = true;
512
+ }
513
+ const mapDelta = {};
514
+ for (const shape of new Set([...Object.keys(initial.map), ...Object.keys(edited.map)])) {
515
+ if (JSON.stringify(initial.map[shape]) !== JSON.stringify(edited.map[shape]))
516
+ mapDelta[shape] = edited.map[shape];
517
+ }
518
+ if (Object.keys(mapDelta).length) {
519
+ routing.map = { ...routing.map, ...mapDelta };
520
+ routingTouched = true;
521
+ }
522
+ const floorDelta = {};
523
+ for (const shape of new Set([...Object.keys(initial.floors), ...Object.keys(edited.floors)])) {
524
+ if (initial.floors[shape] !== edited.floors[shape])
525
+ floorDelta[shape] = edited.floors[shape];
526
+ }
527
+ if (Object.keys(floorDelta).length) {
528
+ routing.floors = { ...routing.floors, ...floorDelta };
529
+ routingTouched = true;
530
+ }
531
+ if (routingTouched)
532
+ out.routing = routing;
533
+ const tiersOut = {
534
+ ...out.tiers,
535
+ };
536
+ let tiersTouched = false;
537
+ const adapters = new Set([...Object.keys(initial.tiers), ...Object.keys(edited.tiers)]);
538
+ for (const adapter of adapters) {
539
+ const models = new Set([
540
+ ...Object.keys(initial.tiers[adapter] ?? {}),
541
+ ...Object.keys(edited.tiers[adapter] ?? {}),
542
+ ]);
543
+ const modelDelta = {};
544
+ for (const model of models) {
545
+ const a = initial.tiers[adapter]?.[model];
546
+ const b = edited.tiers[adapter]?.[model];
547
+ if (JSON.stringify(a) !== JSON.stringify(b)) {
548
+ modelDelta[model] = b === null || b === undefined ? null : b.tier;
549
+ }
550
+ }
551
+ if (Object.keys(modelDelta).length) {
552
+ tiersOut[adapter] = { models: { ...tiersOut[adapter]?.models, ...modelDelta } };
553
+ tiersTouched = true;
554
+ }
555
+ }
556
+ if (tiersTouched)
557
+ out.tiers = tiersOut;
558
+ return out;
559
+ }
560
+ export function repoOverlayYaml(overlay, provenance = {}) {
561
+ if (!Object.keys(overlay).length)
562
+ return "";
563
+ const fleet = fleetSubset(overlay);
564
+ const fleetBody = serializeFleetOverlay(fleet, provenance);
565
+ const rest = { ...overlay };
566
+ for (const k of FLEET_OVERLAY_KEYS)
567
+ delete rest[k];
568
+ if (!Object.keys(rest).length)
569
+ return fleetBody;
570
+ const head = stringify(rest).trimEnd();
571
+ return fleetBody ? `${head}\n${fleetBody}` : `${head}\n`;
572
+ }
573
+ export function serializeFleetOverlay(overlay, provenance = {}) {
574
+ if (!Object.keys(overlay).length)
575
+ return "";
576
+ const lines = [];
577
+ const today = new Date().toISOString().slice(0, 10);
578
+ const routing = overlay.routing;
579
+ if (routing) {
580
+ lines.push("routing:");
581
+ const deny = routing.deny;
582
+ if (deny) {
583
+ lines.push(" deny:");
584
+ if (deny.adapters)
585
+ lines.push(` adapters: ${stringify(deny.adapters).trim()}`);
586
+ if (deny.models)
587
+ lines.push(` models: ${stringify(deny.models).trim()}`);
588
+ }
589
+ if (routing.map) {
590
+ lines.push(" map:");
591
+ for (const [shape, entry] of Object.entries(routing.map)) {
592
+ lines.push(` ${shape}:`);
593
+ const body = stringify(entry, { indent: 2 }).trim().split("\n");
594
+ lines.push(...body.map((l) => ` ${l}`));
595
+ }
596
+ }
597
+ if (routing.floors)
598
+ lines.push(` floors: ${stringify(routing.floors).trim().replace(/^/gm, " ")}`);
599
+ }
600
+ const tiers = overlay.tiers;
601
+ if (tiers) {
602
+ lines.push("tiers:");
603
+ for (const [adapter, entry] of Object.entries(tiers)) {
604
+ lines.push(` ${adapter}:`);
605
+ if (entry.vendor)
606
+ lines.push(` vendor: ${entry.vendor}`);
607
+ if (entry.channel)
608
+ lines.push(` channel: ${entry.channel}`);
609
+ lines.push(" models:");
610
+ for (const [model, tier] of Object.entries(entry.models ?? {})) {
611
+ if (tier === null)
612
+ lines.push(` ${model}: null`);
613
+ else {
614
+ const note = provenance[adapter]?.[model];
615
+ const suffix = note ? ` # ${note} — fleet ${today}` : "";
616
+ lines.push(` ${model}: ${tier}${suffix}`);
617
+ }
618
+ }
619
+ }
620
+ }
621
+ return `${lines.join("\n")}\n`;
622
+ }
623
+ export function unifiedYamlDiff(before, after, label = "config overlay") {
624
+ const a = before.split("\n");
625
+ const b = after.split("\n");
626
+ if (before === after)
627
+ return "";
628
+ const header = [`--- ${label} (current)`, `+++ ${label} (proposed)`];
629
+ const hunks = [];
630
+ let i = 0;
631
+ let j = 0;
632
+ while (i < a.length || j < b.length) {
633
+ if (i < a.length && j < b.length && a[i] === b[j]) {
634
+ i++;
635
+ j++;
636
+ continue;
637
+ }
638
+ const startI = i;
639
+ const startJ = j;
640
+ while (i < a.length && (j >= b.length || a[i] !== b[j]))
641
+ i++;
642
+ while (j < b.length && (i >= a.length || a[i] !== b[j]))
643
+ j++;
644
+ hunks.push("@@");
645
+ for (let k = startI; k < i; k++)
646
+ hunks.push(`-${a[k]}`);
647
+ for (let k = startJ; k < j; k++)
648
+ hunks.push(`+${b[k]}`);
649
+ }
650
+ return `${header.join("\n")}\n${hunks.join("\n")}\n`;
651
+ }
652
+ export function fleetEditableEquals(a, b) {
653
+ return JSON.stringify(a) === JSON.stringify(b);
654
+ }
@@ -61,14 +61,19 @@ const isTest = (a) => typeof a === "object" && a.oracle === "test";
61
61
  const isJudge = (a) => typeof a === "string" || (typeof a === "object" && a.oracle === "judge");
62
62
  // npm/yarn/pnpm/npx script wrappers need `--` to forward -t to the underlying vitest/jest runner; a bare
63
63
  // runner (vitest/jest) takes -t directly. -t is the shared testNamePattern shorthand both honor.
64
+ // OBS-62: vitest -t treats the name as a regex — escape metachars so a verbatim-titled test matches.
65
+ function escapeRegExp(s) {
66
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
67
+ }
64
68
  // OBS-55: when the base command already contains `--`, append -t after forwarded args — a second `--`
65
69
  // makes vitest treat -t as a positional file filter and the name filter is dropped.
66
70
  export function testFiltered(testCmd, name) {
71
+ const pattern = escapeRegExp(name);
67
72
  const wrapped = /^\s*(npm|yarn|pnpm|npx)\b/.test(testCmd);
68
73
  if (wrapped && /\s--\s/.test(testCmd))
69
- return `${testCmd} -t ${shq(name)}`;
74
+ return `${testCmd} -t ${shq(pattern)}`;
70
75
  const fwd = wrapped ? "-- " : "";
71
- return `${testCmd} ${fwd}-t ${shq(name)}`;
76
+ return `${testCmd} ${fwd}-t ${shq(pattern)}`;
72
77
  }
73
78
  // vitest/jest summary: "Tests N passed | M skipped (T)" — count passed+failed as actually ran.
74
79
  function testsRan(output) {
@@ -1,8 +1,9 @@
1
1
  import type { WorkerResult } from "../adapters/types.js";
2
2
  import type { GateResult } from "./types.js";
3
+ export declare function scopeDiffBase(worktree: string, integrationTip: string): Promise<string>;
3
4
  /** Pure offender split — corpus-replay oracle exercises this exact decision logic. */
4
5
  export declare function dispositionOffenders(offenders: string[], allowDeviations: string[]): {
5
6
  hard: string[];
6
7
  allowed: string[];
7
8
  };
8
- export declare function scopeGate(worktree: string, baseRef: string, files: string[], result: WorkerResult, allowDeviations?: string[]): Promise<GateResult>;
9
+ export declare function scopeGate(worktree: string, integrationTip: string, files: string[], result: WorkerResult, allowDeviations?: string[]): Promise<GateResult>;
@@ -1,5 +1,14 @@
1
1
  import picomatch from "picomatch";
2
2
  import { shGitOk } from "../run/git.js";
3
+ // OBS-61: the live integration tip can advance between attempts (sibling merge) while a resumed
4
+ // worktree stays parented on the old base — merge-base(tip, HEAD) pins the diff to the worktree's
5
+ // creation ancestor so sibling edits cannot leak into scope as false out-of-scope offenders.
6
+ export async function scopeDiffBase(worktree, integrationTip) {
7
+ const head = (await shGitOk("git rev-parse HEAD", worktree)).trim();
8
+ if (head === integrationTip)
9
+ return integrationTip;
10
+ return (await shGitOk(`git merge-base '${integrationTip}' '${head}'`, worktree)).trim();
11
+ }
3
12
  /** Pure offender split — corpus-replay oracle exercises this exact decision logic. */
4
13
  export function dispositionOffenders(offenders, allowDeviations) {
5
14
  const allowedMatch = allowDeviations.length ? picomatch(allowDeviations, { dot: true }) : () => false;
@@ -13,9 +22,10 @@ export function dispositionOffenders(offenders, allowDeviations) {
13
22
  }
14
23
  return { hard, allowed };
15
24
  }
16
- export async function scopeGate(worktree, baseRef, files, result, allowDeviations = []) {
25
+ export async function scopeGate(worktree, integrationTip, files, result, allowDeviations = []) {
17
26
  if (!files.length)
18
27
  return { gate: "scope", pass: true, details: "no file scope declared — unrestricted" };
28
+ const baseRef = await scopeDiffBase(worktree, integrationTip);
19
29
  const changed = (await shGitOk(`git diff --name-only '${baseRef}..HEAD'`, worktree)).trim().split("\n").filter(Boolean);
20
30
  const inScope = picomatch(files, { dot: true }); // byte-identical options to assertWriteScope in src/compile/common.ts
21
31
  const offenders = changed.filter((f) => !inScope(f));
@@ -49,7 +49,7 @@ export interface ProfileDiscount {
49
49
  reason: string;
50
50
  }
51
51
  export declare function resolveHygieneWeight(row: ProfileRow, discounts?: readonly ProfileDiscount[]): HygieneWeight;
52
- export declare function explorationBonus(cell: ProfileCell | undefined): number;
52
+ export declare function explorationBonus(cell: ProfileCell | undefined, cap?: number): number;
53
53
  export declare function classify(r: ProfileRow): 1 | 0.5 | 0 | null;
54
54
  export declare function buildProfile(rows: ProfileRow[], opts?: {
55
55
  halfLifeRuns?: number;
@@ -81,7 +81,9 @@ export interface LearnedScoreTerms {
81
81
  }
82
82
  export declare function learnedScoreTerms(profile: RoutingProfile | undefined, shape: string, chKey: string, channel: string, opts?: {
83
83
  availWeight?: number;
84
+ slaMinutes?: number;
84
85
  }): LearnedScoreTerms;
85
86
  export declare function learnedScore(profile: RoutingProfile | undefined, shape: string, chKey: string, channel: string, opts?: {
86
87
  availWeight?: number;
88
+ slaMinutes?: number;
87
89
  }): number;
@@ -59,10 +59,11 @@ export function resolveHygieneWeight(row, discounts = []) {
59
59
  // yielded no quality obs (quota parks) has spent its budget — stop probing it, don't probe it forever.
60
60
  // ponytail: rank-decay ships in v1.8 (buildProfile below); remaining ceiling = doneMedianMs staleness
61
61
  // (perf term is undecayed) and per-cell EWMA as the finer upgrade path.
62
- export function explorationBonus(cell) {
62
+ // ROUTE-15 PARAM-SHAPE: optional cap threads from routing.explore.cap; undefined ⇒ module default (byte-identical).
63
+ export function explorationBonus(cell, cap = EXPLORE_CAP) {
63
64
  if (!cell)
64
65
  return 0;
65
- return Math.max(0, 1 - cell.dispatches / EXPLORE_CAP); // ∈ [0,1]; exactly 0 once dispatches ≥ cap
66
+ return Math.max(0, 1 - cell.dispatches / cap); // ∈ [0,1]; exactly 0 once dispatches ≥ cap
66
67
  }
67
68
  const QUALITY_FAIL_PARKS = new Set(["ladder-exhausted", "attempt-cap", "gate-fail"]);
68
69
  // Quality observation per row: 1 clean, 0.5 degraded, 0 verified failure, null excluded/unobserved.
@@ -183,6 +184,7 @@ export function cellSummary(cell) {
183
184
  // Per-term decomposition of learnedScore — the single arithmetic source for profile --explain.
184
185
  export function learnedScoreTerms(profile, shape, chKey, channel, opts = {}) {
185
186
  const availWeight = opts.availWeight ?? AVAIL_WEIGHT;
187
+ const refMs = opts.slaMinutes !== undefined ? opts.slaMinutes * 60_000 : REF_MS;
186
188
  const cell = cellOf(profile, shape, chKey, channel);
187
189
  if (!cell)
188
190
  return { quality: NEUTRAL, perf: NEUTRAL, avail: NEUTRAL, overrun: NEUTRAL };
@@ -198,7 +200,7 @@ export function learnedScoreTerms(profile, shape, chKey, channel, opts = {}) {
198
200
  // 0 — a silent ROUTE-07 break. With it, every quotaHits=0 cell is byte-identical to pre-ROUTE-12.
199
201
  const perf = cell.n < MIN_SAMPLES || cell.doneMedianMs === undefined || cell.doneCount < MIN_SAMPLES
200
202
  ? 0
201
- : PERF_WEIGHT * (REF_MS / (REF_MS + cell.doneMedianMs) - 0.5);
203
+ : PERF_WEIGHT * (refMs / (refMs + cell.doneMedianMs) - 0.5);
202
204
  // PENALTY-ONLY availability (ROUTE-12): a throttling channel is less available — deprioritize it without ever
203
205
  // treating quota as a quality signal, ejecting it, or predicting quota. quotaHits=0 ⇒ avail 0 (no-throttle
204
206
  // byte-identity). Gated on dispatches (≥ MIN_SAMPLES > 0 ⇒ positive denominator, no divide-by-zero) INDEPENDENTLY
@@ -1,5 +1,5 @@
1
1
  import { type Assignment, type BillingChannel } from "../adapters/types.js";
2
- import { type TickmarkrConfig } from "../config/config.js";
2
+ import { type TickmarkrConfig, type Tier } from "../config/config.js";
3
3
  import type { Task } from "../graph/schema.js";
4
4
  import { type RoutingProfile } from "./profile.js";
5
5
  export type LadderStep = "retry" | "escalate" | "consult" | "human";
@@ -26,9 +26,16 @@ export interface RoutingPreferContext {
26
26
  doctorFresh: boolean;
27
27
  overlayPreferShapes: ReadonlySet<string>;
28
28
  }
29
+ export interface ExploreContext {
30
+ noExplore?: boolean;
31
+ quality?: boolean;
32
+ }
33
+ export declare const NO_EXPLORE_ENV = "TICKMARKR_NO_EXPLORE";
34
+ export declare const QUALITY_ENV = "TICKMARKR_QUALITY";
35
+ export declare const raiseTier: (tier: Tier) => Tier;
29
36
  export declare class RoutingError extends Error {
30
37
  constructor(msg: string);
31
38
  }
32
39
  export declare function marginalCostRank(c: BillingChannel): number;
33
- export declare function route(task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], profile?: RoutingProfile, preferCtx?: RoutingPreferContext, exclude?: ReadonlySet<string>): Route;
40
+ export declare function route(task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], profile?: RoutingProfile, preferCtx?: RoutingPreferContext, exclude?: ReadonlySet<string>, exploreCtx?: ExploreContext): Route;
34
41
  export declare function nextChannel(current: Assignment, task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], tried: string[], profile?: RoutingProfile, exclude?: ReadonlySet<string>): Assignment | null;
@@ -1,7 +1,27 @@
1
1
  import { channelKey, channelsFromConfig } from "../adapters/types.js";
2
2
  import { TIER_RANK } from "../config/config.js";
3
3
  import { disallowedBy } from "./preference.js";
4
- import { cellOf, EXPLORE_CAP, explorationBonus, learnedScore } from "./profile.js";
4
+ import { cellOf, EXPLORE_CAP, explorationBonus, learnedScore, MIN_SAMPLES } from "./profile.js";
5
+ export const NO_EXPLORE_ENV = "TICKMARKR_NO_EXPLORE";
6
+ export const QUALITY_ENV = "TICKMARKR_QUALITY";
7
+ const exploreCap = (cfg) => cfg.routing.explore?.cap ?? EXPLORE_CAP;
8
+ const qualityOn = (exploreCtx) => !!exploreCtx?.quality || process.env[QUALITY_ENV] === "1";
9
+ export const raiseTier = (tier) => tier === "cheap" ? "mid" : tier === "mid" ? "frontier" : "frontier";
10
+ const exploreOff = (task, cfg, exploreCtx) => {
11
+ if (qualityOn(exploreCtx) || exploreCtx?.noExplore || process.env[NO_EXPLORE_ENV] === "1")
12
+ return true;
13
+ const e = cfg.routing.explore;
14
+ if (!e)
15
+ return false;
16
+ if (e.mode === "off")
17
+ return true;
18
+ if (e.excludeShapes?.includes(task.shape))
19
+ return true;
20
+ const thr = e.excludeComplexityAtOrAbove;
21
+ if (thr != null && task.complexity >= thr)
22
+ return true;
23
+ return false;
24
+ };
5
25
  const autoPreferList = (doc, shape) => {
6
26
  const v = doc?.[shape];
7
27
  return Array.isArray(v) ? v : undefined;
@@ -50,10 +70,36 @@ function withoutExcluded(channels, exclude) {
50
70
  // OBS-57: in-run demotion after consecutive no-trailer windows — route around poisoned channels for the rest of the run.
51
71
  return channels.filter((c) => !exclude.has(channelKey(c)));
52
72
  }
53
- export function route(task, cfg, channels, profile, preferCtx, exclude) {
73
+ const qualityBound = (quality, configFloor, taskFloor) => {
74
+ if (!quality)
75
+ return undefined;
76
+ if (configFloor)
77
+ return `floor ${configFloor}→${raiseTier(configFloor)} (--quality)`;
78
+ if (taskFloor)
79
+ return `floor ${taskFloor}→${raiseTier(taskFloor)} (--quality)`;
80
+ return undefined;
81
+ };
82
+ const maybeSlaLint = (lints, task, profile, slaMinutes, c) => {
83
+ if (slaMinutes === undefined || !profile)
84
+ return;
85
+ const cell = cellOf(profile, task.shape, channelKey(c), c.channel);
86
+ if (!cell?.doneMedianMs || cell.doneCount < MIN_SAMPLES)
87
+ return;
88
+ const slaMs = slaMinutes * 60_000;
89
+ if (cell.doneMedianMs <= slaMs)
90
+ return;
91
+ const medianMin = Math.round(cell.doneMedianMs / 60_000);
92
+ lints.push(`${task.id} (${task.shape}): median ${medianMin}m exceeds sla ${slaMinutes}m — learned perf term references sla ${slaMinutes}m ref`);
93
+ };
94
+ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreCtx) {
54
95
  channels = withoutExcluded(channels, exclude);
55
96
  const lints = [];
56
- const floor = cfg.routing.floors[task.shape];
97
+ const advisoryFloor = cfg.routing.floors[task.shape];
98
+ const quality = qualityOn(exploreCtx);
99
+ const floor = advisoryFloor;
100
+ const slaMinutes = cfg.routing.sla?.[task.shape];
101
+ // ponytail: sla is plan-time advisory only — never thread into learnedScore (would reroute warm rivals).
102
+ const scoreOpts = { availWeight: cfg.routing.learnedTuning?.availWeight };
57
103
  const entry = cfg.routing.map[task.shape];
58
104
  const prefer = effectivePrefer(task.shape, entry, preferCtx);
59
105
  const prefActive = !!(cfg.routing.allow || cfg.routing.deny);
@@ -83,11 +129,12 @@ export function route(task, cfg, channels, profile, preferCtx, exclude) {
83
129
  }
84
130
  };
85
131
  const lintFloor = (tier, what) => {
86
- if (floor && TIER_RANK[tier] < TIER_RANK[floor]) {
87
- lints.push(`${task.id} (${task.shape}): ${what} routes ${tier}, below advisory floor ${floor}`);
132
+ if (advisoryFloor && TIER_RANK[tier] < TIER_RANK[advisoryFloor]) {
133
+ lints.push(`${task.id} (${task.shape}): ${what} routes ${tier}, below advisory floor ${advisoryFloor}`);
88
134
  }
89
135
  };
90
- const taskFloor = task.routingHints?.floor;
136
+ const taskFloorRaw = task.routingHints?.floor;
137
+ const taskFloor = taskFloorRaw && quality ? raiseTier(taskFloorRaw) : taskFloorRaw;
91
138
  const source = task.routingHints?.source;
92
139
  const src = source ? `, ${source}` : ""; // never interpolate a possibly-undefined source
93
140
  // task pin: planner-authored, try-first — degrades on miss or below-floor (D-05, research A3), never throws
@@ -98,6 +145,7 @@ export function route(task, cfg, channels, profile, preferCtx, exclude) {
98
145
  const c = channels.find((c) => c.adapter === taskPin.via && c.model === taskPin.model);
99
146
  if (c && (!taskFloor || TIER_RANK[c.tier] >= TIER_RANK[taskFloor])) {
100
147
  lintFloor(c.tier, "task pin");
148
+ maybeSlaLint(lints, task, profile, slaMinutes, c);
101
149
  return { assignment: toAssignment(c), ladder: ladderFor(task, entry), lints, provenance: `pin ${taskPin.via}:${taskPin.model} (task hint${src})` };
102
150
  }
103
151
  const why = c ? `below task floor ${taskFloor}` : "unavailable";
@@ -109,10 +157,16 @@ export function route(task, cfg, channels, profile, preferCtx, exclude) {
109
157
  disallowedPin(entry.pin.via, entry.pin.model, "map pin (config routing.map)");
110
158
  const c = resolvePin(entry.pin, channels);
111
159
  lintFloor(c.tier, "map pin");
160
+ maybeSlaLint(lints, task, profile, slaMinutes, c);
112
161
  return { assignment: toAssignment(c), ladder: ladderFor(task, entry), lints, provenance: `${degraded}pin ${entry.pin.via}:${entry.pin.model} (config routing.map)` };
113
162
  }
114
163
  const baseTier = entry?.tier ?? floor ?? "cheap";
115
- const minTier = taskFloor && TIER_RANK[taskFloor] > TIER_RANK[baseTier] ? taskFloor : baseTier; // task floor is a hard >= constraint in all paths (D-04)
164
+ let minTier = taskFloor && TIER_RANK[taskFloor] > TIER_RANK[baseTier] ? taskFloor : baseTier; // task floor is a hard >= constraint in all paths (D-04)
165
+ if (quality && advisoryFloor) {
166
+ const raised = raiseTier(advisoryFloor);
167
+ if (TIER_RANK[raised] > TIER_RANK[minTier])
168
+ minTier = raised;
169
+ }
116
170
  if (entry?.tier)
117
171
  lintFloor(entry.tier, "map tier");
118
172
  if (prefActive)
@@ -145,13 +199,15 @@ export function route(task, cfg, channels, profile, preferCtx, exclude) {
145
199
  // is keyed by channelKey alone, which would collide only if a fleet exposed the same adapter:model on
146
200
  // both classes at once — impossible under today's scalar TierEntrySchema.channel.
147
201
  // ROUTE-15: availWeight threads from config as a pure param; undefined ⇒ module default (byte-identical).
148
- const scores = new Map(eligibleRaw.map((c) => [channelKey(c), learnedScore(profile, task.shape, channelKey(c), c.channel, { availWeight: cfg.routing.learnedTuning?.availWeight })]));
202
+ const scores = new Map(eligibleRaw.map((c) => [channelKey(c), learnedScore(profile, task.shape, channelKey(c), c.channel, scoreOpts)]));
149
203
  const scoreOf = (c) => scores.get(channelKey(c));
150
204
  // v1.6 Phase 14: exploration bonus precomputed ONCE (Pitfall 5), a new key ABOVE the score. EXP-01 needs
151
205
  // it above the score (a warm-good incumbent can't permanently outrank an under-cap rival in a static tie);
152
206
  // magnitude-free as its own lexicographic key. EXP-02 holds structurally: pins returned at :67/:78 and the
153
207
  // floor filtered eligibleRaw at :89, all upstream of this block, so the bonus decides only within a static tie.
154
- const bonuses = new Map(eligibleRaw.map((c) => [channelKey(c), explorationBonus(cellOf(profile, task.shape, channelKey(c), c.channel))]));
208
+ const cap = exploreCap(cfg);
209
+ const off = exploreOff(task, cfg, exploreCtx);
210
+ const bonuses = new Map(eligibleRaw.map((c) => [channelKey(c), off ? 0 : explorationBonus(cellOf(profile, task.shape, channelKey(c), c.channel), cap)]));
155
211
  const bonusOf = (c) => bonuses.get(channelKey(c));
156
212
  const staticWinner = [...eligibleRaw].sort(staticCmp)[0];
157
213
  // Phase 34 ROUTE-17: prefer becomes a BAND + group-rep keys so exploration fires ACROSS prefer
@@ -182,7 +238,7 @@ export function route(task, cfg, channels, profile, preferCtx, exclude) {
182
238
  const diffKey = ru ? firstDiff(w, ru) : -1;
183
239
  const probe = diffKey === 1 || diffKey === 6;
184
240
  if (probe) {
185
- learnedChosen = `via exploration probe (dispatches=${cellOf(profile, task.shape, channelKey(w), w.channel)?.dispatches ?? 0} < ${EXPLORE_CAP})`;
241
+ learnedChosen = `via exploration probe (dispatches=${cellOf(profile, task.shape, channelKey(w), w.channel)?.dispatches ?? 0} < ${cap})`;
186
242
  }
187
243
  else if (diffKey === 2 || diffKey === 7) {
188
244
  const n = cellOf(profile, task.shape, channelKey(w), w.channel)?.n ?? 0;
@@ -195,10 +251,11 @@ export function route(task, cfg, channels, profile, preferCtx, exclude) {
195
251
  else {
196
252
  eligible = eligibleRaw.sort(staticCmp); // ROUTE-07/09: literally the v1.5 code path — dead code cannot deviate
197
253
  }
198
- const bound = taskFloor && TIER_RANK[taskFloor] >= TIER_RANK[baseTier] ? `floor ${taskFloor} (task hint${src})` :
199
- entry?.tier ? `tier ${entry.tier} (config routing.map)` :
200
- floor ? `floor ${floor} (config floors)` :
201
- "tier cheap (default)";
254
+ const bound = qualityBound(quality, advisoryFloor, taskFloorRaw) ??
255
+ (taskFloor && TIER_RANK[taskFloor] >= TIER_RANK[baseTier] ? `floor ${taskFloor} (task hint${src})` :
256
+ entry?.tier ? `tier ${entry.tier} (config routing.map)` :
257
+ floor ? `floor ${floor} (config floors)` :
258
+ "tier cheap (default)");
202
259
  // name the key that actually broke the tie: prefer outranks the marginal-cost/tier keys, so if the
203
260
  // winner matched a prefer entry, prefer decided it — not "cheapest sufficient tier" (ROUTE-03, WR-01)
204
261
  const preferVia = preferFromAuto(task.shape, preferCtx)
@@ -206,6 +263,7 @@ export function route(task, cfg, channels, profile, preferCtx, exclude) {
206
263
  : "via prefer";
207
264
  const chosenBy = learnedChosen || (prefer && preferIndex(eligible[0], prefer) < prefer.length
208
265
  ? preferVia : "cheapest sufficient tier");
266
+ maybeSlaLint(lints, task, profile, slaMinutes, eligible[0]);
209
267
  return { assignment: toAssignment(eligible[0]), ladder: ladderFor(task, entry), lints, provenance: `${degraded}${bound}, marginal-cost auto (${chosenBy})`, ...(deviation ? { deviation } : {}) };
210
268
  }
211
269
  export function nextChannel(current, task, cfg, channels, tried, profile, exclude) {
@@ -427,7 +427,7 @@ export async function runDaemon(repoRoot, opts = {}) {
427
427
  const promptFile = writePrompt(journal.dir, t, attempt, feedback, nonce);
428
428
  // OBS-56: state the non-interactive, one-pass finish contract and the OBS-54 stall budget in every
429
429
  // worker prompt, not only consult retry guidance. Prepended so prompt.ts's completion trailer stays last.
430
- const workerContract = `## Harness contract\n- This harness is non-interactive: make one continuous pass; do not stop for questions or follow-up input.\n- You have a ${taskTimeoutMinutes} minute stall window. Budget the full suite once, then commit and emit the completion trailer before it expires.`;
430
+ const workerContract = `## Harness contract\n- This harness is non-interactive: make one continuous pass; do not stop for questions or follow-up input.\n- You have a ${taskTimeoutMinutes} minute stall window. Budget the full suite once, then commit and emit the completion trailer before it expires.\n- Each test: acceptance criterion must exist as a vitest test whose title matches the criterion string verbatim.`; // OBS-64
431
431
  // OBS-47: state the worktree layout contract in the worker prompt (cheap-tier workers were
432
432
  // committing/deleting node_modules and tripping the scope gate). The harness re-asserts the link
433
433
  // itself before gates regardless of what the worker does with it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.46.0",
3
+ "version": "1.47.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",