tickmarkr 1.91.0 → 1.92.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/README.md +12 -9
- package/dist/adapters/model-lints.d.ts +1 -0
- package/dist/adapters/model-lints.js +29 -1
- package/dist/brand.d.ts +2 -0
- package/dist/brand.js +13 -0
- package/dist/cli/commands/doctor.js +34 -6
- package/dist/cli/commands/fleet-picker.js +1 -1
- package/dist/cli/commands/fleet.d.ts +4 -4
- package/dist/cli/commands/fleet.js +168 -35
- package/dist/cli/commands/init.js +6 -4
- package/dist/config/config.d.ts +34 -1
- package/dist/config/config.js +96 -5
- package/dist/config/fleet-overlay.d.ts +9 -2
- package/dist/config/fleet-overlay.js +139 -10
- package/dist/route/preference.d.ts +1 -1
- package/dist/route/preference.js +12 -2
- package/dist/route/router.js +31 -2
- package/dist/tui/ink/fleet-app.d.ts +50 -8
- package/dist/tui/ink/fleet-app.js +1210 -839
- package/dist/tui/ink/frame.d.ts +62 -0
- package/dist/tui/ink/frame.js +204 -0
- package/dist/tui/ink/init-app.d.ts +2 -1
- package/dist/tui/ink/init-app.js +28 -100
- package/package.json +1 -1
package/dist/config/config.d.ts
CHANGED
|
@@ -16,11 +16,26 @@ declare const ModeEnum: z.ZodEnum<{
|
|
|
16
16
|
}>;
|
|
17
17
|
export type RoutingMode = z.infer<typeof ModeEnum>;
|
|
18
18
|
export declare const INTEGRITY_FLOOR_SHAPES: readonly ["plan", "spec", "migration", "ui"];
|
|
19
|
+
export declare const PoolSchema: z.ZodObject<{
|
|
20
|
+
mode: z.ZodEnum<{
|
|
21
|
+
any: "any";
|
|
22
|
+
ordered: "ordered";
|
|
23
|
+
}>;
|
|
24
|
+
channels: z.ZodArray<z.ZodString>;
|
|
25
|
+
}, z.core.$strip>;
|
|
26
|
+
export type PoolEntry = z.infer<typeof PoolSchema>;
|
|
19
27
|
export declare const MapEntrySchema: z.ZodObject<{
|
|
20
28
|
pin: z.ZodOptional<z.ZodObject<{
|
|
21
29
|
via: z.ZodString;
|
|
22
30
|
model: z.ZodString;
|
|
23
31
|
}, z.core.$strip>>;
|
|
32
|
+
pool: z.ZodOptional<z.ZodObject<{
|
|
33
|
+
mode: z.ZodEnum<{
|
|
34
|
+
any: "any";
|
|
35
|
+
ordered: "ordered";
|
|
36
|
+
}>;
|
|
37
|
+
channels: z.ZodArray<z.ZodString>;
|
|
38
|
+
}, z.core.$strip>>;
|
|
24
39
|
tier: z.ZodOptional<z.ZodPipe<z.ZodOptional<z.ZodNull>, z.ZodTransform<undefined, null | undefined>>>;
|
|
25
40
|
prefer: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
26
41
|
escalate: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -139,6 +154,13 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
|
|
|
139
154
|
via: z.ZodString;
|
|
140
155
|
model: z.ZodString;
|
|
141
156
|
}, z.core.$strip>>;
|
|
157
|
+
pool: z.ZodOptional<z.ZodObject<{
|
|
158
|
+
mode: z.ZodEnum<{
|
|
159
|
+
any: "any";
|
|
160
|
+
ordered: "ordered";
|
|
161
|
+
}>;
|
|
162
|
+
channels: z.ZodArray<z.ZodString>;
|
|
163
|
+
}, z.core.$strip>>;
|
|
142
164
|
tier: z.ZodOptional<z.ZodPipe<z.ZodOptional<z.ZodNull>, z.ZodTransform<undefined, null | undefined>>>;
|
|
143
165
|
prefer: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
144
166
|
escalate: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -331,7 +353,18 @@ export type FleetEditable = {
|
|
|
331
353
|
};
|
|
332
354
|
export declare function repoOverlayPath(repoRoot: string): string;
|
|
333
355
|
export declare function readOverlayFile(path: string): Record<string, unknown>;
|
|
334
|
-
|
|
356
|
+
/** Grammar A1 membership test: does any discovered universe row cover this deny/allow entry
|
|
357
|
+
* (adapter id, bare model id, or adapter:model)? Uncovered entries name channels the probe
|
|
358
|
+
* did not serve this session (failed auth, rate limit, retired sku) — they are NOT editable
|
|
359
|
+
* membership state and must survive a membership write verbatim, never be re-derived away. */
|
|
360
|
+
export declare function universeCovers(universe: {
|
|
361
|
+
adapter: string;
|
|
362
|
+
models: string[];
|
|
363
|
+
}[], entry: string): boolean;
|
|
364
|
+
export declare function fleetEditableFromConfig(cfg: TickmarkrConfig, universe?: {
|
|
365
|
+
adapter: string;
|
|
366
|
+
models: string[];
|
|
367
|
+
}[]): FleetEditable;
|
|
335
368
|
/** Resolve which layer last set a dotted fleet path (defaults < global < repo). */
|
|
336
369
|
export declare function fleetKeyLayer(repoRoot: string, dotted: string, opts?: {
|
|
337
370
|
globalDir?: string;
|
package/dist/config/config.js
CHANGED
|
@@ -24,6 +24,13 @@ const ModeEnum = z.enum(ROUTING_MODES, {
|
|
|
24
24
|
// Integrity set (weak-oracle shapes, OBS-68..70 class): no mode may resolve these below frontier.
|
|
25
25
|
// Only an explicit operator floor line can — and it draws a standing plan lint every load.
|
|
26
26
|
export const INTEGRITY_FLOOR_SHAPES = ["plan", "spec", "migration", "ui"];
|
|
27
|
+
// v1.92: a pool is a shape's OWN closed candidate set — mode "any" lets the economy engine pick
|
|
28
|
+
// inside it (cost, then tier); mode "ordered" walks the declaration order, first live wins. An
|
|
29
|
+
// exhausted pool fails loud at plan (pin precedent, fail-closed).
|
|
30
|
+
export const PoolSchema = z.object({
|
|
31
|
+
mode: z.enum(["any", "ordered"]),
|
|
32
|
+
channels: z.array(z.string().regex(/^[^:\s]+:\S.*$/, "pool channels are adapter:model")).min(1),
|
|
33
|
+
});
|
|
27
34
|
// v1.52 T5: tier is no longer a map-entry band authority — routing.floors is the only tier
|
|
28
35
|
// source left (v1.51 T3 deprecated this field with a promise to remove it here). Only the legacy
|
|
29
36
|
// `tier: null` tombstone still parses (and normalizes to absent); any real value fails config
|
|
@@ -32,6 +39,7 @@ export const INTEGRITY_FLOOR_SHAPES = ["plan", "spec", "migration", "ui"];
|
|
|
32
39
|
// outright) so a map entry can never carry a real tier value again, structurally.
|
|
33
40
|
export const MapEntrySchema = z.object({
|
|
34
41
|
pin: z.object({ via: z.string(), model: z.string() }).optional(),
|
|
42
|
+
pool: PoolSchema.optional(),
|
|
35
43
|
tier: z
|
|
36
44
|
.null({
|
|
37
45
|
error: (iss) => `routing.map entry tier ${JSON.stringify(iss.input)} is no longer a band authority — move this value to routing.floors.<shape> (only "tier: null" tombstones still parse)`,
|
|
@@ -272,7 +280,26 @@ export const TickmarkrConfigSchema = z.object({
|
|
|
272
280
|
routing: z.object({
|
|
273
281
|
// v1.51 T1: preset expanded into floors by loadConfig — absent ⇒ risk-based ⇒ byte-identical routing.
|
|
274
282
|
mode: ModeEnum.optional(),
|
|
275
|
-
|
|
283
|
+
// v1.92: pool exclusivity — the record-level superRefine (not MapEntrySchema's) because the
|
|
284
|
+
// error must name the shape. Fail-closed: both keys present fails config load.
|
|
285
|
+
map: z.record(z.string(), MapEntrySchema).superRefine((map, ctx) => {
|
|
286
|
+
for (const [shape, entry] of Object.entries(map)) {
|
|
287
|
+
if (!entry.pool)
|
|
288
|
+
continue;
|
|
289
|
+
if (entry.pin) {
|
|
290
|
+
ctx.addIssue({
|
|
291
|
+
code: "custom", path: [shape, "pool"],
|
|
292
|
+
message: `routing.map.${shape}: pin and pool are one declaration — a pin is pool {mode: ordered, channels:[one]}; keep one`,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
if (entry.prefer) {
|
|
296
|
+
ctx.addIssue({
|
|
297
|
+
code: "custom", path: [shape, "pool"],
|
|
298
|
+
message: `routing.map.${shape}: pool already decides the candidate set — prefer is the OPEN bias; keep one`,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}),
|
|
276
303
|
floors: z.record(z.string(), TierEnum),
|
|
277
304
|
learned: z.enum(["on", "off"]), // v1.6 ROUTE-09 kill switch; a typo (offf) fails loud via safeParse
|
|
278
305
|
// v1.9 ROUTE-15 — optional overrides for profile.ts HALF_LIFE_RUNS/AVAIL_WEIGHT; absent ⇒ byte-identical defaults.
|
|
@@ -529,6 +556,19 @@ function deepMerge(base, over, path = []) {
|
|
|
529
556
|
return over;
|
|
530
557
|
}
|
|
531
558
|
const out = { ...base };
|
|
559
|
+
// v1.92: pin/pool/prefer form ONE routing declaration slot per shape — a higher layer that
|
|
560
|
+
// declares any of them overrides the lower layer's whole slot atomically, so a seed prefer can
|
|
561
|
+
// never merge beside an overlay pool into a same-shape conflict the author never wrote (the
|
|
562
|
+
// map-entry exclusivity refine still rejects duplicates written in ONE document). Tombstones
|
|
563
|
+
// (explicit null) keep their precise per-key semantics via the loop below.
|
|
564
|
+
if (path.length === 3 && path[0] === "routing" && path[1] === "map") {
|
|
565
|
+
const declared = over;
|
|
566
|
+
if (declared.pin != null || declared.pool != null || declared.prefer != null) {
|
|
567
|
+
delete out.pin;
|
|
568
|
+
delete out.pool;
|
|
569
|
+
delete out.prefer;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
532
572
|
for (const [k, v] of Object.entries(over)) {
|
|
533
573
|
if (v === null) {
|
|
534
574
|
// T19: null is a declared multi-vendor state only at tiers.<adapter>.vendor. Every other null
|
|
@@ -686,13 +726,16 @@ export function configTemplate(overlay) {
|
|
|
686
726
|
# implement: { prefer: [cursor-agent, codex] }
|
|
687
727
|
# migration: { pin: { via: claude-code, model: fable } }
|
|
688
728
|
# tests: { tier: null } # tombstone: null removes a legacy map tier (a real tier value now fails config load — move the band to routing.floors.tests)
|
|
729
|
+
# implement: { pool: { mode: any, channels: [codex:gpt-5.6-luna, kimi:kimi-code/k3] } }
|
|
730
|
+
# # pool: the shape's OWN candidate set — any: economy engine picks inside it;
|
|
731
|
+
# # ordered: first live entry wins. Exhausted pool fails loud at plan (pin precedent).
|
|
689
732
|
# floors: # tier authority — advisory minimum bands; 'tickmarkr plan' lints violations
|
|
690
733
|
# migration: frontier
|
|
691
734
|
# 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'
|
|
692
735
|
# learnedTuning: { halfLifeRuns: 5, availWeight: 0.05 } # optional; defaults byte-identical
|
|
693
736
|
# explore: { mode: on, excludeShapes: [], excludeComplexityAtOrAbove: null, cap: 5 } # optional; absent ⇒ byte-identical
|
|
694
737
|
# sla: { implement: 15 } # optional per-shape minutes — advisory plan lint only; absent ⇒ no lint
|
|
695
|
-
# allow: { adapters: [claude-code, codex] } #
|
|
738
|
+
# allow: { adapters: [claude-code, codex] } # the FLEET: presence activates fail-closed; the fleet editor writes this form
|
|
696
739
|
# deny: # optional fleet denylist; deny beats allow on conflict
|
|
697
740
|
# models:
|
|
698
741
|
# - pi:zai/glm-5.2 # OBS-57: pi passes run-start probe but hangs at finish without TICKMARKR_RESULT — remove after no-trailer demotion ships (v1.46 provider-outage taxonomy)
|
|
@@ -781,7 +824,18 @@ export function readOverlayFile(path) {
|
|
|
781
824
|
throw new ConfigError(`invalid overlay at ${path}`);
|
|
782
825
|
return raw;
|
|
783
826
|
}
|
|
784
|
-
|
|
827
|
+
/** Grammar A1 membership test: does any discovered universe row cover this deny/allow entry
|
|
828
|
+
* (adapter id, bare model id, or adapter:model)? Uncovered entries name channels the probe
|
|
829
|
+
* did not serve this session (failed auth, rate limit, retired sku) — they are NOT editable
|
|
830
|
+
* membership state and must survive a membership write verbatim, never be re-derived away. */
|
|
831
|
+
export function universeCovers(universe, entry) {
|
|
832
|
+
return universe.some((row) => entry === row.adapter || row.models.some((m) => entry === m || entry === `${row.adapter}:${m}`));
|
|
833
|
+
}
|
|
834
|
+
// v1.92 fleet membership: with a discovered `universe` (classified models only) the deny sets become
|
|
835
|
+
// the EFFECTIVE out-of-fleet complement — anything routing.allow does not admit (fail-closed) UNION
|
|
836
|
+
// routing.deny adapters/models scopes (never workers). Absent universe ⇒ deny arrays verbatim, so
|
|
837
|
+
// non-UI callers stay byte-identical.
|
|
838
|
+
export function fleetEditableFromConfig(cfg, universe) {
|
|
785
839
|
const tiers = {};
|
|
786
840
|
for (const [adapter, entry] of Object.entries(cfg.tiers)) {
|
|
787
841
|
tiers[adapter] = {};
|
|
@@ -789,9 +843,46 @@ export function fleetEditableFromConfig(cfg) {
|
|
|
789
843
|
tiers[adapter][model] = { tier };
|
|
790
844
|
}
|
|
791
845
|
}
|
|
846
|
+
let denyAdapters = [...(cfg.routing.deny?.adapters ?? [])].sort();
|
|
847
|
+
let denyModels = [...(cfg.routing.deny?.models ?? [])].sort();
|
|
848
|
+
if (universe !== undefined) {
|
|
849
|
+
const { allow, deny } = cfg.routing;
|
|
850
|
+
// Grammar A1 (disallowedBy precedent): entries match adapter id, bare model id, or adapter:model.
|
|
851
|
+
// Inlined rather than imported — route/preference.ts imports this module.
|
|
852
|
+
const denyEntries = [...(deny?.adapters ?? []), ...(deny?.models ?? [])];
|
|
853
|
+
const allowEntries = allow ? [...(allow.adapters ?? []), ...(allow.models ?? [])] : null;
|
|
854
|
+
const outOfFleet = (adapter, model) => {
|
|
855
|
+
const matches = (e) => e === adapter || e === model || e === `${adapter}:${model}`;
|
|
856
|
+
if (denyEntries.some(matches))
|
|
857
|
+
return true;
|
|
858
|
+
return allowEntries !== null && !allowEntries.some(matches);
|
|
859
|
+
};
|
|
860
|
+
const adaptersOut = [];
|
|
861
|
+
const modelsOut = [];
|
|
862
|
+
for (const row of universe) {
|
|
863
|
+
const out = row.models.filter((m) => outOfFleet(row.adapter, m));
|
|
864
|
+
if (row.models.length ? out.length === row.models.length : outOfFleet(row.adapter, "")) {
|
|
865
|
+
adaptersOut.push(row.adapter);
|
|
866
|
+
}
|
|
867
|
+
else {
|
|
868
|
+
modelsOut.push(...out.map((m) => `${row.adapter}:${m}`));
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
// OBS-517: deny entries the probe universe does not cover (failed/rate-limited probes drop the
|
|
872
|
+
// channel from discoverChannels) are preserved VERBATIM — deriving membership purely from the
|
|
873
|
+
// universe silently un-denied them on the next write (fail-open on the operator's own ban list).
|
|
874
|
+
denyAdapters = [...new Set([
|
|
875
|
+
...adaptersOut,
|
|
876
|
+
...(deny?.adapters ?? []).filter((e) => !universeCovers(universe, e)),
|
|
877
|
+
])].sort();
|
|
878
|
+
denyModels = [...new Set([
|
|
879
|
+
...modelsOut,
|
|
880
|
+
...(deny?.models ?? []).filter((e) => !universeCovers(universe, e)),
|
|
881
|
+
])].sort();
|
|
882
|
+
}
|
|
792
883
|
return {
|
|
793
|
-
denyAdapters
|
|
794
|
-
denyModels
|
|
884
|
+
denyAdapters,
|
|
885
|
+
denyModels,
|
|
795
886
|
tiers,
|
|
796
887
|
map: structuredClone(cfg.routing.map),
|
|
797
888
|
floors: { ...cfg.routing.floors },
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type FleetEditable, type RoutingMode } from "./config.js";
|
|
2
2
|
/** Fleet-owned overlay keys — the only config surface `tickmarkr fleet` may write. */
|
|
3
3
|
export declare const FLEET_OVERLAY_KEYS: readonly ["routing", "tiers"];
|
|
4
4
|
export type FleetOverlayWrite = {
|
|
@@ -19,6 +19,10 @@ export type FleetOverlayWrite = {
|
|
|
19
19
|
consult?: string[];
|
|
20
20
|
};
|
|
21
21
|
};
|
|
22
|
+
universe?: {
|
|
23
|
+
adapter: string;
|
|
24
|
+
models: string[];
|
|
25
|
+
}[];
|
|
22
26
|
};
|
|
23
27
|
export type FleetFirstTouch = {
|
|
24
28
|
vendor: string;
|
|
@@ -29,7 +33,10 @@ export declare function fleetFirstTouchProvenance(note: string, firstTouch: Flee
|
|
|
29
33
|
* keys, ordering, scalar style, and comments; fresh provenance is written directly on its tier node. */
|
|
30
34
|
export declare function renderFleetOverlayWrite(priorBytes: string, write: FleetOverlayWrite): string;
|
|
31
35
|
/** Build the repo overlay fragment fleet would write for edits since session start. */
|
|
32
|
-
export declare function fleetRepoOverlayFromDelta(initial: FleetEditable, edited: FleetEditable, existingRepo?: Record<string, unknown>, firstTouches?: Readonly<Record<string, FleetFirstTouch
|
|
36
|
+
export declare function fleetRepoOverlayFromDelta(initial: FleetEditable, edited: FleetEditable, existingRepo?: Record<string, unknown>, firstTouches?: Readonly<Record<string, FleetFirstTouch>>, universe?: {
|
|
37
|
+
adapter: string;
|
|
38
|
+
models: string[];
|
|
39
|
+
}[]): Record<string, unknown>;
|
|
33
40
|
export declare function repoOverlayYaml(overlay: Record<string, unknown>): string;
|
|
34
41
|
export declare function serializeFleetOverlay(overlay: Record<string, unknown>): string;
|
|
35
42
|
export declare function unifiedYamlDiff(before: string, after: string, label?: string): string;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Fleet-overlay mutation, serialization, and diff rendering for the `tickmarkr fleet` write path.
|
|
2
2
|
import { isMap, isScalar, isSeq, parseDocument, stringify, visit } from "yaml";
|
|
3
|
+
import { universeCovers } from "./config.js";
|
|
3
4
|
/** Fleet-owned overlay keys — the only config surface `tickmarkr fleet` may write. */
|
|
4
5
|
export const FLEET_OVERLAY_KEYS = ["routing", "tiers"];
|
|
5
6
|
function fleetSubset(obj) {
|
|
@@ -13,6 +14,41 @@ function fleetSubset(obj) {
|
|
|
13
14
|
function sortedUnique(xs) {
|
|
14
15
|
return [...new Set(xs)].sort();
|
|
15
16
|
}
|
|
17
|
+
// The minimal routing.allow form for an exclusion-set membership write: whole adapter ids for
|
|
18
|
+
// fully-in adapters, adapter:model keys for partially-in ones, nothing for fully-out ones.
|
|
19
|
+
// `excluded` false ⇔ the whole universe is in fleet ⇒ the allow block is removed, not written.
|
|
20
|
+
function allowFormFromExclusions(universe, edited) {
|
|
21
|
+
if (!universe.length) {
|
|
22
|
+
throw new Error("fleet write: universe is empty — no classified models to compute routing.allow from; classify models in `tickmarkr fleet` first");
|
|
23
|
+
}
|
|
24
|
+
const denyAdapters = new Set(edited.denyAdapters);
|
|
25
|
+
const denyModels = new Set(edited.denyModels);
|
|
26
|
+
const adapters = [];
|
|
27
|
+
const models = [];
|
|
28
|
+
let excluded = false;
|
|
29
|
+
for (const row of universe) {
|
|
30
|
+
if (denyAdapters.has(row.adapter)) {
|
|
31
|
+
excluded = true;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const inFleet = row.models.filter((m) => !denyModels.has(`${row.adapter}:${m}`));
|
|
35
|
+
if (inFleet.length === row.models.length) {
|
|
36
|
+
adapters.push(row.adapter);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
excluded = true;
|
|
40
|
+
models.push(...inFleet.map((m) => `${row.adapter}:${m}`));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { adapters: sortedUnique(adapters), models: sortedUnique(models), excluded };
|
|
44
|
+
}
|
|
45
|
+
// OBS-517: deny entries the discovered universe cannot express through the allow form — the probe
|
|
46
|
+
// dropped their channel (failed auth, rate limit, retired sku) so the allow complement never names
|
|
47
|
+
// them. They must be written back into routing.deny verbatim (deny beats allow at routing time),
|
|
48
|
+
// or a transient probe failure permanently erases a deliberate operator exclusion.
|
|
49
|
+
function residualDeny(universe, entries) {
|
|
50
|
+
return sortedUnique(entries.filter((entry) => !universeCovers(universe, entry)));
|
|
51
|
+
}
|
|
16
52
|
// fleet.ts deliberately remains the sole overlay builder and writer. Its established classification
|
|
17
53
|
// seam copies only `tier` and `note` into FleetEditable, so first-touch entry metadata rides inside a
|
|
18
54
|
// private provenance envelope until this module writes the YAML. The envelope never reaches disk.
|
|
@@ -106,8 +142,47 @@ export function renderFleetOverlayWrite(priorBytes, write) {
|
|
|
106
142
|
const denyChanged = sortedUnique(initial.denyAdapters).join() !== sortedUnique(edited.denyAdapters).join()
|
|
107
143
|
|| sortedUnique(initial.denyModels).join() !== sortedUnique(edited.denyModels).join();
|
|
108
144
|
if (denyChanged) {
|
|
109
|
-
|
|
110
|
-
|
|
145
|
+
if (write.universe) {
|
|
146
|
+
// Membership write: the allow form IS the fleet; deny adapters/models scopes are tombstoned
|
|
147
|
+
// so a lower layer can never re-exclude behind the operator's back (workers untouched).
|
|
148
|
+
const form = allowFormFromExclusions(write.universe, edited);
|
|
149
|
+
if (form.excluded) {
|
|
150
|
+
const allowNode = doc.getIn(["routing", "allow"], true);
|
|
151
|
+
if (allowNode !== undefined && !isMap(allowNode))
|
|
152
|
+
doc.deleteIn(["routing", "allow"]);
|
|
153
|
+
// deleteIn throws on an empty document — only delete keys that exist.
|
|
154
|
+
if (form.adapters.length) {
|
|
155
|
+
setStringSequencePreservingComments(doc, ["routing", "allow", "adapters"], form.adapters);
|
|
156
|
+
}
|
|
157
|
+
else if (doc.getIn(["routing", "allow", "adapters"]) !== undefined) {
|
|
158
|
+
doc.deleteIn(["routing", "allow", "adapters"]);
|
|
159
|
+
}
|
|
160
|
+
if (form.models.length) {
|
|
161
|
+
setStringSequencePreservingComments(doc, ["routing", "allow", "models"], form.models);
|
|
162
|
+
}
|
|
163
|
+
else if (doc.getIn(["routing", "allow", "models"]) !== undefined) {
|
|
164
|
+
doc.deleteIn(["routing", "allow", "models"]);
|
|
165
|
+
}
|
|
166
|
+
// Whole fleet out: allow stays present but empty — fail-closed, nothing admitted.
|
|
167
|
+
if (doc.getIn(["routing", "allow"]) === undefined) {
|
|
168
|
+
doc.setIn(["routing", "allow"], doc.createNode({}));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else if (doc.getIn(["routing", "allow"]) !== undefined) {
|
|
172
|
+
// Whole fleet in: no restriction to express — the allow block goes away entirely.
|
|
173
|
+
doc.deleteIn(["routing", "allow"]);
|
|
174
|
+
}
|
|
175
|
+
// Residuals stay in deny; covered scopes are tombstoned so a lower layer can never
|
|
176
|
+
// re-exclude behind the operator's back (workers untouched).
|
|
177
|
+
const residualAdapters = residualDeny(write.universe, edited.denyAdapters);
|
|
178
|
+
const residualModels = residualDeny(write.universe, edited.denyModels);
|
|
179
|
+
setStringSequencePreservingComments(doc, ["routing", "deny", "adapters"], residualAdapters.length ? residualAdapters : null);
|
|
180
|
+
setStringSequencePreservingComments(doc, ["routing", "deny", "models"], residualModels.length ? residualModels : null);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
setStringSequencePreservingComments(doc, ["routing", "deny", "adapters"], edited.denyAdapters.length ? sortedUnique(edited.denyAdapters) : null);
|
|
184
|
+
setStringSequencePreservingComments(doc, ["routing", "deny", "models"], edited.denyModels.length ? sortedUnique(edited.denyModels) : null);
|
|
185
|
+
}
|
|
111
186
|
}
|
|
112
187
|
for (const shape of new Set([...Object.keys(initial.map), ...Object.keys(edited.map)])) {
|
|
113
188
|
const before = initial.map[shape];
|
|
@@ -118,9 +193,33 @@ export function renderFleetOverlayWrite(priorBytes, write) {
|
|
|
118
193
|
else
|
|
119
194
|
doc.setIn(["routing", "map", shape, "pin"], doc.createNode(after.pin));
|
|
120
195
|
}
|
|
196
|
+
if (JSON.stringify(before?.pool) !== JSON.stringify(after?.pool)) {
|
|
197
|
+
const path = ["routing", "map", shape, "pool"];
|
|
198
|
+
if (after?.pool === undefined) {
|
|
199
|
+
// pool: null tombstone — masks a lower-layer pool instead of inheriting it again.
|
|
200
|
+
setStringSequencePreservingComments(doc, path, null);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
const existing = doc.getIn(path, true);
|
|
204
|
+
if (existing !== undefined && !isMap(existing))
|
|
205
|
+
doc.deleteIn(path);
|
|
206
|
+
setScalarPreservingComment(doc, [...path, "mode"], after.pool.mode);
|
|
207
|
+
// Channel order is semantic (ordered walks it; any breaks ties by it) — dedupe, never sort.
|
|
208
|
+
setStringSequencePreservingComments(doc, [...path, "channels"], [...new Set(after.pool.channels)]);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
121
211
|
if (JSON.stringify(before?.prefer) !== JSON.stringify(after?.prefer)) {
|
|
122
|
-
|
|
123
|
-
|
|
212
|
+
if (after?.prefer === undefined && (after?.pool !== undefined || after?.pin !== undefined)) {
|
|
213
|
+
// the pin/pool declaration owns the whole slot at merge (deepMerge drops the lower
|
|
214
|
+
// layer's pin/pool/prefer atomically) — an [] mask here would re-declare prefer BESIDE
|
|
215
|
+
// the pool in one document and fail the exclusivity refine; delete the key instead
|
|
216
|
+
doc.deleteIn(["routing", "map", shape, "prefer"]);
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
// Clearing a resolved list with no replacing declaration must mask the lower layer
|
|
220
|
+
// with [], not delete the key and inherit it again.
|
|
221
|
+
setStringSequencePreservingComments(doc, ["routing", "map", shape, "prefer"], after?.prefer ?? []);
|
|
222
|
+
}
|
|
124
223
|
}
|
|
125
224
|
if (before?.escalate !== after?.escalate) {
|
|
126
225
|
if (after?.escalate === undefined)
|
|
@@ -209,10 +308,14 @@ export function renderFleetOverlayWrite(priorBytes, write) {
|
|
|
209
308
|
commentString: (comment) => comment.startsWith(INLINE_COMMENT_SENTINEL)
|
|
210
309
|
? ` #${comment.slice(1)}`
|
|
211
310
|
: comment.replace(/^(?!$)(?: $)?/gm, "#"),
|
|
311
|
+
// OBS-518: yaml's default pads flow collections (`[kimi]` → `[ kimi ]`), churning untouched
|
|
312
|
+
// lines on the one confirmation surface an operator reviews. Hand-written overlays use the
|
|
313
|
+
// unpadded form; emit it.
|
|
314
|
+
flowCollectionPadding: false,
|
|
212
315
|
});
|
|
213
316
|
}
|
|
214
317
|
/** Build the repo overlay fragment fleet would write for edits since session start. */
|
|
215
|
-
export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}, firstTouches = {}) {
|
|
318
|
+
export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}, firstTouches = {}, universe) {
|
|
216
319
|
if (fleetEditableEquals(initial, edited))
|
|
217
320
|
return {};
|
|
218
321
|
const out = structuredClone(existingRepo);
|
|
@@ -221,19 +324,45 @@ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}, fi
|
|
|
221
324
|
const denyChanged = sortedUnique(initial.denyAdapters).join() !== sortedUnique(edited.denyAdapters).join()
|
|
222
325
|
|| sortedUnique(initial.denyModels).join() !== sortedUnique(edited.denyModels).join();
|
|
223
326
|
if (denyChanged) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
327
|
+
if (universe) {
|
|
328
|
+
const form = allowFormFromExclusions(universe, edited);
|
|
329
|
+
if (form.excluded) {
|
|
330
|
+
routing.allow = {
|
|
331
|
+
...(form.adapters.length ? { adapters: form.adapters } : {}),
|
|
332
|
+
...(form.models.length ? { models: form.models } : {}),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
else
|
|
336
|
+
delete routing.allow;
|
|
337
|
+
const residualAdapters = residualDeny(universe, edited.denyAdapters);
|
|
338
|
+
const residualModels = residualDeny(universe, edited.denyModels);
|
|
339
|
+
routing.deny = {
|
|
340
|
+
...routing.deny,
|
|
341
|
+
adapters: residualAdapters.length ? residualAdapters : null,
|
|
342
|
+
models: residualModels.length ? residualModels : null,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
routing.deny = {
|
|
347
|
+
adapters: edited.denyAdapters.length ? edited.denyAdapters : null,
|
|
348
|
+
models: edited.denyModels.length ? edited.denyModels : null,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
228
351
|
routingTouched = true;
|
|
229
352
|
}
|
|
353
|
+
// pool widened to accept the null tombstone; MapEntry itself never carries null in memory.
|
|
230
354
|
const mapDelta = {};
|
|
231
355
|
for (const shape of new Set([...Object.keys(initial.map), ...Object.keys(edited.map)])) {
|
|
232
356
|
if (JSON.stringify(initial.map[shape]) !== JSON.stringify(edited.map[shape])) {
|
|
233
357
|
const next = { ...edited.map[shape] };
|
|
234
|
-
if (initial.map[shape]?.prefer !== undefined && edited.map[shape]?.prefer === undefined
|
|
358
|
+
if (initial.map[shape]?.prefer !== undefined && edited.map[shape]?.prefer === undefined
|
|
359
|
+
&& edited.map[shape]?.pool === undefined && edited.map[shape]?.pin === undefined) {
|
|
235
360
|
next.prefer = [];
|
|
236
361
|
}
|
|
362
|
+
// pool: null tombstone — a removed pool must mask the lower layer, never inherit it again.
|
|
363
|
+
if (initial.map[shape]?.pool !== undefined && edited.map[shape]?.pool === undefined) {
|
|
364
|
+
next.pool = null;
|
|
365
|
+
}
|
|
237
366
|
mapDelta[shape] = next;
|
|
238
367
|
}
|
|
239
368
|
}
|
|
@@ -27,7 +27,7 @@ export declare function disallowedBy(c: {
|
|
|
27
27
|
model: string;
|
|
28
28
|
}, routing: TickmarkrConfig["routing"], role?: PreferenceRole): Disallowed | null;
|
|
29
29
|
export interface DenyPreferCollision {
|
|
30
|
-
kind: "prefer" | "pin";
|
|
30
|
+
kind: "prefer" | "pin" | "pool";
|
|
31
31
|
shape: string;
|
|
32
32
|
detail: string;
|
|
33
33
|
disallowed: Disallowed;
|
package/dist/route/preference.js
CHANGED
|
@@ -113,11 +113,21 @@ export function denyPreferCollisions(cfg, shapes) {
|
|
|
113
113
|
disallowed: preferEntryDenied(prefer[0], cfg),
|
|
114
114
|
});
|
|
115
115
|
}
|
|
116
|
+
const pool = entry.pool;
|
|
117
|
+
if (pool) {
|
|
118
|
+
const denied = pool.channels.map((p) => {
|
|
119
|
+
const i = p.indexOf(":");
|
|
120
|
+
return disallowedBy({ adapter: p.slice(0, i), model: p.slice(i + 1) }, cfg.routing);
|
|
121
|
+
});
|
|
122
|
+
// only a FULLY dead pool collides — a partial deny still leaves live members to route
|
|
123
|
+
if (denied.every((d) => d !== null)) {
|
|
124
|
+
out.push({ kind: "pool", shape, detail: pool.channels.join(" > "), disallowed: denied[0] });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
116
127
|
}
|
|
117
128
|
return out;
|
|
118
129
|
}
|
|
119
130
|
export function denyPreferCollisionLine({ kind, shape, detail, disallowed }) {
|
|
120
|
-
const surface = kind === "pin" ? `routing.map.${shape}.pin` : `routing.map.${shape}.prefer`;
|
|
121
131
|
const verb = kind === "pin" ? "is disallowed" : "fully disallowed";
|
|
122
|
-
return `deny∩prefer:
|
|
132
|
+
return `deny∩prefer: routing.map.${shape}.${kind} ${detail} ${verb} by routing.${disallowed.by} (${disallowed.entry}) — remove the ${disallowed.by} entry or adjust ${kind}`;
|
|
123
133
|
}
|
package/dist/route/router.js
CHANGED
|
@@ -160,9 +160,9 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
160
160
|
const mapPinFloor = taskFloor && (!advisoryFloor || TIER_RANK[taskFloor] >= TIER_RANK[advisoryFloor])
|
|
161
161
|
? { tier: taskFloor, source: "task" }
|
|
162
162
|
: advisoryFloor ? { tier: advisoryFloor, source: "config" } : undefined;
|
|
163
|
-
const lintMapPinFloor = (tier) => {
|
|
163
|
+
const lintMapPinFloor = (tier, what = "pin") => {
|
|
164
164
|
if (mapPinFloor && TIER_RANK[tier] < TIER_RANK[mapPinFloor.tier]) {
|
|
165
|
-
lints.push(`${task.id} (${task.shape}): map
|
|
165
|
+
lints.push(`${task.id} (${task.shape}): map ${what} routes ${tier}, below ${mapPinFloor.source} floor ${mapPinFloor.tier} — map ${what}s are supreme`);
|
|
166
166
|
}
|
|
167
167
|
};
|
|
168
168
|
const source = task.routingHints?.source;
|
|
@@ -190,6 +190,35 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
190
190
|
maybeSlaLint(lints, task, profile, slaMinutes, c);
|
|
191
191
|
return { assignment: toAssignment(c), ladder: ladderFor(task, entry), lints, provenance: `${degraded}pin ${entry.pin.via}:${entry.pin.model} (config routing.map)` };
|
|
192
192
|
}
|
|
193
|
+
// map pool: operator-authored closed candidate set (v1.92) — sits between the pin (a
|
|
194
|
+
// single-channel pool) and the open floor/auto path. Exhaustion stays fail-loud (pin precedent).
|
|
195
|
+
if (entry?.pool) {
|
|
196
|
+
const pool = entry.pool;
|
|
197
|
+
if (prefActive) {
|
|
198
|
+
for (const p of pool.channels) {
|
|
199
|
+
const i = p.indexOf(":");
|
|
200
|
+
const d = disallowedBy({ adapter: p.slice(0, i), model: p.slice(i + 1) }, cfg.routing);
|
|
201
|
+
if (d) {
|
|
202
|
+
throw new RoutingError(`${task.id}: pool entry ${p} is disallowed by routing.${d.by} (${d.entry}) — remove the ${d.by} entry or drop it from the pool`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// live preserves pool declaration order — ordered mode and any-mode stable-sort ties depend on it
|
|
207
|
+
const live = pool.channels
|
|
208
|
+
.map((id) => channels.find((c) => channelKey(c) === id))
|
|
209
|
+
.filter((c) => c !== undefined);
|
|
210
|
+
if (!live.length) {
|
|
211
|
+
throw new RoutingError(`${task.id}: routing.map.${task.shape}.pool has no live channel — declared: ${pool.channels.join(", ")}; doctor found: ${channels.map(channelKey).join(", ") || "(nothing)"}`);
|
|
212
|
+
}
|
|
213
|
+
// ponytail: no learned/explore/spread inside pools in v1 — a pool pick is fully static; the
|
|
214
|
+
// upgrade path is feeding the pool-filtered set through the learned path below.
|
|
215
|
+
const chosen = pool.mode === "ordered"
|
|
216
|
+
? live[0]
|
|
217
|
+
: [...live].sort((a, b) => marginalCostRank(a) - marginalCostRank(b) || TIER_RANK[a.tier] - TIER_RANK[b.tier])[0];
|
|
218
|
+
lintMapPinFloor(chosen.tier, "pool");
|
|
219
|
+
maybeSlaLint(lints, task, profile, slaMinutes, chosen);
|
|
220
|
+
return { assignment: toAssignment(chosen), ladder: ladderFor(task, entry), lints, provenance: `${degraded}pool ${pool.mode} ${channelKey(chosen)} (config routing.map)` };
|
|
221
|
+
}
|
|
193
222
|
const baseTier = floor ?? "cheap";
|
|
194
223
|
// D-04: a task floor is hard only on floor/auto paths; the map-pin branch above stays supreme.
|
|
195
224
|
const minTier = taskFloor && TIER_RANK[taskFloor] > TIER_RANK[baseTier] ? taskFloor : baseTier;
|
|
@@ -46,14 +46,28 @@ export type FleetModelSuggestion = {
|
|
|
46
46
|
tier: Tier;
|
|
47
47
|
note: string;
|
|
48
48
|
};
|
|
49
|
+
/** Catalog + probe metadata rendered as the browser's metadata columns (omp-style). */
|
|
50
|
+
export type FleetModelEvidence = {
|
|
51
|
+
contextWindow?: number;
|
|
52
|
+
outputWindow?: number;
|
|
53
|
+
inputCostPerMtok?: number;
|
|
54
|
+
outputCostPerMtok?: number;
|
|
55
|
+
/** doctor's model probe wall clock (modelAuth.durationMs) — the "how fast did it answer" column */
|
|
56
|
+
probeMs?: number;
|
|
57
|
+
/** OBS-519: doctor's failed model-auth verdict reason — the row must not render like a healthy one */
|
|
58
|
+
unauthed?: string;
|
|
59
|
+
};
|
|
49
60
|
export type FleetModelGroup = {
|
|
50
61
|
adapter: string;
|
|
51
62
|
vendor?: string;
|
|
63
|
+
/** configured billing channel (cfg.tiers.<adapter>.channel) — prices render for api, quota for sub */
|
|
64
|
+
channel?: "sub" | "api";
|
|
52
65
|
rows: Array<{
|
|
53
66
|
model: string;
|
|
54
67
|
tier?: Tier;
|
|
55
68
|
detectedAt?: string;
|
|
56
69
|
suggestion?: FleetModelSuggestion;
|
|
70
|
+
evidence?: FleetModelEvidence;
|
|
57
71
|
}>;
|
|
58
72
|
};
|
|
59
73
|
export type FleetClassification = {
|
|
@@ -81,7 +95,7 @@ export type FleetCandidateOption = {
|
|
|
81
95
|
};
|
|
82
96
|
};
|
|
83
97
|
export declare function formatDoctorAge(ageMs: number | null): string;
|
|
84
|
-
export declare function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewOverlay, reloadGuard, entry, initialJudge, judgeSeats, viewRows, }: {
|
|
98
|
+
export declare function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewOverlay, reloadGuard, entry, initialJudge, judgeSeats, viewRows, frameColumns, frameRows, }: {
|
|
85
99
|
ageMs: number | null;
|
|
86
100
|
agents: AgentCli[];
|
|
87
101
|
initialDenyAdapters: string[];
|
|
@@ -90,9 +104,21 @@ export declare function FleetApp({ ageMs, agents, initialDenyAdapters, initialDe
|
|
|
90
104
|
initialMode: RoutingMode;
|
|
91
105
|
modeOptions: FleetModeOption[];
|
|
92
106
|
initialMap: Record<string, MapEntry>;
|
|
93
|
-
modePreview: (mode: RoutingMode, map: Record<string, MapEntry
|
|
94
|
-
|
|
95
|
-
|
|
107
|
+
modePreview: (mode: RoutingMode, map: Record<string, MapEntry>, deny: {
|
|
108
|
+
adapters: string[];
|
|
109
|
+
models: string[];
|
|
110
|
+
}) => string[];
|
|
111
|
+
shapeRows: (mode: RoutingMode, map: Record<string, MapEntry>, deny: {
|
|
112
|
+
adapters: string[];
|
|
113
|
+
models: string[];
|
|
114
|
+
}) => FleetShapeRow[];
|
|
115
|
+
candidatesForShape: (shape: Shape, mode: RoutingMode, map: Record<string, MapEntry>, deny: {
|
|
116
|
+
adapters: string[];
|
|
117
|
+
models: string[];
|
|
118
|
+
}) => {
|
|
119
|
+
rows: FleetCandidateOption[];
|
|
120
|
+
excludedNote?: string;
|
|
121
|
+
};
|
|
96
122
|
preferOptionsForShape: (shape: Shape, current: string[]) => string[];
|
|
97
123
|
initialSteering: Record<FleetSteeringKey, string[] | undefined>;
|
|
98
124
|
steeringOptionsFor: (which: FleetSteeringKey, current: string[]) => string[];
|
|
@@ -105,6 +131,10 @@ export declare function FleetApp({ ageMs, agents, initialDenyAdapters, initialDe
|
|
|
105
131
|
judgeSeats?: string[];
|
|
106
132
|
/** list viewport capacity (terminal rows minus chrome) — long lists window around the cursor */
|
|
107
133
|
viewRows?: number;
|
|
134
|
+
/** terminal width the frame lays out against */
|
|
135
|
+
frameColumns?: number;
|
|
136
|
+
/** terminal height — set only on a real TTY so the footer sticks to the bottom */
|
|
137
|
+
frameRows?: number;
|
|
108
138
|
}): import("react").JSX.Element;
|
|
109
139
|
export declare function runFleetInkEditor({ ageMs, adapters, health, initialDenyAdapters, initialDenyModels, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewOverlay, reloadGuard, entry, initialJudge, judgeSeats, initialInput, input, output, debug, }: {
|
|
110
140
|
ageMs: number | null;
|
|
@@ -116,15 +146,27 @@ export declare function runFleetInkEditor({ ageMs, adapters, health, initialDeny
|
|
|
116
146
|
initialMode: RoutingMode;
|
|
117
147
|
modeOptions: FleetModeOption[];
|
|
118
148
|
initialMap: Record<string, MapEntry>;
|
|
119
|
-
modePreview: (mode: RoutingMode, map: Record<string, MapEntry
|
|
120
|
-
|
|
121
|
-
|
|
149
|
+
modePreview: (mode: RoutingMode, map: Record<string, MapEntry>, deny: {
|
|
150
|
+
adapters: string[];
|
|
151
|
+
models: string[];
|
|
152
|
+
}) => string[];
|
|
153
|
+
shapeRows: (mode: RoutingMode, map: Record<string, MapEntry>, deny: {
|
|
154
|
+
adapters: string[];
|
|
155
|
+
models: string[];
|
|
156
|
+
}) => FleetShapeRow[];
|
|
157
|
+
candidatesForShape: (shape: Shape, mode: RoutingMode, map: Record<string, MapEntry>, deny: {
|
|
158
|
+
adapters: string[];
|
|
159
|
+
models: string[];
|
|
160
|
+
}) => {
|
|
161
|
+
rows: FleetCandidateOption[];
|
|
162
|
+
excludedNote?: string;
|
|
163
|
+
};
|
|
122
164
|
preferOptionsForShape: (shape: Shape, current: string[]) => string[];
|
|
123
165
|
initialSteering: Record<FleetSteeringKey, string[] | undefined>;
|
|
124
166
|
steeringOptionsFor: (which: FleetSteeringKey, current: string[]) => string[];
|
|
125
167
|
reviewOverlay: (state: FleetEditorState) => FleetOverlayReview;
|
|
126
168
|
reloadGuard: (bytes: string) => string | null;
|
|
127
|
-
/** "presets"
|
|
169
|
+
/** "presets" = init's entry: Esc in the browser is HOME to the preset overlay, not quit */
|
|
128
170
|
entry?: "presets" | "probe";
|
|
129
171
|
initialJudge?: string;
|
|
130
172
|
judgeSeats?: string[];
|