tickmarkr 1.91.0 → 1.92.1

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.
@@ -52,7 +88,7 @@ function setScalarPreservingComment(doc, path, value, authoredComment) {
52
88
  if (isScalar(existing))
53
89
  existing.value = value;
54
90
  else
55
- doc.setIn(path, doc.createNode(value));
91
+ setAt(doc, path, doc.createNode(value));
56
92
  if (authoredComment !== undefined) {
57
93
  const written = doc.getIn(path, true);
58
94
  if (isScalar(written))
@@ -76,11 +112,11 @@ function setStringSequencePreservingComments(doc, path, values) {
76
112
  if (comments.length)
77
113
  tombstone.comment = comments.join("\n");
78
114
  }
79
- doc.setIn(path, tombstone);
115
+ setAt(doc, path, tombstone);
80
116
  return;
81
117
  }
82
118
  if (!isSeq(existing)) {
83
- doc.setIn(path, doc.createNode(values));
119
+ setAt(doc, path, doc.createNode(values));
84
120
  return;
85
121
  }
86
122
  const available = [...existing.items];
@@ -91,6 +127,34 @@ function setStringSequencePreservingComments(doc, path, values) {
91
127
  return doc.createNode(value);
92
128
  });
93
129
  }
130
+ // OBS-533: yaml's setIn/deleteIn throw on any non-collection intermediate ("Expected YAML
131
+ // collection at spec. Remaining path: pin"), and a v1.1 null tombstone (`spec:` under
132
+ // routing.map) is exactly that scalar — deepMerge prunes it before schema validation, so the
133
+ // overlay loads clean and the first fleet write under the tombstoned key crashed the TUI.
134
+ // Total forms: a delete treats the masked subtree as absent (the operator's tombstone already
135
+ // denies it, and survives untouched); a set clears the scalar and rebuilds what fleet owns —
136
+ // the routing.allow precedent below.
137
+ function deleteAt(doc, path) {
138
+ for (let depth = 1; depth < path.length; depth++) {
139
+ if (!isMap(doc.getIn(path.slice(0, depth), true)))
140
+ return;
141
+ }
142
+ // deleteIn throws on an empty document — only delete keys that exist.
143
+ if (doc.getIn(path) !== undefined)
144
+ doc.deleteIn(path);
145
+ }
146
+ function setAt(doc, path, value) {
147
+ for (let depth = 1; depth < path.length; depth++) {
148
+ const node = doc.getIn(path.slice(0, depth), true);
149
+ if (node === undefined)
150
+ break;
151
+ if (!isMap(node)) {
152
+ doc.deleteIn(path.slice(0, depth));
153
+ break;
154
+ }
155
+ }
156
+ doc.setIn(path, value);
157
+ }
94
158
  function deleteEmptyMap(doc, path) {
95
159
  const node = doc.getIn(path, true);
96
160
  if (isMap(node) && node.items.length === 0)
@@ -106,25 +170,84 @@ export function renderFleetOverlayWrite(priorBytes, write) {
106
170
  const denyChanged = sortedUnique(initial.denyAdapters).join() !== sortedUnique(edited.denyAdapters).join()
107
171
  || sortedUnique(initial.denyModels).join() !== sortedUnique(edited.denyModels).join();
108
172
  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);
173
+ if (write.universe) {
174
+ // Membership write: the allow form IS the fleet; deny adapters/models scopes are tombstoned
175
+ // so a lower layer can never re-exclude behind the operator's back (workers untouched).
176
+ const form = allowFormFromExclusions(write.universe, edited);
177
+ if (form.excluded) {
178
+ const allowNode = doc.getIn(["routing", "allow"], true);
179
+ if (allowNode !== undefined && !isMap(allowNode))
180
+ doc.deleteIn(["routing", "allow"]);
181
+ if (form.adapters.length) {
182
+ setStringSequencePreservingComments(doc, ["routing", "allow", "adapters"], form.adapters);
183
+ }
184
+ else {
185
+ deleteAt(doc, ["routing", "allow", "adapters"]);
186
+ }
187
+ if (form.models.length) {
188
+ setStringSequencePreservingComments(doc, ["routing", "allow", "models"], form.models);
189
+ }
190
+ else {
191
+ deleteAt(doc, ["routing", "allow", "models"]);
192
+ }
193
+ // Whole fleet out: allow stays present but empty — fail-closed, nothing admitted.
194
+ if (doc.getIn(["routing", "allow"]) === undefined) {
195
+ setAt(doc, ["routing", "allow"], doc.createNode({}));
196
+ }
197
+ }
198
+ else {
199
+ // Whole fleet in: no restriction to express — the allow block goes away entirely.
200
+ deleteAt(doc, ["routing", "allow"]);
201
+ }
202
+ // Residuals stay in deny; covered scopes are tombstoned so a lower layer can never
203
+ // re-exclude behind the operator's back (workers untouched).
204
+ const residualAdapters = residualDeny(write.universe, edited.denyAdapters);
205
+ const residualModels = residualDeny(write.universe, edited.denyModels);
206
+ setStringSequencePreservingComments(doc, ["routing", "deny", "adapters"], residualAdapters.length ? residualAdapters : null);
207
+ setStringSequencePreservingComments(doc, ["routing", "deny", "models"], residualModels.length ? residualModels : null);
208
+ }
209
+ else {
210
+ setStringSequencePreservingComments(doc, ["routing", "deny", "adapters"], edited.denyAdapters.length ? sortedUnique(edited.denyAdapters) : null);
211
+ setStringSequencePreservingComments(doc, ["routing", "deny", "models"], edited.denyModels.length ? sortedUnique(edited.denyModels) : null);
212
+ }
111
213
  }
