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.
- package/dist/adapters/model-lints.d.ts +19 -0
- package/dist/adapters/model-lints.js +74 -1
- package/dist/cli/commands/doctor.js +6 -2
- package/dist/cli/commands/fleet.d.ts +5 -0
- package/dist/cli/commands/fleet.js +249 -0
- package/dist/cli/commands/plan.js +7 -1
- package/dist/cli/commands/run.js +34 -17
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +3 -1
- package/dist/compile/native.js +16 -1
- package/dist/config/config.d.ts +42 -0
- package/dist/config/config.js +288 -1
- package/dist/gates/acceptance.js +7 -2
- package/dist/gates/scope.d.ts +2 -1
- package/dist/gates/scope.js +11 -1
- package/dist/route/profile.d.ts +3 -1
- package/dist/route/profile.js +5 -3
- package/dist/route/router.d.ts +9 -2
- package/dist/route/router.js +72 -14
- package/dist/run/daemon.js +1 -1
- package/package.json +1 -1
package/dist/config/config.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/gates/acceptance.js
CHANGED
|
@@ -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(
|
|
74
|
+
return `${testCmd} -t ${shq(pattern)}`;
|
|
70
75
|
const fwd = wrapped ? "-- " : "";
|
|
71
|
-
return `${testCmd} ${fwd}-t ${shq(
|
|
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) {
|
package/dist/gates/scope.d.ts
CHANGED
|
@@ -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,
|
|
9
|
+
export declare function scopeGate(worktree: string, integrationTip: string, files: string[], result: WorkerResult, allowDeviations?: string[]): Promise<GateResult>;
|
package/dist/gates/scope.js
CHANGED
|
@@ -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,
|
|
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));
|
package/dist/route/profile.d.ts
CHANGED
|
@@ -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;
|
package/dist/route/profile.js
CHANGED
|
@@ -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
|
-
|
|
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 /
|
|
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 * (
|
|
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
|
package/dist/route/router.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/route/router.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 (
|
|
87
|
-
lints.push(`${task.id} (${task.shape}): ${what} routes ${tier}, below advisory 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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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} < ${
|
|
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 =
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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) {
|
package/dist/run/daemon.js
CHANGED
|
@@ -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.
|