tickmarkr 1.90.8 → 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.
@@ -697,10 +697,11 @@ acceptance is required on every task (a nested list of observable outcomes).
697
697
  WHAT MAKES A CRITERION REAL:
698
698
  - "test:" must name a real test asserting on recorded state, journal lines, or drawn frames, and its
699
699
  title must match the criterion string verbatim. It also needs a collectable test path in files[].
700
- - A "test:" oracle must name a TOP-LEVEL test case, never one nested under describe(). Vitest prefixes
701
- nested cases with every suite title, while the acceptance gate builds an anchored pattern that
702
- vitest -t applies against the FULL runner-visible name; the verbatim leaf title then selects ZERO
703
- tests and the gate parks even though the suite is green. Measured: two parks in one run.
700
+ - A "test:" oracle's criterion must be the test's OWN title — the leaf — verbatim. Nesting under
701
+ describe() is allowed (OBS-511: the gate matches the criterion as the trailing segment of the
702
+ runner-visible full name), but the leaf title itself must equal the criterion: a shortened,
703
+ decorated, or paraphrased leaf selects ZERO tests and the gate parks even though the suite is green.
704
+ Measured before the widening: two parks across two runs, one full attempt lost in each.
704
705
  - NO criterion may be satisfiable by an absence, a rename, a source-text grep, or an empty collection.
705
706
  "no file references X" is not a criterion — it passes in a repo where the feature was never built.
706
707
  - "goal:" is NEVER verification. Prose in the goal enforces nothing: every obligation needs an