112
214
  for (const shape of new Set([...Object.keys(initial.map), ...Object.keys(edited.map)])) {
113
215
  const before = initial.map[shape];
114
216
  const after = edited.map[shape];
115
217
  if (JSON.stringify(before?.pin) !== JSON.stringify(after?.pin)) {
116
218
  if (after?.pin === undefined)
117
- doc.deleteIn(["routing", "map", shape, "pin"]);
219
+ deleteAt(doc, ["routing", "map", shape, "pin"]);
118
220
  else
119
- doc.setIn(["routing", "map", shape, "pin"], doc.createNode(after.pin));
221
+ setAt(doc, ["routing", "map", shape, "pin"], doc.createNode(after.pin));
222
+ }
223
+ if (JSON.stringify(before?.pool) !== JSON.stringify(after?.pool)) {
224
+ const path = ["routing", "map", shape, "pool"];
225
+ if (after?.pool === undefined) {
226
+ // pool: null tombstone — masks a lower-layer pool instead of inheriting it again.
227
+ setStringSequencePreservingComments(doc, path, null);
228
+ }
229
+ else {
230
+ setScalarPreservingComment(doc, [...path, "mode"], after.pool.mode);
231
+ // Channel order is semantic (ordered walks it; any breaks ties by it) — dedupe, never sort.
232
+ setStringSequencePreservingComments(doc, [...path, "channels"], [...new Set(after.pool.channels)]);
233
+ }
120
234
  }
121
235
  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 ?? []);
236
+ if (after?.prefer === undefined && (after?.pool !== undefined || after?.pin !== undefined)) {
237
+ // the pin/pool declaration owns the whole slot at merge (deepMerge drops the lower
238
+ // layer's pin/pool/prefer atomically) — an [] mask here would re-declare prefer BESIDE
239
+ // the pool in one document and fail the exclusivity refine; delete the key instead
240
+ deleteAt(doc, ["routing", "map", shape, "prefer"]);
241
+ }
242
+ else {
243
+ // Clearing a resolved list with no replacing declaration must mask the lower layer
244
+ // with [], not delete the key and inherit it again.
245
+ setStringSequencePreservingComments(doc, ["routing", "map", shape, "prefer"], after?.prefer ?? []);
246
+ }
124
247
  }
125
248
  if (before?.escalate !== after?.escalate) {
126
249
  if (after?.escalate === undefined)
127
- doc.deleteIn(["routing", "map", shape, "escalate"]);
250
+ deleteAt(doc, ["routing", "map", shape, "escalate"]);
128
251
  else
129
252
  setScalarPreservingComment(doc, ["routing", "map", shape, "escalate"], after.escalate);
130
253
  }
@@ -134,7 +257,7 @@ export function renderFleetOverlayWrite(priorBytes, write) {
134
257
  continue;
135
258
  const tier = edited.floors[shape];
136
259
  if (tier === undefined)
137
- doc.deleteIn(["routing", "floors", shape]);
260
+ deleteAt(doc, ["routing", "floors", shape]);
138
261
  else
139
262
  setScalarPreservingComment(doc, ["routing", "floors", shape], tier);
140
263
  }
