tickmarkr 1.51.0 → 1.52.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.
@@ -126,6 +126,9 @@ export function hasCodexTrustedProject(text, root) {
126
126
  export const codex = {
127
127
  id: "codex",
128
128
  vendor: "openai",
129
+ // OBS-72: codex probes self-contend in one repo (v1.33.5: 4 concurrent → all 4 timed out;
130
+ // 2026-07-18: gpt-5.5 lost the concurrent pair every doctor sweep) — probe one model at a time.
131
+ probeConcurrency: 1,
129
132
  probe: async () => probeVersion("codex"),
130
133
  channels: (cfg) => channelsFromConfig("codex", cfg),
131
134
  // --sandbox workspace-write is the autonomous sandbox mode (codex v0.144.1+)
@@ -17,7 +17,6 @@ export type RoutedAssignment = {
17
17
  /** Advisory only — absent windows config or undeclared model window ⇒ no lint. */
18
18
  export declare function contextWindowLints(tasks: ReadonlyArray<Task>, assignments: ReadonlyArray<RoutedAssignment>, cfg: TickmarkrConfig, repoRoot: string): string[];
19
19
  export declare function seedPreferLints(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], overlayPreferShapes?: ReadonlySet<string>): string[];
20
- export declare function mapTierLints(cfg: TickmarkrConfig): string[];
21
20
  export declare function modelLints(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], opts?: {
22
21
  tty?: boolean;
23
22
  stateDir?: string;
@@ -68,8 +68,8 @@ export function contextWindowLints(tasks, assignments, cfg, repoRoot) {
68
68
  return lints;
69
69
  }
70
70
  const adapterHasAuthedChannel = (adapterId, shape, cfg, health, adapters) => {
71
- const entry = cfg.routing.map[shape];
72
- const minTier = entry?.tier ?? cfg.routing.floors[shape] ?? "cheap";
71
+ // v1.52 T5: routing.floors is the only band authority — a map entry can no longer carry a tier.
72
+ const minTier = cfg.routing.floors[shape] ?? "cheap";
73
73
  const a = adapters.find((x) => x.id === adapterId);
74
74
  const h = health[adapterId];
75
75
  if (!a || !h?.installed || typeof a.channels !== "function")
@@ -95,14 +95,6 @@ export function seedPreferLints(cfg, health, adapters, overlayPreferShapes = new
95
95
  }
96
96
  return lints;
97
97
  }
98
- // v1.51 T3: built-in defaults no longer carry map tiers, so any tier surviving the merge came from an
99
- // overlay. Deprecated — routing.floors is the single band authority; MapEntrySchema.tier removal itself
100
- // is deferred to the next version. One lint per shape, identical text on every surface (plan + doctor).
101
- export function mapTierLints(cfg) {
102
- return Object.entries(cfg.routing.map)
103
- .filter(([, entry]) => entry.tier !== undefined)
104
- .map(([shape, entry]) => `routing.map.${shape}.tier: ${entry.tier} is deprecated — move this value to routing.floors.${shape} (single band authority; tier removal deferred to next version)`);
105
- }
106
98
  // Diffs detected models (doctor.json) against configured tiers, both directions, per adapter id in cfg.tiers.
107
99
  // No ` ! ` prefix here — the consumer (doctor rows / plan lints) owns that. Pre-v1.5 doctor.json (models:[], no
108
100
  // modelsDetectedAt) is the compat baseline: `?.`/`?? []` everywhere, no zod (would reject old files).
@@ -145,7 +137,6 @@ export function modelLints(cfg, health, adapters, opts) {
145
137
  }
146
138
  }
147
139
  lints.push(...seedPreferLints(cfg, health, adapters, opts?.overlayPreferShapes));
148
- lints.push(...mapTierLints(cfg)); // v1.51 T3: shared here so plan and doctor draw the exact same message
149
140
  return lints;
150
141
  }
151
142
  // T2/T6: one lint per exclusion, naming the probe reason and date. TTY truncates reasons to 60 chars and
@@ -82,8 +82,10 @@ function probeFailure(code, stdout, stderr, timedOut, timeoutMs = MODEL_PROBE_TI
82
82
  if (timedOut)
83
83
  return `probe timed out after ${timeoutMs}ms`;
84
84
  const output = `${stderr}\n${stdout}`.trim().replace(/\s+/g, " ");
85
+ // OBS-72: TAIL, not head — the error lands at the END of CLI output; a head slice stores only the
86
+ // startup banner and hid the real "Not inside a trusted directory" failure for a day.
85
87
  return code !== 0 || QUOTA_RE.test(output) || AUTH_FAILURE_RE.test(output)
86
- ? output.slice(0, 240) || `probe exited ${code}`
88
+ ? output.slice(-240) || `probe exited ${code}`
87
89
  : undefined;
88
90
  }
89
91
  export const pendingAutoPreferKey = Symbol.for("tickmarkr.pendingAutoPrefer");
@@ -97,8 +99,9 @@ export const DOCTOR_ROUTING_STALE_MS = 24 * 60 * 60 * 1000;
97
99
  export function deriveAutoPrefer(cfg, adapters, health, profile) {
98
100
  const derivedAt = new Date().toISOString();
99
101
  const out = { derivedAt };
100
- for (const [shape, entry] of Object.entries(cfg.routing.map)) {
101
- const minTier = entry?.tier ?? cfg.routing.floors[shape] ?? "cheap";
102
+ // v1.52 T5: routing.floors is the only band authority — a map entry can no longer carry a tier.
103
+ for (const shape of Object.keys(cfg.routing.map)) {
104
+ const minTier = cfg.routing.floors[shape] ?? "cheap";
102
105
  const ranked = [];
103
106
  for (const a of adapters) {
104
107
  const h = health[a.id];
@@ -153,54 +156,70 @@ export async function probeModels(cfg, repoRoot, adapters, health, onProgress) {
153
156
  const verdicts = {};
154
157
  const priorModelAuth = priorHealth?.[a.id]?.modelAuth;
155
158
  const probeRoot = a.probeCwd === "neutral" ? mkdtempSync(join(tmpdir(), "tickmarkr-probe-")) : repoRoot;
156
- // models probe concurrently too (v1.33.5) sequential chains made init wall time Σ(models×60s)
157
- // on the slowest adapter (measured 96s); each probe is one tiny prompt, safe to overlap.
158
- // Capped at MODEL_PROBE_CONCURRENCY per adapter (v1.33.5 regression: 4 concurrent codex exec
159
- // in one repo made ALL 4 codex probes time out where sequential passed 2/4).
160
- await mapLimit(Object.keys(cfg.tiers[a.id]?.models ?? {}), MODEL_PROBE_CONCURRENCY, async (model) => {
159
+ const store = (model, verdict) => {
160
+ verdicts[model] = verdict;
161
+ onProgress?.(a.id, model, probeModelStatus(verdict), verdict.durationMs);
162
+ };
163
+ // One bounded probe call. verdict:null = a first-pass failure that earns the one serial retry
164
+ // (OBS-72: a failure inside the concurrent batch is indistinguishable from adapter self-contention
165
+ // until re-probed alone); a retry attempt always returns a final verdict.
166
+ const attempt = async (model, retry) => {
161
167
  const t0 = Date.now();
162
168
  const probedAt = new Date().toISOString();
163
- const priorTimedOut = priorModelAuth?.[model]?.reason?.includes("timed out") === true;
164
- let verdict;
169
+ const v = (authed, reason) => ({ authed, ...(reason !== undefined ? { reason } : {}), probedAt, durationMs: Date.now() - t0 });
165
170
  try {
166
- if (typeof a.headlessCommand !== "function") {
167
- verdict = { authed: false, reason: "headless probe unavailable", probedAt, durationMs: Date.now() - t0 };
168
- }
169
- else {
170
- const promptFile = join(mkdtempSync(join(tmpdir(), "tickmarkr-auth-")), "probe.md");
171
- writeFileSync(promptFile, MODEL_PROBE_PROMPT);
172
- const r = await sh(a.headlessCommand(promptFile, model), probeRoot, MODEL_PROBE_TIMEOUT_MS);
173
- if (r.timedOut && priorTimedOut) {
174
- verdict = { authed: false, reason: `probe timed out (repeat — retry skipped) (${MODEL_PROBE_TIMEOUT_MS}ms)`, probedAt, durationMs: Date.now() - t0 };
175
- }
176
- else if (r.timedOut) {
177
- const r2 = await sh(a.headlessCommand(promptFile, model), probeRoot, MODEL_PROBE_TIMEOUT_MS);
178
- if (r2.timedOut) {
179
- verdict = { authed: false, reason: `probe timed out twice (${MODEL_PROBE_TIMEOUT_MS}ms)`, probedAt, durationMs: Date.now() - t0 };
180
- }
181
- else {
182
- const reason = probeFailure(r2.code, r2.stdout, r2.stderr, r2.timedOut, MODEL_PROBE_TIMEOUT_MS);
183
- verdict = reason ? { authed: false, reason, probedAt, durationMs: Date.now() - t0 } : { authed: true, probedAt, durationMs: Date.now() - t0 };
184
- }
185
- }
186
- else {
187
- const reason = probeFailure(r.code, r.stdout, r.stderr, r.timedOut, MODEL_PROBE_TIMEOUT_MS);
188
- verdict = reason ? { authed: false, reason, probedAt, durationMs: Date.now() - t0 } : { authed: true, probedAt, durationMs: Date.now() - t0 };
189
- }
171
+ if (typeof a.headlessCommand !== "function")
172
+ return { verdict: v(false, "headless probe unavailable"), timedOut: false };
173
+ const promptFile = join(mkdtempSync(join(tmpdir(), "tickmarkr-auth-")), "probe.md");
174
+ writeFileSync(promptFile, MODEL_PROBE_PROMPT);
175
+ const r = await sh(a.headlessCommand(promptFile, model), probeRoot, MODEL_PROBE_TIMEOUT_MS);
176
+ // T2 rule unchanged: a prior doctor.json timeout skips the retry — a persistently dead
177
+ // model (e.g. opencode glm-5.2) costs one attempt instead of two every run.
178
+ if (r.timedOut && !retry && priorModelAuth?.[model]?.reason?.includes("timed out") === true) {
179
+ return { verdict: v(false, `probe timed out (repeat — retry skipped) (${MODEL_PROBE_TIMEOUT_MS}ms)`), timedOut: true };
190
180
  }
181
+ const reason = probeFailure(r.code, r.stdout, r.stderr, r.timedOut, MODEL_PROBE_TIMEOUT_MS);
182
+ if (!reason)
183
+ return { verdict: v(true), timedOut: false };
184
+ if (!retry)
185
+ return { verdict: null, timedOut: r.timedOut === true };
186
+ return {
187
+ verdict: r.timedOut && retry.firstTimedOut ? v(false, `probe timed out twice (${MODEL_PROBE_TIMEOUT_MS}ms)`) : v(false, reason),
188
+ timedOut: r.timedOut === true,
189
+ };
191
190
  }
192
191
  catch (e) {
193
- verdict = { authed: false, reason: String(e), probedAt, durationMs: Date.now() - t0 };
192
+ return { verdict: retry ? v(false, String(e)) : null, timedOut: false };
194
193
  }
195
- verdicts[model] = verdict;
196
- onProgress?.(a.id, model, probeModelStatus(verdict), verdict.durationMs);
194
+ };
195
+ // models probe concurrently too (v1.33.5) sequential chains made init wall time Σ(models×60s)
196
+ // on the slowest adapter (measured 96s); each probe is one tiny prompt, safe to overlap.
197
+ // Capped at MODEL_PROBE_CONCURRENCY per adapter unless the adapter declares its own cap
198
+ // (codex: 1 — OBS-72, its concurrent probes self-contend in one repo).
199
+ const retries = [];
200
+ await mapLimit(Object.keys(cfg.tiers[a.id]?.models ?? {}), a.probeConcurrency ?? MODEL_PROBE_CONCURRENCY, async (model) => {
201
+ const first = await attempt(model);
202
+ if (first.verdict)
203
+ store(model, first.verdict);
204
+ else
205
+ retries.push({ model, firstTimedOut: first.timedOut });
197
206
  });
207
+ // OBS-72: re-probe each first-pass failure once with ONE probe in flight, only after the
208
+ // concurrent batch drained — an in-slot retry still races its concurrency partner. Success here
209
+ // was contention; a second failure is the real verdict. Successful first-pass probes stored
210
+ // above and never wait; cost is one extra call per genuinely dead model only.
211
+ for (const { model, firstTimedOut } of retries) {
212
+ const second = await attempt(model, { firstTimedOut });
213
+ if (second.verdict)
214
+ store(model, second.verdict);
215
+ }
198
216
  h.modelAuth = verdicts;
199
217
  }));
200
218
  health[pendingAutoPreferKey] = deriveAutoPrefer(cfg, adapters, health);
201
219
  }
202
220
  // T2: caps concurrent probes per adapter at 2 — v1.33.5 regression, 4 concurrent codex exec in one
203
221
  // repo made ALL 4 time out where sequential passed 2/4 (suspected CLI self-contention).
222
+ // v1.52 T4: default only — an adapter's own probeConcurrency declaration wins (codex: 1).
204
223
  const MODEL_PROBE_CONCURRENCY = 2;
205
224
  async function mapLimit(items, limit, fn) {
206
225
  let i = 0;
@@ -73,6 +73,7 @@ export interface WorkerAdapter {
73
73
  id: string;
74
74
  vendor: string;
75
75
  probeCwd?: "repo" | "neutral";
76
+ probeConcurrency?: number;
76
77
  probe(): Promise<AuthHealth>;
77
78
  channels(cfg: TickmarkrConfig): BillingChannel[];
78
79
  headlessCommand(promptFile: string, model: string): string;
@@ -6,7 +6,7 @@ import { parseArgs } from "node:util";
6
6
  import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, readAutoPrefer } from "../../adapters/registry.js";
7
7
  import { fleetUnclassifiedModels } from "../../adapters/model-lints.js";
8
8
  import { GLYPHS, bold, legend as legendText, toggleActive, toggleInactive } from "../../brand.js";
9
- import { fleetEditableFromConfig, fleetEditableEquals, fleetRepoOverlayFromDelta, formatFleetPrint, globalConfigDir, overlayPreferShapes, readOverlayFile, repoOverlayPath, repoOverlayYaml, ROUTING_MODES, unifiedYamlDiff, } from "../../config/config.js";
9
+ import { fleetEditableFromConfig, fleetEditableEquals, fleetRepoOverlayFromDelta, formatFleetPrint, globalConfigDir, overlayBytesLoadError, overlayPreferShapes, readOverlayFile, repoOverlayPath, repoOverlayYaml, ROUTING_MODES, unifiedYamlDiff, } from "../../config/config.js";
10
10
  import { SHAPES, TIERS } from "../../graph/schema.js";
11
11
  import { route } from "../../route/router.js";
12
12
  import { resolveRunMode } from "../../run/daemon.js";
@@ -357,7 +357,8 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
357
357
  return `${shape} → ${now}${auto}`;
358
358
  });
359
359
  const shapesTitle = "step 5/5 · shape routing";
360
- const shapesLegend = "↑↓/jk move · a auto · p pin · t tier · f prefer · enter next · esc/q quit";
360
+ // v1.52 T5: no map-tier editing action routing.floors is the only band authority now.
361
+ const shapesLegend = "↑↓/jk move · a auto · p pin · f prefer · enter next · esc/q quit";
361
362
  let sCursor = 0;
362
363
  term.frame(listFrame(shapesTitle, shapesLegend, shapeRows(), sCursor));
363
364
  for (;;) {
@@ -388,15 +389,6 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
388
389
  editable.map[shape] = { pin: { via: pin.slice(0, i), model: pin.slice(i + 1) } };
389
390
  changed = true;
390
391
  }
391
- else if (k.name === "t") {
392
- const tier = (await term.askTyped(`tier (${TIERS.join("|")})> `));
393
- if (!TIERS.includes(tier))
394
- return { out: `fleet: invalid tier ${tier}`, code: 1 };
395
- const next = { ...entry, tier };
396
- delete next.pin;
397
- editable.map[shape] = next;
398
- changed = true;
399
- }
400
392
  else if (k.name === "f") {
401
393
  const pref = await term.askTyped("prefer (comma-separated adapters or adapter:model)> ");
402
394
  editable.map[shape] = { ...entry, prefer: pref.split(",").map((s) => s.trim()).filter(Boolean) };
@@ -424,6 +416,13 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
424
416
  if (!/^y(?:es)?$/i.test(confirm))
425
417
  return "fleet: discarded overlay changes";
426
418
  const path = repoOverlayPath(cwd);
419
+ // v1.52 T2 reload guard: the exact bytes about to land must reload through the production
420
+ // loader path, or the write is refused and the existing overlay stays untouched (OBS-75 class:
421
+ // fleet must never persist a config that bricks every later command).
422
+ const loadError = overlayBytesLoadError(cwd, after, { globalDir });
423
+ if (loadError) {
424
+ return { out: `fleet: refusing to write ${path} — the config loader rejects the proposed overlay:\n${loadError}`, code: 1 };
425
+ }
427
426
  mkdirSync(dirname(path), { recursive: true });
428
427
  writeFileSync(path, after);
429
428
  return `fleet: wrote ${path}`;
@@ -20,11 +20,7 @@ export declare const MapEntrySchema: z.ZodObject<{
20
20
  via: z.ZodString;
21
21
  model: z.ZodString;
22
22
  }, z.core.$strip>>;
23
- tier: z.ZodOptional<z.ZodEnum<{
24
- cheap: "cheap";
25
- mid: "mid";
26
- frontier: "frontier";
27
- }>>;
23
+ tier: z.ZodOptional<z.ZodPipe<z.ZodOptional<z.ZodNull>, z.ZodTransform<undefined, null | undefined>>>;
28
24
  prefer: z.ZodOptional<z.ZodArray<z.ZodString>>;
29
25
  escalate: z.ZodOptional<z.ZodBoolean>;
30
26
  }, z.core.$strip>;
@@ -78,11 +74,7 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
78
74
  via: z.ZodString;
79
75
  model: z.ZodString;
80
76
  }, z.core.$strip>>;
81
- tier: z.ZodOptional<z.ZodEnum<{
82
- cheap: "cheap";
83
- mid: "mid";
84
- frontier: "frontier";
85
- }>>;
77
+ tier: z.ZodOptional<z.ZodPipe<z.ZodOptional<z.ZodNull>, z.ZodTransform<undefined, null | undefined>>>;
86
78
  prefer: z.ZodOptional<z.ZodArray<z.ZodString>>;
87
79
  escalate: z.ZodOptional<z.ZodBoolean>;
88
80
  }, z.core.$strip>>;
@@ -219,6 +211,7 @@ export type ModeResolution = {
219
211
  * already applied to cfg.routing.floors — route() consumes floors only and never sees the mode. */
220
212
  export declare function loadConfigWithMode(repoRoot: string, opts?: {
221
213
  globalDir?: string;
214
+ repoOverlayText?: string;
222
215
  }): {
223
216
  cfg: TickmarkrConfig;
224
217
  mode: ModeResolution;
@@ -226,6 +219,12 @@ export declare function loadConfigWithMode(repoRoot: string, opts?: {
226
219
  export declare function loadConfig(repoRoot: string, opts?: {
227
220
  globalDir?: string;
228
221
  }): TickmarkrConfig;
222
+ /** v1.52 T2 write-time reload guard: run candidate repo-overlay bytes through the SAME production
223
+ * loader path every later command uses (parse → merge → schema → mode resolution). Returns null
224
+ * when the bytes load, else the loader's failure message — the caller must refuse the write. */
225
+ export declare function overlayBytesLoadError(repoRoot: string, bytes: string, opts?: {
226
+ globalDir?: string;
227
+ }): string | null;
229
228
  export type InitConfigOverlay = {
230
229
  concurrency?: number;
231
230
  driver?: TickmarkrConfig["driver"];
@@ -20,9 +20,21 @@ const ModeEnum = z.enum(ROUTING_MODES, {
20
20
  // Integrity set (weak-oracle shapes, OBS-68..70 class): no mode may resolve these below frontier.
21
21
  // Only an explicit operator floor line can — and it draws a standing plan lint every load.
22
22
  export const INTEGRITY_FLOOR_SHAPES = ["plan", "spec", "migration", "ui"];
23
+ // v1.52 T5: tier is no longer a map-entry band authority — routing.floors is the only tier
24
+ // source left (v1.51 T3 deprecated this field with a promise to remove it here). Only the legacy
25
+ // `tier: null` tombstone still parses (and normalizes to absent); any real value fails config
26
+ // load fail-closed, naming routing.floors as the move target, so a band intent set here can never
27
+ // silently vanish. The field stays in the schema/type as an always-undefined marker (not deleted
28
+ // outright) so a map entry can never carry a real tier value again, structurally.
23
29
  export const MapEntrySchema = z.object({
24
30
  pin: z.object({ via: z.string(), model: z.string() }).optional(),
25
- tier: TierEnum.optional(),
31
+ tier: z
32
+ .null({
33
+ 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)`,
34
+ })
35
+ .optional()
36
+ .transform(() => undefined)
37
+ .optional(),
26
38
  prefer: z.array(z.string()).optional(),
27
39
  escalate: z.boolean().optional(),
28
40
  });
@@ -167,11 +179,10 @@ export const DEFAULT_CONFIG = {
167
179
  taskTimeoutMinutes: 30,
168
180
  contextWarnTokens: 170_000, // v1.23 T2: overseer ctx-watch.sh proven threshold
169
181
  routing: {
170
- // v1.51 T3 (round-2 consult): map entries carry preferences/pins only — band policy lives in
171
- // routing.floors, the single tier authority. The dropped tiers (implement mid, tests cheap,
172
- // docs cheap) were byte-equal to the floors below, so routing is unchanged. Overlay map tiers
173
- // still parse but draw a move-to-floors lint in plan/doctor; MapEntrySchema.tier removal itself
174
- // is deferred to the next version (tombstone grammar: tier: null).
182
+ // v1.51 T3 (round-2 consult) / v1.52 T5: map entries carry preferences/pins only — band
183
+ // policy lives in routing.floors, the single tier authority. A map entry can no longer carry
184
+ // a real tier value at all: MapEntrySchema fails config load fail-closed on one, naming
185
+ // routing.floors as the move target; only the legacy `tier: null` tombstone still parses.
175
186
  map: {
176
187
  plan: { pin: { via: "claude-code", model: "fable" } },
177
188
  spec: { pin: { via: "claude-code", model: "fable" } },
@@ -294,7 +305,9 @@ function deepMerge(base, over) {
294
305
  delete out[k]; // v1.1 tombstone: an explicit null in an overlay removes the key (e.g. stale tiers model ids)
295
306
  continue;
296
307
  }
297
- out[k] = k in out ? deepMerge(out[k], v) : v;
308
+ // OBS-75 class: merge fresh-landing objects onto {} so nested tombstone nulls are pruned even when
309
+ // no lower layer set the key (a fleet-cleared deny writes {adapters: null} — the schema rejects raw null)
310
+ out[k] = k in out ? deepMerge(out[k], v) : deepMerge({}, v);
298
311
  }
299
312
  return out;
300
313
  }
@@ -393,7 +406,11 @@ function resolveRoutingMode(cfg, layers) {
393
406
  * already applied to cfg.routing.floors — route() consumes floors only and never sees the mode. */
394
407
  export function loadConfigWithMode(repoRoot, opts = {}) {
395
408
  const globalCfg = readYaml(join(opts.globalDir ?? globalConfigDir(), "config.yaml"));
396
- const repoCfg = readYaml(join(repoRoot, stateDirName(repoRoot), "config.yaml"));
409
+ // v1.52 T2: repoOverlayText substitutes candidate bytes for the on-disk repo layer — the fleet
410
+ // write guard validates EXACTLY what it is about to write through this one loader path.
411
+ const repoCfg = opts.repoOverlayText === undefined
412
+ ? readYaml(join(repoRoot, stateDirName(repoRoot), "config.yaml"))
413
+ : parse(opts.repoOverlayText);
397
414
  const merged = deepMerge(deepMerge(structuredClone(DEFAULT_CONFIG), globalCfg), repoCfg);
398
415
  const r = TickmarkrConfigSchema.safeParse(merged);
399
416
  if (!r.success)
@@ -403,6 +420,18 @@ export function loadConfigWithMode(repoRoot, opts = {}) {
403
420
  export function loadConfig(repoRoot, opts = {}) {
404
421
  return loadConfigWithMode(repoRoot, opts).cfg;
405
422
  }
423
+ /** v1.52 T2 write-time reload guard: run candidate repo-overlay bytes through the SAME production
424
+ * loader path every later command uses (parse → merge → schema → mode resolution). Returns null
425
+ * when the bytes load, else the loader's failure message — the caller must refuse the write. */
426
+ export function overlayBytesLoadError(repoRoot, bytes, opts = {}) {
427
+ try {
428
+ loadConfigWithMode(repoRoot, { ...opts, repoOverlayText: bytes });
429
+ return null;
430
+ }
431
+ catch (e) {
432
+ return e.message;
433
+ }
434
+ }
406
435
  export function configTemplate(overlay) {
407
436
  const base = `# tickmarkr config overlay — merges over built-in defaults (repo beats global beats defaults)
408
437
  # concurrency: 3
@@ -418,7 +447,7 @@ export function configTemplate(overlay) {
418
447
  # map: # per-shape preferences/pins; band policy lives in routing.floors
419
448
  # implement: { prefer: [cursor-agent, codex] }
420
449
  # migration: { pin: { via: claude-code, model: fable } }
421
- # tests: { tier: null } # tombstone: null removes a legacy map tier (deprecated — move the band to routing.floors.tests; schema removal lands next version)
450
+ # 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)
422
451
  # floors: # tier authority — advisory minimum bands; 'tickmarkr plan' lints violations
423
452
  # migration: frontier
424
453
  # 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'
@@ -656,7 +685,9 @@ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}) {
656
685
  }
657
686
  }
658
687
  if (Object.keys(modelDelta).length) {
659
- tiersOut[adapter] = { models: { ...tiersOut[adapter]?.models, ...modelDelta } };
688
+ // spread the existing entry so vendor/channel/windows survive the rewrite — dropping them
689
+ // makes the overlay unloadable for any adapter without a default seed (reload-guard class)
690
+ tiersOut[adapter] = { ...tiersOut[adapter], models: { ...tiersOut[adapter]?.models, ...modelDelta } };
660
691
  tiersTouched = true;
661
692
  }
662
693
  }
@@ -682,47 +713,58 @@ export function serializeFleetOverlay(overlay, provenance = {}) {
682
713
  return "";
683
714
  const lines = [];
684
715
  const today = new Date().toISOString().slice(0, 10);
716
+ // OBS-75: never glue stringify() output onto a key line — wrap the key into the object and
717
+ // re-indent the whole emitted block, so sequences/nested maps nest correctly and null
718
+ // tombstones/empty collections survive the serialize→parse round-trip.
719
+ const block = (obj, pad) => stringify(obj).trimEnd().split("\n").map((l) => `${pad}${l}`);
685
720
  const routing = overlay.routing;
686
721
  if (routing) {
687
722
  lines.push("routing:");
688
723
  const deny = routing.deny;
689
- if (deny) {
724
+ if (deny && (deny.adapters !== undefined || deny.models !== undefined)) {
690
725
  lines.push(" deny:");
691
- if (deny.adapters)
692
- lines.push(` adapters: ${stringify(deny.adapters).trim()}`);
693
- if (deny.models)
694
- lines.push(` models: ${stringify(deny.models).trim()}`);
695
- }
696
- if (routing.map) {
697
- lines.push(" map:");
698
- for (const [shape, entry] of Object.entries(routing.map)) {
699
- lines.push(` ${shape}:`);
700
- const body = stringify(entry, { indent: 2 }).trim().split("\n");
701
- lines.push(...body.map((l) => ` ${l}`));
702
- }
726
+ if (deny.adapters !== undefined)
727
+ lines.push(...block({ adapters: deny.adapters }, " "));
728
+ if (deny.models !== undefined)
729
+ lines.push(...block({ models: deny.models }, " "));
703
730
  }
731
+ if (routing.map)
732
+ lines.push(...block({ map: routing.map }, " "));
704
733
  if (routing.floors)
705
- lines.push(` floors: ${stringify(routing.floors).trim().replace(/^/gm, " ")}`);
734
+ lines.push(...block({ floors: routing.floors }, " "));
706
735
  }
707
736
  const tiers = overlay.tiers;
708
- if (tiers) {
737
+ if (tiers && Object.keys(tiers).length) {
709
738
  lines.push("tiers:");
710
739
  for (const [adapter, entry] of Object.entries(tiers)) {
711
- lines.push(` ${adapter}:`);
740
+ const body = [];
712
741
  if (entry.vendor)
713
- lines.push(` vendor: ${entry.vendor}`);
742
+ body.push(` vendor: ${entry.vendor}`);
714
743
  if (entry.channel)
715
- lines.push(` channel: ${entry.channel}`);
716
- lines.push(" models:");
717
- for (const [model, tier] of Object.entries(entry.models ?? {})) {
718
- if (tier === null)
719
- lines.push(` ${model}: null`);
720
- else {
721
- const note = provenance[adapter]?.[model];
722
- const suffix = note ? ` # ${note} — fleet ${today}` : "";
723
- lines.push(` ${model}: ${tier}${suffix}`);
744
+ body.push(` channel: ${entry.channel}`);
745
+ if (entry.windows)
746
+ body.push(...block({ windows: entry.windows }, " "));
747
+ const models = Object.entries(entry.models ?? {});
748
+ if (models.length) {
749
+ body.push(" models:");
750
+ for (const [model, tier] of models) {
751
+ if (tier === null)
752
+ body.push(` ${model}: null`);
753
+ else {
754
+ const note = provenance[adapter]?.[model];
755
+ const suffix = note ? ` # ${note} — fleet ${today}` : "";
756
+ body.push(` ${model}: ${tier}${suffix}`);
757
+ }
724
758
  }
725
759
  }
760
+ else if (entry.models) {
761
+ body.push(" models: {}"); // present-but-empty: explicit {} — a childless header parses as null and the loader rejects it
762
+ }
763
+ // a bare `adapter:` line parses as a null tombstone and would DELETE the adapter's default seeds on merge
764
+ if (body.length)
765
+ lines.push(` ${adapter}:`, ...body);
766
+ else
767
+ lines.push(` ${adapter}: {}`);
726
768
  }
727
769
  }
728
770
  return `${lines.join("\n")}\n`;
@@ -32,6 +32,7 @@ export interface ExploreContext {
32
32
  }
33
33
  export declare const NO_EXPLORE_ENV = "TICKMARKR_NO_EXPLORE";
34
34
  export declare const QUALITY_ENV = "TICKMARKR_QUALITY";
35
+ export declare const ROUTING_ENV_SEAMS: readonly ["TICKMARKR_QUALITY", "TICKMARKR_NO_EXPLORE"];
35
36
  export declare const raiseTier: (tier: Tier) => Tier;
36
37
  export declare class RoutingError extends Error {
37
38
  constructor(msg: string);
@@ -4,6 +4,9 @@ import { disallowedBy } from "./preference.js";
4
4
  import { cellOf, EXPLORE_CAP, explorationBonus, learnedScore, MIN_SAMPLES } from "./profile.js";
5
5
  export const NO_EXPLORE_ENV = "TICKMARKR_NO_EXPLORE";
6
6
  export const QUALITY_ENV = "TICKMARKR_QUALITY";
7
+ // OBS-74: every routing env seam, in one list — the spawn seam (src/run/git.ts) scrubs exactly
8
+ // these from child env so gate/baseline/tip-verify children are hermetic by construction.
9
+ export const ROUTING_ENV_SEAMS = [QUALITY_ENV, NO_EXPLORE_ENV];
7
10
  const exploreCap = (cfg) => cfg.routing.explore?.cap ?? EXPLORE_CAP;
8
11
  const qualityOn = (exploreCtx) => !!exploreCtx?.quality || process.env[QUALITY_ENV] === "1";
9
12
  export const raiseTier = (tier) => tier === "cheap" ? "mid" : tier === "mid" ? "frontier" : "frontier";
@@ -160,15 +163,13 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
160
163
  maybeSlaLint(lints, task, profile, slaMinutes, c);
161
164
  return { assignment: toAssignment(c), ladder: ladderFor(task, entry), lints, provenance: `${degraded}pin ${entry.pin.via}:${entry.pin.model} (config routing.map)` };
162
165
  }
163
- const baseTier = entry?.tier ?? floor ?? "cheap";
166
+ const baseTier = floor ?? "cheap";
164
167
  let minTier = taskFloor && TIER_RANK[taskFloor] > TIER_RANK[baseTier] ? taskFloor : baseTier; // task floor is a hard >= constraint in all paths (D-04)
165
168
  if (quality && advisoryFloor) {
166
169
  const raised = raiseTier(advisoryFloor);
167
170
  if (TIER_RANK[raised] > TIER_RANK[minTier])
168
171
  minTier = raised;
169
172
  }
170
- if (entry?.tier)
171
- lintFloor(entry.tier, "map tier");
172
173
  if (prefActive)
173
174
  for (const p of prefer ?? [])
174
175
  preflightPrefer(p);
@@ -253,9 +254,8 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
253
254
  }
254
255
  const bound = qualityBound(quality, advisoryFloor, taskFloorRaw) ??
255
256
  (taskFloor && TIER_RANK[taskFloor] >= TIER_RANK[baseTier] ? `floor ${taskFloor} (task hint${src})` :
256
- entry?.tier ? `tier ${entry.tier} (config routing.map)` :
257
- floor ? `floor ${floor} (config floors)` :
258
- "tier cheap (default)");
257
+ floor ? `floor ${floor} (config floors)` :
258
+ "tier cheap (default)");
259
259
  // name the key that actually broke the tie: prefer outranks the marginal-cost/tier keys, so if the
260
260
  // winner matched a prefer entry, prefer decided it — not "cheapest sufficient tier" (ROUTE-03, WR-01)
261
261
  const preferVia = preferFromAuto(task.shape, preferCtx)
package/dist/run/git.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { ROUTING_ENV_SEAMS } from "../route/router.js";
2
+ export { ROUTING_ENV_SEAMS };
1
3
  export interface ShResult {
2
4
  code: number;
3
5
  stdout: string;
package/dist/run/git.js CHANGED
@@ -3,14 +3,23 @@ import { existsSync, lstatSync, readlinkSync, rmSync, symlinkSync } from "node:f
3
3
  import { join } from "node:path";
4
4
  import { shq } from "../adapters/types.js";
5
5
  import { tickmarkrDir } from "../graph/graph.js";
6
+ import { ROUTING_ENV_SEAMS } from "../route/router.js";
7
+ export { ROUTING_ENV_SEAMS };
6
8
  // stdin "ignore": same class as HARD-05 / SubprocessDriver — never leave an open pipe a child can block on
7
9
  // (pi -p / codex exec wait for stdin EOF). timedOut distinguishes SIGKILL-timeout from a real nonzero exit.
8
10
  function shell(cmd, cwd, timeoutMs, login) {
11
+ // OBS-74: scrub tickmarkr's own routing env seams from every child — a daemon carrying
12
+ // TICKMARKR_QUALITY leaked it into baseline/gate/tip-verify children, turning a dogfood
13
+ // repo's route() tests red inside the gates. Scrub a copy at this one choke point so
14
+ // children are hermetic by construction; the daemon's own process.env stays unchanged.
15
+ const env = { ...process.env };
16
+ for (const k of ROUTING_ENV_SEAMS)
17
+ delete env[k];
9
18
  return new Promise((resolve) => {
10
19
  // detached: bash gets its own process group so a timeout can kill the whole tree —
11
20
  // SIGKILLing bash alone orphans grandchildren (codex/pi) that hold the stdio pipes
12
21
  // open, so "close" never fires and the promise wedges forever (v1.33.1 init hang).
13
- const p = spawn("bash", [login ? "-lc" : "-c", cmd], { cwd, stdio: ["ignore", "pipe", "pipe"], detached: true });
22
+ const p = spawn("bash", [login ? "-lc" : "-c", cmd], { cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
14
23
  let stdout = "", stderr = "";
15
24
  let timedOut = false, done = false;
16
25
  const finish = (code, err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.51.0",
3
+ "version": "1.52.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",