@@ -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)
@@ -705,6 +748,11 @@ export function configTemplate(overlay) {
705
748
  # tiers: # model → capability band; extend when new models ship
706
749
  # claude-code: { vendor: anthropic, channel: sub, models: { fable: frontier } }
707
750
  # codex: { models: { gpt-5.2-codex: null } } # null = tombstone: removes a stale seed id
751
+ # tier evidence: \`tickmarkr doctor --refresh-catalog\` fetches the catalogs behind tier suggestions.
752
+ # models.dev and LiveBench need no key. The Artificial Analysis leg runs ONLY when the environment
753
+ # carries ARTIFICIAL_ANALYSIS_API_KEY (free key: artificialanalysis.ai) — unset, AA contributes
754
+ # nothing and suggestions fall back to LiveBench's agentic-coding rank, then price. Not a config
755
+ # key: an API credential belongs in the environment, never in a tracked overlay.
708
756
  # visibility:
709
757
  # llm: headless # headless (default): judge/review/consult run silently | pane: visible agents
710
758
  # keepPanes: run # run (default): ephemeral judge/review/consult panes close when read; a merged task's worker pane closes on done; other worker panes persist until run end | attempt | forever (keep everything for debugging)
@@ -776,7 +824,18 @@ export function readOverlayFile(path) {
776
824
  throw new ConfigError(`invalid overlay at ${path}`);
777
825
  return raw;
778
826
  }
779
- 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) {
780
839
  const tiers = {};
781
840
  for (const [adapter, entry] of Object.entries(cfg.tiers)) {
782
841
  tiers[adapter] = {};
@@ -784,9 +843,46 @@ export function fleetEditableFromConfig(cfg) {
784
843
  tiers[adapter][model] = { tier };
785
844
  }
786
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
+ }
787
883
  return {
788
- denyAdapters: [...(cfg.routing.deny?.adapters ?? [])].sort(),
789
- denyModels: [...(cfg.routing.deny?.models ?? [])].sort(),
884
+ denyAdapters,
885
+ denyModels,
790
886
  tiers,
791
887
  map: structuredClone(cfg.routing.map),
792
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
- import { isMap, isScalar, isSeq, parseDocument, stringify } from "yaml";
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,11 +14,49 @@ 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.
19
55
  const FIRST_TOUCH_OPEN = "\uE000tickmarkr-fleet-first-touch:";
20
56
  const FIRST_TOUCH_CLOSE = "\uE001";
57
+ // OBS-505: marks a scalar-trailing single-line comment for the two-space inline note style at
58
+ // stringify time; applied and consumed inside renderFleetOverlayWrite, never written to disk.
59
+ const INLINE_COMMENT_SENTINEL = "\uE002";
21
60
  export function fleetFirstTouchProvenance(note, firstTouch) {
22
61
  return `${FIRST_TOUCH_OPEN}${encodeURIComponent(firstTouch.vendor)}:${firstTouch.channel}${FIRST_TOUCH_CLOSE}${note}`;
23
62
  }
@@ -103,8 +142,47 @@ export function renderFleetOverlayWrite(priorBytes, write) {
103
142
  const denyChanged = sortedUnique(initial.denyAdapters).join() !== sortedUnique(edited.denyAdapters).join()
104
143
  || sortedUnique(initial.denyModels).join() !== sortedUnique(edited.denyModels).join();
105
144
  if (denyChanged) {
106
- setStringSequencePreservingComments(doc, ["routing", "deny", "adapters"], edited.denyAdapters.length ? sortedUnique(edited.denyAdapters) : null);
107
- 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
+ }
108
186
  }
109
187
  for (const shape of new Set([...Object.keys(initial.map), ...Object.keys(edited.map)])) {
110
188
  const before = initial.map[shape];
@@ -115,9 +193,33 @@ export function renderFleetOverlayWrite(priorBytes, write) {
115
193
  else
116
194
  doc.setIn(["routing", "map", shape, "pin"], doc.createNode(after.pin));
117
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
+ }
118
211
  if (JSON.stringify(before?.prefer) !== JSON.stringify(after?.prefer)) {
119
- // Clearing a resolved list must mask the lower layer with [], not delete the key and inherit it again.
120
- 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
+ }
121
223
  }
122
224
  if (before?.escalate !== after?.escalate) {
123
225
  if (after?.escalate === undefined)
@@ -189,14 +291,31 @@ export function renderFleetOverlayWrite(priorBytes, write) {
189
291
  }
190
292
  }
191
293
  }
192
- // Fleet's established note style uses two spaces before `#`; the Document remains the sole
193
- // comment writer while preserving that byte-level convention for old and newly-authored notes.
294
+ // OBS-505: fleet's two-space note style (`tier: mid # note`) applies to INLINE comments only.
295
+ // The previous commentString prefixed " #" onto EVERY comment line, so block comments — the
296
+ // whole init scaffold at column 0, and indented operator essays — each gained a stray leading
297
+ // space, turning a one-key write into a whole-file diff on the one confirmation surface an
298
+ // operator reviews. commentString has no position context, but this writer owns the document:
299
+ // scalar-trailing single-line comments (the only inline form fleet emits) are marked with a
300
+ // private-use sentinel (the FIRST_TOUCH envelope precedent above), everything else renders
301
+ // byte-identical to yaml's own stringifyComment.
302
+ visit(doc, (_key, node) => {
303
+ if (isScalar(node) && typeof node.comment === "string" && !node.comment.includes("\n")) {
304
+ node.comment = `${INLINE_COMMENT_SENTINEL}${node.comment}`;
305
+ }
306
+ });
194
307
  return doc.toString({
195
- commentString: (comment) => comment.replace(/^(?!$)(?: $)?/gm, " #"),
308
+ commentString: (comment) => comment.startsWith(INLINE_COMMENT_SENTINEL)
309
+ ? ` #${comment.slice(1)}`
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,
196
315
  });
197
316
  }
198
317
  /** Build the repo overlay fragment fleet would write for edits since session start. */
199
- export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}, firstTouches = {}) {
318
+ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}, firstTouches = {}, universe) {
200
319
  if (fleetEditableEquals(initial, edited))
201
320
  return {};
202
321
  const out = structuredClone(existingRepo);
@@ -205,19 +324,45 @@ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}, fi
205
324
  const denyChanged = sortedUnique(initial.denyAdapters).join() !== sortedUnique(edited.denyAdapters).join()
206
325
  || sortedUnique(initial.denyModels).join() !== sortedUnique(edited.denyModels).join();
207
326
  if (denyChanged) {
208
- routing.deny = {
209
- adapters: edited.denyAdapters.length ? edited.denyAdapters : null,
210
- models: edited.denyModels.length ? edited.denyModels : null,
211
- };
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
+ }
212
351
  routingTouched = true;
213
352
  }
353
+ // pool widened to accept the null tombstone; MapEntry itself never carries null in memory.
214
354
  const mapDelta = {};