@@ -184,7 +307,7 @@ export function renderFleetOverlayWrite(priorBytes, write) {
184
307
  if (JSON.stringify(before) === JSON.stringify(after))
185
308
  continue;
186
309
  if (after === undefined) {
187
- doc.deleteIn([key, "prefer"]);
310
+ deleteAt(doc, [key, "prefer"]);
188
311
  deleteEmptyMap(doc, [key]);
189
312
  }
190
313
  else {
@@ -192,6 +315,11 @@ export function renderFleetOverlayWrite(priorBytes, write) {
192
315
  }
193
316
  }
194
317
  }
318
+ // Every mutation no-oped against an empty or comment-only overlay (contents stays null;
319
+ // deleteAt never creates content): return the prior bytes verbatim — stringifying a null
320
+ // document would write a literal `null` line into the file.
321
+ if (doc.contents === null)
322
+ return priorBytes;
195
323
  // OBS-505: fleet's two-space note style (`tier: mid # note`) applies to INLINE comments only.
196
324
  // The previous commentString prefixed " #" onto EVERY comment line, so block comments — the
197
325
  // whole init scaffold at column 0, and indented operator essays — each gained a stray leading
@@ -209,10 +337,14 @@ export function renderFleetOverlayWrite(priorBytes, write) {
209
337
  commentString: (comment) => comment.startsWith(INLINE_COMMENT_SENTINEL)
210
338
  ? ` #${comment.slice(1)}`
211
339
  : comment.replace(/^(?!$)(?: $)?/gm, "#"),
340
+ // OBS-518: yaml's default pads flow collections (`[kimi]` → `[ kimi ]`), churning untouched
341
+ // lines on the one confirmation surface an operator reviews. Hand-written overlays use the
342
+ // unpadded form; emit it.
343
+ flowCollectionPadding: false,
212
344
  });
213
345
  }
214
346
  /** Build the repo overlay fragment fleet would write for edits since session start. */
215
- export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}, firstTouches = {}) {
347
+ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}, firstTouches = {}, universe) {
216
348
  if (fleetEditableEquals(initial, edited))
217
349
  return {};
218
350
  const out = structuredClone(existingRepo);
@@ -221,19 +353,45 @@ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}, fi
221
353
  const denyChanged = sortedUnique(initial.denyAdapters).join() !== sortedUnique(edited.denyAdapters).join()
222
354
  || sortedUnique(initial.denyModels).join() !== sortedUnique(edited.denyModels).join();
223
355
  if (denyChanged) {
224
- routing.deny = {
225
- adapters: edited.denyAdapters.length ? edited.denyAdapters : null,
226
- models: edited.denyModels.length ? edited.denyModels : null,
227
- };
356
+ if (universe) {
357
+ const form = allowFormFromExclusions(universe, edited);
358
+ if (form.excluded) {
359
+ routing.allow = {
360
+ ...(form.adapters.length ? { adapters: form.adapters } : {}),
361
+ ...(form.models.length ? { models: form.models } : {}),
362
+ };
363
+ }
364
+ else
365
+ delete routing.allow;
366
+ const residualAdapters = residualDeny(universe, edited.denyAdapters);
367
+ const residualModels = residualDeny(universe, edited.denyModels);
368
+ routing.deny = {
369
+ ...routing.deny,
370
+ adapters: residualAdapters.length ? residualAdapters : null,
371
+ models: residualModels.length ? residualModels : null,
372
+ };
373
+ }
374
+ else {
375
+ routing.deny = {
376
+ adapters: edited.denyAdapters.length ? edited.denyAdapters : null,
377
+ models: edited.denyModels.length ? edited.denyModels : null,
378
+ };
379
+ }
228
380
  routingTouched = true;
229
381
  }
382
+ // pool widened to accept the null tombstone; MapEntry itself never carries null in memory.
230
383
  const mapDelta = {};
231
384
  for (const shape of new Set([...Object.keys(initial.map), ...Object.keys(edited.map)])) {
232
385
  if (JSON.stringify(initial.map[shape]) !== JSON.stringify(edited.map[shape])) {
233
386
  const next = { ...edited.map[shape] };
234
- if (initial.map[shape]?.prefer !== undefined && edited.map[shape]?.prefer === undefined) {
387
+ if (initial.map[shape]?.prefer !== undefined && edited.map[shape]?.prefer === undefined
388
+ && edited.map[shape]?.pool === undefined && edited.map[shape]?.pin === undefined) {
235
389
  next.prefer = [];
236
390
  }
391
+ // pool: null tombstone — a removed pool must mask the lower layer, never inherit it again.
392
+ if (initial.map[shape]?.pool !== undefined && edited.map[shape]?.pool === undefined) {
393
+ next.pool = null;
394
+ }
237
395
  mapDelta[shape] = next;
238
396
  }
239
397
  }
@@ -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;