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.
@@ -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
- export declare function fleetEditableFromConfig(cfg: TickmarkrConfig): FleetEditable;
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;
@@ -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
- map: z.record(z.string(), MapEntrySchema),
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] } # optional fleet allowlist; presence activates even if empty (fail-closed)
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
- export function fleetEditableFromConfig(cfg) {
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: [...(cfg.routing.deny?.adapters ?? [])].sort(),
794
- denyModels: [...(cfg.routing.deny?.models ?? [])].sort(),
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 { FleetEditable, RoutingMode } from "./config.js";
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>>): Record<string, unknown>;
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
- setStringSequencePreservingComments(doc, ["routing", "deny", "adapters"], edited.denyAdapters.length ? sortedUnique(edited.denyAdapters) : null);
110
- setStringSequencePreservingComments(doc, ["routing", "deny", "models"], edited.denyModels.length ? sortedUnique(edited.denyModels) : null);
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
- // Clearing a resolved list must mask the lower layer with [], not delete the key and inherit it again.
123
- setStringSequencePreservingComments(doc, ["routing", "map", shape, "prefer"], after?.prefer ?? []);
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
- routing.deny = {
225
- adapters: edited.denyAdapters.length ? edited.denyAdapters : null,
226
- models: edited.denyModels.length ? edited.denyModels : null,
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;
@@ -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: ${surface} ${detail} ${verb} by routing.${disallowed.by} (${disallowed.entry}) — remove the ${disallowed.by} entry or adjust ${kind === "pin" ? "pin" : "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
  }
@@ -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 pin routes ${tier}, below ${mapPinFloor.source} floor ${mapPinFloor.tier} — map pins are supreme`);
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>) => string[];
94
- shapeRows: (mode: RoutingMode, map: Record<string, MapEntry>) => FleetShapeRow[];
95
- candidatesForShape: (shape: Shape, mode: RoutingMode, map: Record<string, MapEntry>) => FleetCandidateOption[];
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>) => string[];
120
- shapeRows: (mode: RoutingMode, map: Record<string, MapEntry>) => FleetShapeRow[];
121
- candidatesForShape: (shape: Shape, mode: RoutingMode, map: Record<string, MapEntry>) => FleetCandidateOption[];
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" opens on the routing-mode screen with the extra custom row; default preserves the probe-first walk */
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[];