215
355
  for (const shape of new Set([...Object.keys(initial.map), ...Object.keys(edited.map)])) {
216
356
  if (JSON.stringify(initial.map[shape]) !== JSON.stringify(edited.map[shape])) {
217
357
  const next = { ...edited.map[shape] };
218
- 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) {
219
360
  next.prefer = [];
220
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
+ }
221
366
  mapDelta[shape] = next;
222
367
  }
223
368
  }
@@ -75,10 +75,17 @@ function escapeRegExp(s) {
75
75
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
76
76
  }
77
77
  // v1.89 T6: Vitest applies -t to the runner-visible full name (enclosing describe titles and the test
78
- // title, space-joined). Escaping preserves literal criteria; anchoring makes equality, rather than
79
- // substring containment, the named-test contract.
78
+ // title, space-joined). Escaping preserves literal criteria; the leaf anchor makes trailing-title
79
+ // equality, rather than substring containment, the named-test contract.
80
+ // OBS-511 (runs 1013+1027, one full attempt lost in EACH): two independent workers nested the verbatim
81
+ // criterion under a describe, the old `^…$` full-name equality matched zero tests, and the gate parked a
82
+ // green suite. `(^| )` accepts any describe prefix while keeping the criterion as the complete trailing
83
+ // segment — equality still matches (empty prefix), and a title merely CONTAINING the criterion still
84
+ // selects zero. ponytail: a leaf title that itself ENDS with " <criterion>" also matches — vitest -t sees
85
+ // only the space-joined full name, so true leaf-boundary precision needs the runner's --list JSON
86
+ // (auditAcceptanceCorpus has it at doctor time); upgrade there if this window is ever exploited.
80
87
  export function testFilterPattern(name) {
81
- return `^${escapeRegExp(name)}$`;
88
+ return `(^| )${escapeRegExp(name)}$`;
82
89
  }
83
90
  // OBS-55: when the base command already contains `--`, append -t after forwarded args — a second `--`
84
91
  // makes vitest treat -t as a positional file filter and the name filter is dropped.
@@ -126,8 +133,10 @@ export function auditAcceptanceCorpus(corpusRoot, listedTests) {
126
133
  ? {
127
134
  namedTest: {
128
135
  criterion: item.test,
136
+ // OBS-511: mirror the gate's leaf-anchored suffix rule — the audit's denominator
137
+ // must count exactly the tests the -t filter would select, or doctor and gate disagree.
129
138
  matches: runnerNames
130
- .filter(({ fullName }) => fullName === item.test)
139
+ .filter(({ fullName }) => fullName === item.test || fullName.endsWith(` ${item.test}`))
131
140
  .map(({ listed }) => listed),
132
141
  },
133
142
  }
@@ -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;
@@ -1944,7 +1944,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1944
1944
  const promptFile = writePrompt(journal.dir, t, attempt, feedback, nonce);
1945
1945
  // OBS-56: state the non-interactive, one-pass finish contract and the OBS-54 stall budget in every
1946
1946
  // worker prompt, not only consult retry guidance. Prepended so prompt.ts's completion trailer stays last.
1947
- const workerContract = `## Harness contract\n- This harness is non-interactive: make one continuous pass; do not stop for questions or follow-up input.\n- You have a ${taskTimeoutMinutes} minute stall window. Budget the full suite once, then commit and emit the completion trailer before it expires.\n- Each test: acceptance criterion must exist as a vitest test whose title matches the criterion string verbatim.`; // OBS-64
1947
+ const workerContract = `## Harness contract\n- This harness is non-interactive: make one continuous pass; do not stop for questions or follow-up input.\n- You have a ${taskTimeoutMinutes} minute stall window. Budget the full suite once, then commit and emit the completion trailer before it expires.\n- Each test: acceptance criterion must exist as a vitest test whose OWN title (the leaf, not counting enclosing describe titles) is the criterion string verbatim — never shortened, never decorated. Nesting under describe() is allowed.`; // OBS-64; OBS-511: leaf-title rule stated where the worker reads it
1948
1948
  // OBS-47: state the worktree layout contract in the worker prompt (cheap-tier workers were
1949
1949
  // committing/deleting node_modules and tripping the scope gate). The harness re-asserts the link
1950
1950
  // itself before gates regardless of what the worker does with it.