tickmarkr 1.46.0 → 1.48.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.
@@ -4,7 +4,7 @@ import { loadConfig } from "../../config/config.js";
4
4
  import { pickDriver } from "../../drivers/index.js";
5
5
  import { loadGraph } from "../../graph/graph.js";
6
6
  import { formatSummary, runDaemon } from "../../run/daemon.js";
7
- import { route } from "../../route/router.js";
7
+ import { route, QUALITY_ENV, NO_EXPLORE_ENV } from "../../route/router.js";
8
8
  import { formatJournalNarration, loadRoutingProfile } from "../../run/journal.js";
9
9
  const summaryGreen = (s) => s.failed.length === 0 && s.human.length === 0 && s.blocked.length === 0 && s.pending.length === 0
10
10
  && s.tipVerify !== "failed";
@@ -15,6 +15,8 @@ export async function run(argv, cwd = process.cwd()) {
15
15
  concurrency: { type: "string" },
16
16
  driver: { type: "string" },
17
17
  "route-strict": { type: "boolean" },
18
+ "no-explore": { type: "boolean" },
19
+ quality: { type: "boolean" },
18
20
  },
19
21
  });
20
22
  if (values.concurrency !== undefined) {
@@ -23,21 +25,36 @@ export async function run(argv, cwd = process.cwd()) {
23
25
  throw new Error(`--concurrency must be a positive integer (got ${values.concurrency})`);
24
26
  }
25
27
  const cfg = loadConfig(cwd);
26
- if (values["route-strict"]) {
27
- const adapters = allAdapters();
28
- const health = readDoctor(cwd) ?? (await probeAll(adapters));
29
- const channels = discoverChannels(cfg, adapters, health);
30
- // no preview: the strict pre-flight routes through exactly what the daemon will use (honors the switch)
31
- const profile = loadRoutingProfile(cwd, cfg);
32
- const lints = loadGraph(cwd).tasks.flatMap((t) => route(t, cfg, channels, profile).lints);
33
- if (lints.length)
34
- throw new Error(`--route-strict: routing lints present, refusing to dispatch:\n${lints.join("\n")}`);
28
+ const quality = !!values.quality;
29
+ const noExplore = !!values["no-explore"] || quality;
30
+ const exploreCtx = noExplore || quality ? { noExplore, quality } : undefined;
31
+ if (noExplore)
32
+ process.env[NO_EXPLORE_ENV] = "1";
33
+ if (quality)
34
+ process.env[QUALITY_ENV] = "1";
35
+ try {
36
+ if (values["route-strict"]) {
37
+ const adapters = allAdapters();
38
+ const health = readDoctor(cwd) ?? (await probeAll(adapters));
39
+ const channels = discoverChannels(cfg, adapters, health);
40
+ // no preview: the strict pre-flight routes through exactly what the daemon will use (honors the switch)
41
+ const profile = loadRoutingProfile(cwd, cfg);
42
+ const lints = loadGraph(cwd).tasks.flatMap((t) => route(t, cfg, channels, profile, undefined, undefined, exploreCtx).lints);
43
+ if (lints.length)
44
+ throw new Error(`--route-strict: routing lints present, refusing to dispatch:\n${lints.join("\n")}`);
45
+ }
46
+ const s = await runDaemon(cwd, {
47
+ concurrency: values.concurrency ? Number(values.concurrency) : undefined,
48
+ driver: pickDriver(cfg, values.driver),
49
+ narrate: (event) => console.log(formatJournalNarration(event)),
50
+ });
51
+ const out = `run ${s.runId} finished — ${formatSummary(s)} (merge to main is a human decision)`;
52
+ return { out, code: summaryGreen(s) ? 0 : 2 };
53
+ }
54
+ finally {
55
+ if (noExplore)
56
+ delete process.env[NO_EXPLORE_ENV];
57
+ if (quality)
58
+ delete process.env[QUALITY_ENV];
35
59
  }
36
- const s = await runDaemon(cwd, {
37
- concurrency: values.concurrency ? Number(values.concurrency) : undefined,
38
- driver: pickDriver(cfg, values.driver),
39
- narrate: (event) => console.log(formatJournalNarration(event)),
40
- });
41
- const out = `run ${s.runId} finished — ${formatSummary(s)} (merge to main is a human decision)`;
42
- return { out, code: summaryGreen(s) ? 0 : 2 };
43
60
  }
@@ -5,7 +5,7 @@ export type CommandResult = string | {
5
5
  };
6
6
  export type CommandMap = Record<string, (argv: string[]) => Promise<CommandResult>>;
7
7
  export declare const COMMANDS: CommandMap;
8
- export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n run execute the graph (--concurrency N --driver herdr|subprocess --route-strict)\n status live run state\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n approve <id> <task> approve a parked human gate (--by <name> --reason <text>); takes effect on resume";
8
+ export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n run execute the graph (--concurrency N --driver herdr|subprocess --route-strict)\n status live run state\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n approve <id> <task> approve a parked human gate (--by <name> --reason <text>); takes effect on resume";
9
9
  export declare function dispatch(cmd: string | undefined, argv: string[], commands?: CommandMap): Promise<{
10
10
  out: string;
11
11
  code: number;
package/dist/cli/index.js CHANGED
@@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url";
4
4
  import { approve } from "./commands/approve.js";
5
5
  import { compile } from "./commands/compile.js";
6
6
  import { doctor } from "./commands/doctor.js";
7
+ import { fleet } from "./commands/fleet.js";
7
8
  import { init } from "./commands/init.js";
8
9
  import { plan } from "./commands/plan.js";
9
10
  import { profile } from "./commands/profile.js";
@@ -16,7 +17,7 @@ import { unlock } from "./commands/unlock.js";
16
17
  import { version } from "./commands/version.js";
17
18
  const normalize = (r) => typeof r === "string" ? { out: r, code: 0 } : r;
18
19
  export const COMMANDS = {
19
- init, doctor, compile, scope, plan, run, status, resume, report, profile, unlock, approve, version,
20
+ init, doctor, fleet, compile, scope, plan, run, status, resume, report, profile, unlock, approve, version,
20
21
  };
21
22
  const VERSION_FLAGS = new Set(["version", "--version", "-v"]);
22
23
  const HELP_CMDS = new Set(["help", "-h", "--help"]);
@@ -25,6 +26,7 @@ export const USAGE = `tickmarkr — spec-driven orchestration harness for AI cod
25
26
  usage: tickmarkr <command>
26
27
  init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs
27
28
  doctor re-probe adapters, herdr, auth; print capability matrix
29
+ fleet interactive fleet editor (fleet --print for CI drift checks)
28
30
  compile <src> spec → .tickmarkr/graph.json (fails without acceptance criteria)
29
31
  scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)
30
32
  plan dry-run routing table + cost estimate + floor lints
@@ -23,7 +23,7 @@ export function compileNative(file) {
23
23
  for (const line of content.split("\n")) {
24
24
  const heading = line.match(HEAD_RE);
25
25
  if (heading) {
26
- drafts.push({ id: heading[1], title: heading[2].trim(), fields: {}, acceptance: [], gates: [], hasGates: false, list: null });
26
+ drafts.push({ id: heading[1], title: heading[2].trim(), fields: {}, acceptance: [], gates: [], hasGates: false, list: null, goalContinuation: false });
27
27
  continue;
28
28
  }
29
29
  const draft = drafts.at(-1);
@@ -41,15 +41,30 @@ export function compileNative(file) {
41
41
  draft.list = name;
42
42
  if (name === "gates")
43
43
  draft.hasGates = true;
44
+ draft.goalContinuation = false;
44
45
  }
45
46
  else {
46
47
  if (name === "goal" && !value)
47
48
  invalid(draft.id, field[1], "must not be empty");
48
49
  draft.fields[name] = value;
50
+ // OBS-60: carry every indented continuation line after `- goal:` into the compiled goal.
51
+ draft.goalContinuation = name === "goal";
49
52
  draft.list = null;
50
53
  }
51
54
  continue;
52
55
  }
56
+ // OBS-60: multiline goals — indented prose after `- goal:` is appended until the next field.
57
+ if (draft.goalContinuation) {
58
+ if ((line.startsWith(" ") || line.startsWith("\t")) && !NESTED_RE.test(line)) {
59
+ draft.fields.goal += `\n${line.trim()}`;
60
+ continue;
61
+ }
62
+ if (!line.trim()) {
63
+ draft.fields.goal += "\n";
64
+ continue;
65
+ }
66
+ draft.goalContinuation = false;
67
+ }
53
68
  const nested = line.match(NESTED_RE);
54
69
  if (nested && draft.list) {
55
70
  const value = nested[1].trim();
@@ -32,6 +32,7 @@ export declare const TierEntrySchema: z.ZodObject<{
32
32
  mid: "mid";
33
33
  frontier: "frontier";
34
34
  }>>;
35
+ windows: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
35
36
  }, z.core.$strip>;
36
37
  export type TierEntry = z.infer<typeof TierEntrySchema>;
37
38
  export declare const ModelPricingSchema: z.ZodObject<{
@@ -85,6 +86,16 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
85
86
  halfLifeRuns: z.ZodOptional<z.ZodNumber>;
86
87
  availWeight: z.ZodOptional<z.ZodNumber>;
87
88
  }, z.core.$strip>>;
89
+ explore: z.ZodOptional<z.ZodObject<{
90
+ mode: z.ZodOptional<z.ZodEnum<{
91
+ on: "on";
92
+ off: "off";
93
+ }>>;
94
+ excludeShapes: z.ZodOptional<z.ZodArray<z.ZodString>>;
95
+ excludeComplexityAtOrAbove: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
96
+ cap: z.ZodOptional<z.ZodNumber>;
97
+ }, z.core.$strip>>;
98
+ sla: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
88
99
  allowUnverifiedModels: z.ZodBoolean;
89
100
  allow: z.ZodOptional<z.ZodObject<{
90
101
  adapters: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -106,6 +117,7 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
106
117
  mid: "mid";
107
118
  frontier: "frontier";
108
119
  }>>;
120
+ windows: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
109
121
  }, z.core.$strip>>;
110
122
  pricing: z.ZodRecord<z.ZodString, z.ZodNumber>;
111
123
  cost: z.ZodOptional<z.ZodObject<{
@@ -194,4 +206,34 @@ export type InitConfigOverlay = {
194
206
  };
195
207
  };
196
208
  export declare function configTemplate(overlay?: InitConfigOverlay): string;
209
+ /** Fleet-owned overlay keys — the only config surface `tickmarkr fleet` may write. */
210
+ export declare const FLEET_OVERLAY_KEYS: readonly ["routing", "tiers"];
211
+ export type FleetTierAssignment = {
212
+ tier: Tier;
213
+ provenance?: string;
214
+ };
215
+ export type FleetEditable = {
216
+ denyAdapters: string[];
217
+ denyModels: string[];
218
+ tiers: Record<string, Record<string, FleetTierAssignment | null>>;
219
+ map: Record<string, MapEntry>;
220
+ floors: Record<string, Tier>;
221
+ };
222
+ export declare function repoOverlayPath(repoRoot: string): string;
223
+ export declare function readOverlayFile(path: string): Record<string, unknown>;
224
+ export declare function fleetEditableFromConfig(cfg: TickmarkrConfig): FleetEditable;
225
+ /** Resolve which layer last set a dotted fleet path (defaults < global < repo). */
226
+ export declare function fleetKeyLayer(repoRoot: string, dotted: string, opts?: {
227
+ globalDir?: string;
228
+ }): "defaults" | "global" | "repo";
229
+ /** Non-interactive fleet state for CI drift checks (`tickmarkr fleet --print`). */
230
+ export declare function formatFleetPrint(repoRoot: string, opts?: {
231
+ globalDir?: string;
232
+ }): string;
233
+ /** Build the repo overlay fragment fleet would write for edits since session start. */
234
+ export declare function fleetRepoOverlayFromDelta(initial: FleetEditable, edited: FleetEditable, existingRepo?: Record<string, unknown>): Record<string, unknown>;
235
+ export declare function repoOverlayYaml(overlay: Record<string, unknown>, provenance?: Record<string, Record<string, string>>): string;
236
+ export declare function serializeFleetOverlay(overlay: Record<string, unknown>, provenance?: Record<string, Record<string, string>>): string;
237
+ export declare function unifiedYamlDiff(before: string, after: string, label?: string): string;
238
+ export declare function fleetEditableEquals(a: FleetEditable, b: FleetEditable): boolean;
197
239
  export {};
@@ -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(),
@@ -233,6 +244,18 @@ export const DEFAULT_CONFIG = {
233
244
  vendor: "xai", channel: "sub",
234
245
  models: { "grok-4.5": "mid", "grok-composer-2.5-fast": "cheap" },
235
246
  },
247
+ // Native kimi CLI (Phase 48). kimi-code/k3 → frontier: 81.2 FrontierSWE, 88.3 Terminal-Bench 2.1,
248
+ // top-3 across six coding benchmarks (research 2026-07-17, K3 released 2026-07-16).
249
+ // kimi-code/kimi-for-coding → mid (K2.7 Coding, 262k ctx). kimi-code/kimi-for-coding-highspeed →
250
+ // cheap (fast K2.7 variant). Ids live-verified `kimi provider list --json` 2026-07-17, kimi 0.27.0.
251
+ kimi: {
252
+ vendor: "moonshot", channel: "sub",
253
+ models: {
254
+ "kimi-code/k3": "frontier",
255
+ "kimi-code/kimi-for-coding": "mid",
256
+ "kimi-code/kimi-for-coding-highspeed": "cheap",
257
+ },
258
+ },
236
259
  },
237
260
  pricing: { cheap: 0.1, mid: 0.5, frontier: 2.5 },
238
261
  gates: { diffCap: DEFAULT_DIFF_CAP },
@@ -305,6 +328,8 @@ export function configTemplate(overlay) {
305
328
  # migration: frontier
306
329
  # 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
330
  # learnedTuning: { halfLifeRuns: 5, availWeight: 0.05 } # optional; defaults byte-identical
331
+ # explore: { mode: on, excludeShapes: [], excludeComplexityAtOrAbove: null, cap: 5 } # optional; absent ⇒ byte-identical
332
+ # sla: { implement: 15 } # optional per-shape minutes — advisory plan lint only; absent ⇒ no lint
308
333
  # allow: { adapters: [claude-code, codex] } # optional fleet allowlist; presence activates even if empty (fail-closed)
309
334
  # deny: { models: [codex:gpt-5.5] } # optional fleet denylist; deny beats allow on conflict
310
335
  # # entry grammar: adapter id | model id | adapter:model (entries in either list accept all three forms)
@@ -365,3 +390,277 @@ export function configTemplate(overlay) {
365
390
  const nl = base.indexOf("\n");
366
391
  return `${base.slice(0, nl + 1)}${lines.join("\n")}\n${base.slice(nl + 1)}`;
367
392
  }
393
+ /** Fleet-owned overlay keys — the only config surface `tickmarkr fleet` may write. */
394
+ export const FLEET_OVERLAY_KEYS = ["routing", "tiers"];
395
+ export function repoOverlayPath(repoRoot) {
396
+ return join(repoRoot, stateDirName(repoRoot), "config.yaml");
397
+ }
398
+ export function readOverlayFile(path) {
399
+ const raw = readYaml(path);
400
+ if (raw === undefined || raw === null)
401
+ return {};
402
+ if (typeof raw !== "object" || Array.isArray(raw))
403
+ throw new ConfigError(`invalid overlay at ${path}`);
404
+ return raw;
405
+ }
406
+ export function fleetEditableFromConfig(cfg) {
407
+ const tiers = {};
408
+ for (const [adapter, entry] of Object.entries(cfg.tiers)) {
409
+ tiers[adapter] = {};
410
+ for (const [model, tier] of Object.entries(entry.models))
411
+ tiers[adapter][model] = { tier };
412
+ }
413
+ return {
414
+ denyAdapters: [...(cfg.routing.deny?.adapters ?? [])].sort(),
415
+ denyModels: [...(cfg.routing.deny?.models ?? [])].sort(),
416
+ tiers,
417
+ map: structuredClone(cfg.routing.map),
418
+ floors: { ...cfg.routing.floors },
419
+ };
420
+ }
421
+ function fleetSubset(obj) {
422
+ const out = {};
423
+ for (const k of FLEET_OVERLAY_KEYS) {
424
+ if (obj[k] !== undefined)
425
+ out[k] = obj[k];
426
+ }
427
+ return out;
428
+ }
429
+ /** Resolve which layer last set a dotted fleet path (defaults < global < repo). */
430
+ export function fleetKeyLayer(repoRoot, dotted, opts = {}) {
431
+ const gdir = opts.globalDir ?? globalConfigDir();
432
+ const globalRaw = readOverlayFile(join(gdir, "config.yaml"));
433
+ const repoRaw = readOverlayFile(repoOverlayPath(repoRoot));
434
+ const parts = dotted.split(".");
435
+ const at = (layer) => {
436
+ let cur = layer;
437
+ for (const p of parts) {
438
+ if (cur === undefined || cur === null || typeof cur !== "object" || Array.isArray(cur))
439
+ return undefined;
440
+ cur = cur[p];
441
+ }
442
+ return cur;
443
+ };
444
+ if (at(repoRaw) !== undefined)
445
+ return "repo";
446
+ if (at(globalRaw) !== undefined)
447
+ return "global";
448
+ return "defaults";
449
+ }
450
+ /** Non-interactive fleet state for CI drift checks (`tickmarkr fleet --print`). */
451
+ export function formatFleetPrint(repoRoot, opts = {}) {
452
+ const gdir = opts.globalDir ?? globalConfigDir();
453
+ const effective = loadConfig(repoRoot, { globalDir: gdir });
454
+ const editable = fleetEditableFromConfig(effective);
455
+ const lines = ["# tickmarkr fleet — effective state (repo > global > defaults)"];
456
+ const annotate = (dotted, yaml) => `${yaml} # ${fleetKeyLayer(repoRoot, dotted, opts)}`;
457
+ const denyAdapters = editable.denyAdapters;
458
+ const denyModels = editable.denyModels;
459
+ if (denyAdapters.length || denyModels.length) {
460
+ lines.push("routing:");
461
+ lines.push(" deny:");
462
+ if (denyAdapters.length)
463
+ lines.push(annotate("routing.deny.adapters", ` adapters: ${stringify(denyAdapters).trim()}`));
464
+ if (denyModels.length)
465
+ lines.push(annotate("routing.deny.models", ` models: ${stringify(denyModels).trim()}`));
466
+ }
467
+ const mapKeys = Object.keys(editable.map).sort();
468
+ if (mapKeys.length) {
469
+ if (!lines.some((l) => l === "routing:"))
470
+ lines.push("routing:");
471
+ lines.push(" map:");
472
+ for (const shape of mapKeys) {
473
+ const entry = editable.map[shape];
474
+ const body = stringify(entry, { indent: 4 }).trim().split("\n").map((l) => ` ${l}`).join("\n");
475
+ lines.push(annotate(`routing.map.${shape}`, ` ${shape}:`));
476
+ lines.push(body.split("\n").slice(1).join("\n"));
477
+ }
478
+ }
479
+ const floorKeys = Object.keys(editable.floors).sort();
480
+ if (floorKeys.length) {
481
+ if (!lines.some((l) => l === "routing:"))
482
+ lines.push("routing:");
483
+ lines.push(annotate("routing.floors", ` floors: ${stringify(Object.fromEntries(floorKeys.map((k) => [k, editable.floors[k]]))).trim().replace(/^/gm, " ")}`));
484
+ }
485
+ const tierAdapters = Object.keys(editable.tiers).sort();
486
+ if (tierAdapters.length) {
487
+ lines.push("tiers:");
488
+ for (const adapter of tierAdapters) {
489
+ const models = editable.tiers[adapter];
490
+ const modelIds = Object.keys(models).sort();
491
+ if (!modelIds.length)
492
+ continue;
493
+ lines.push(` ${adapter}:`);
494
+ lines.push(" models:");
495
+ for (const model of modelIds) {
496
+ const v = models[model];
497
+ if (v === null)
498
+ lines.push(` ${model}: null`);
499
+ else
500
+ lines.push(annotate(`tiers.${adapter}.models.${model}`, ` ${model}: ${v.tier}`));
501
+ }
502
+ }
503
+ }
504
+ return `${lines.join("\n")}\n`;
505
+ }
506
+ function sortedUnique(xs) {
507
+ return [...new Set(xs)].sort();
508
+ }
509
+ /** Build the repo overlay fragment fleet would write for edits since session start. */
510
+ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}) {
511
+ if (fleetEditableEquals(initial, edited))
512
+ return {};
513
+ const out = structuredClone(existingRepo);
514
+ const routing = { ...out.routing };
515
+ let routingTouched = false;
516
+ const denyChanged = sortedUnique(initial.denyAdapters).join() !== sortedUnique(edited.denyAdapters).join()
517
+ || sortedUnique(initial.denyModels).join() !== sortedUnique(edited.denyModels).join();
518
+ if (denyChanged) {
519
+ routing.deny = {
520
+ adapters: edited.denyAdapters.length ? edited.denyAdapters : null,
521
+ models: edited.denyModels.length ? edited.denyModels : null,
522
+ };
523
+ routingTouched = true;
524
+ }
525
+ const mapDelta = {};
526
+ for (const shape of new Set([...Object.keys(initial.map), ...Object.keys(edited.map)])) {
527
+ if (JSON.stringify(initial.map[shape]) !== JSON.stringify(edited.map[shape]))
528
+ mapDelta[shape] = edited.map[shape];
529
+ }
530
+ if (Object.keys(mapDelta).length) {
531
+ routing.map = { ...routing.map, ...mapDelta };
532
+ routingTouched = true;
533
+ }
534
+ const floorDelta = {};
535
+ for (const shape of new Set([...Object.keys(initial.floors), ...Object.keys(edited.floors)])) {
536
+ if (initial.floors[shape] !== edited.floors[shape])
537
+ floorDelta[shape] = edited.floors[shape];
538
+ }
539
+ if (Object.keys(floorDelta).length) {
540
+ routing.floors = { ...routing.floors, ...floorDelta };
541
+ routingTouched = true;
542
+ }
543
+ if (routingTouched)
544
+ out.routing = routing;
545
+ const tiersOut = {
546
+ ...out.tiers,
547
+ };
548
+ let tiersTouched = false;
549
+ const adapters = new Set([...Object.keys(initial.tiers), ...Object.keys(edited.tiers)]);
550
+ for (const adapter of adapters) {
551
+ const models = new Set([
552
+ ...Object.keys(initial.tiers[adapter] ?? {}),
553
+ ...Object.keys(edited.tiers[adapter] ?? {}),
554
+ ]);
555
+ const modelDelta = {};
556
+ for (const model of models) {
557
+ const a = initial.tiers[adapter]?.[model];
558
+ const b = edited.tiers[adapter]?.[model];
559
+ if (JSON.stringify(a) !== JSON.stringify(b)) {
560
+ modelDelta[model] = b === null || b === undefined ? null : b.tier;
561
+ }
562
+ }
563
+ if (Object.keys(modelDelta).length) {
564
+ tiersOut[adapter] = { models: { ...tiersOut[adapter]?.models, ...modelDelta } };
565
+ tiersTouched = true;
566
+ }
567
+ }
568
+ if (tiersTouched)
569
+ out.tiers = tiersOut;
570
+ return out;
571
+ }
572
+ export function repoOverlayYaml(overlay, provenance = {}) {
573
+ if (!Object.keys(overlay).length)
574
+ return "";
575
+ const fleet = fleetSubset(overlay);
576
+ const fleetBody = serializeFleetOverlay(fleet, provenance);
577
+ const rest = { ...overlay };
578
+ for (const k of FLEET_OVERLAY_KEYS)
579
+ delete rest[k];
580
+ if (!Object.keys(rest).length)
581
+ return fleetBody;
582
+ const head = stringify(rest).trimEnd();
583
+ return fleetBody ? `${head}\n${fleetBody}` : `${head}\n`;
584
+ }
585
+ export function serializeFleetOverlay(overlay, provenance = {}) {
586
+ if (!Object.keys(overlay).length)
587
+ return "";
588
+ const lines = [];
589
+ const today = new Date().toISOString().slice(0, 10);
590
+ const routing = overlay.routing;
591
+ if (routing) {
592
+ lines.push("routing:");
593
+ const deny = routing.deny;
594
+ if (deny) {
595
+ lines.push(" deny:");
596
+ if (deny.adapters)
597
+ lines.push(` adapters: ${stringify(deny.adapters).trim()}`);
598
+ if (deny.models)
599
+ lines.push(` models: ${stringify(deny.models).trim()}`);
600
+ }
601
+ if (routing.map) {
602
+ lines.push(" map:");
603
+ for (const [shape, entry] of Object.entries(routing.map)) {
604
+ lines.push(` ${shape}:`);
605
+ const body = stringify(entry, { indent: 2 }).trim().split("\n");
606
+ lines.push(...body.map((l) => ` ${l}`));
607
+ }
608
+ }
609
+ if (routing.floors)
610
+ lines.push(` floors: ${stringify(routing.floors).trim().replace(/^/gm, " ")}`);
611
+ }
612
+ const tiers = overlay.tiers;
613
+ if (tiers) {
614
+ lines.push("tiers:");
615
+ for (const [adapter, entry] of Object.entries(tiers)) {
616
+ lines.push(` ${adapter}:`);
617
+ if (entry.vendor)
618
+ lines.push(` vendor: ${entry.vendor}`);
619
+ if (entry.channel)
620
+ lines.push(` channel: ${entry.channel}`);
621
+ lines.push(" models:");
622
+ for (const [model, tier] of Object.entries(entry.models ?? {})) {
623
+ if (tier === null)
624
+ lines.push(` ${model}: null`);
625
+ else {
626
+ const note = provenance[adapter]?.[model];
627
+ const suffix = note ? ` # ${note} — fleet ${today}` : "";
628
+ lines.push(` ${model}: ${tier}${suffix}`);
629
+ }
630
+ }
631
+ }
632
+ }
633
+ return `${lines.join("\n")}\n`;
634
+ }
635
+ export function unifiedYamlDiff(before, after, label = "config overlay") {
636
+ const a = before.split("\n");
637
+ const b = after.split("\n");
638
+ if (before === after)
639
+ return "";
640
+ const header = [`--- ${label} (current)`, `+++ ${label} (proposed)`];
641
+ const hunks = [];
642
+ let i = 0;
643
+ let j = 0;
644
+ while (i < a.length || j < b.length) {
645
+ if (i < a.length && j < b.length && a[i] === b[j]) {
646
+ i++;
647
+ j++;
648
+ continue;
649
+ }
650
+ const startI = i;
651
+ const startJ = j;
652
+ while (i < a.length && (j >= b.length || a[i] !== b[j]))
653
+ i++;
654
+ while (j < b.length && (i >= a.length || a[i] !== b[j]))
655
+ j++;
656
+ hunks.push("@@");
657
+ for (let k = startI; k < i; k++)
658
+ hunks.push(`-${a[k]}`);
659
+ for (let k = startJ; k < j; k++)
660
+ hunks.push(`+${b[k]}`);
661
+ }
662
+ return `${header.join("\n")}\n${hunks.join("\n")}\n`;
663
+ }
664
+ export function fleetEditableEquals(a, b) {
665
+ return JSON.stringify(a) === JSON.stringify(b);
666
+ }
@@ -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;