tickmarkr 1.50.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+)
@@ -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")
@@ -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,9 +6,10 @@ 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, loadConfig, overlayPreferShapes, readOverlayFile, repoOverlayPath, repoOverlayYaml, 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
+ import { resolveRunMode } from "../../run/daemon.js";
12
13
  import { loadRoutingProfile } from "../../run/journal.js";
13
14
  const NON_TTY_MSG = "tickmarkr fleet: interactive fleet editor requires a TTY — use `tickmarkr fleet --print` for non-interactive output";
14
15
  const QUIT = "fleet: quit without writing";
@@ -55,10 +56,21 @@ function provenanceMap(editable) {
55
56
  }
56
57
  return out;
57
58
  }
58
- function proposedOverlayText(initial, editable, repoRoot) {
59
- const merged = fleetRepoOverlayFromDelta(initial, editable, readOverlayFile(repoOverlayPath(repoRoot)));
60
- return repoOverlayYaml(merged, provenanceMap(editable));
59
+ // v1.51 T4: serializeFleetOverlay predates routing.mode — splice the mode line under routing:
60
+ // so a repo-declared mode survives fleet writes and a mode selection lands as routing.mode.
61
+ function withModeLine(yaml, mode) {
62
+ if (!mode)
63
+ return yaml;
64
+ if (/^routing:$/m.test(yaml))
65
+ return yaml.replace(/^routing:$/m, `routing:\n mode: ${mode}`);
66
+ return `routing:\n mode: ${mode}\n${yaml}`;
61
67
  }
68
+ // v1.51 T4: one gloss per routing mode on the fleet mode screen — mirrors the preset compiler.
69
+ const MODE_GLOSS = {
70
+ "partner-led": "every shape frontier · explore off",
71
+ "risk-based": "risk-tiered default floors",
72
+ "staff-led": "implement/refactor one band down · integrity shapes hold frontier",
73
+ };
62
74
  const isAbort = (k) => k.name === "escape" || k.name === "q" || (k.ctrl === true && k.name === "c");
63
75
  const isNext = (k) => k.name === "return" || k.name === "enter";
64
76
  const isToggle = (k) => k.name === "space";
@@ -154,8 +166,14 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
154
166
  const input = io.input ?? process.stdin;
155
167
  const output = io.output ?? process.stdout;
156
168
  const interactive = input.isTTY === true && output.isTTY === true;
157
- if (print)
158
- return formatFleetPrint(cwd, { globalDir });
169
+ if (print) {
170
+ // v1.51 T4: the print surface names the mode and its source layer right under the header —
171
+ // comment-prefixed so the YAML body stays machine-parseable and regex-stable.
172
+ const rm = resolveRunMode(cwd, { globalDir });
173
+ const body = formatFleetPrint(cwd, { globalDir });
174
+ const nl = body.indexOf("\n");
175
+ return `${body.slice(0, nl)}\n# mode: ${rm.mode.mode} (${rm.source})${body.slice(nl)}`;
176
+ }
159
177
  if (!interactive)
160
178
  return { out: NON_TTY_MSG, code: 1 };
161
179
  const fresh = values.fresh ?? false;
@@ -166,15 +184,16 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
166
184
  code: 1,
167
185
  };
168
186
  }
169
- const cfg = loadConfig(cwd, { globalDir });
187
+ const rm = resolveRunMode(cwd, { globalDir });
188
+ const cfg = rm.cfg;
170
189
  const initial = fleetEditableFromConfig(cfg);
171
190
  const editable = structuredClone(initial);
172
191
  const health = cached;
173
192
  const term = openTerm(input, output);
174
193
  try {
175
- // step 1/4 — probe data
194
+ // step 1/5 — probe data
176
195
  const ageLine = formatAge(doctorAgeMs(cwd));
177
- term.frame(listFrame("step 1/4 · probe data", "enter continue · r refresh via doctor · esc/q quit", [`probe data: ${ageLine} (.tickmarkr/doctor.json)`], 0));
196
+ term.frame(listFrame("step 1/5 · probe data", "enter continue · r refresh via doctor · esc/q quit", [`probe data: ${ageLine} (.tickmarkr/doctor.json)`], 0));
178
197
  for (;;) {
179
198
  const k = await term.key();
180
199
  if (isAbort(k))
@@ -185,7 +204,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
185
204
  if (isNext(k))
186
205
  break;
187
206
  }
188
- // step 2/4 — agent CLIs (space toggles adapter deny)
207
+ // step 2/5 — agent CLIs (space toggles adapter deny)
189
208
  const installed = adapters.filter((a) => health[a.id]?.installed);
190
209
  const adapterRows = () => installed.map((a) => {
191
210
  const h = health[a.id];
@@ -194,7 +213,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
194
213
  });
195
214
  const listLegend = "↑↓/jk move · space toggle · enter next · esc/q quit";
196
215
  let aCursor = 0;
197
- term.frame(listFrame("step 2/4 · agent CLIs", listLegend, adapterRows(), aCursor));
216
+ term.frame(listFrame("step 2/5 · agent CLIs", listLegend, adapterRows(), aCursor));
198
217
  for (;;) {
199
218
  const k = await term.key();
200
219
  if (isAbort(k))
@@ -218,9 +237,9 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
218
237
  changed = true;
219
238
  }
220
239
  if (changed)
221
- term.frame(listFrame("step 2/4 · agent CLIs", listLegend, adapterRows(), aCursor));
240
+ term.frame(listFrame("step 2/5 · agent CLIs", listLegend, adapterRows(), aCursor));
222
241
  }
223
- // step 3/4 — models per enabled adapter (space toggles deny, t assigns tier from the row)
242
+ // step 3/5 — models per enabled adapter (space toggles deny, t assigns tier from the row)
224
243
  const enabledAdapters = installed.filter((a) => !isDeniedAdapter(a.id, editable));
225
244
  for (const a of enabledAdapters) {
226
245
  const unclassifiedAll = fleetUnclassifiedModels(cfg, health, adapters).filter((u) => u.adapter === a.id);
@@ -238,7 +257,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
238
257
  const denied = isDeniedModel(a.id, r.model, editable);
239
258
  return `${denied ? toggleInactive() : toggleActive()} ${r.model} ${tier?.tier ?? "???"} ${denied ? "denied" : "allowed"}`;
240
259
  });
241
- const title = `step 3/4 · models · ${a.id}`;
260
+ const title = `step 3/5 · models · ${a.id}`;
242
261
  const modelsLegend = "↑↓/jk move · space toggle · t tier · enter next · esc/q quit";
243
262
  let mCursor = 0;
244
263
  term.frame(listFrame(title, modelsLegend, renderRows(rowsData()), mCursor));
@@ -283,7 +302,44 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
283
302
  term.frame(listFrame(title, modelsLegend, renderRows(rowsData()), mCursor));
284
303
  }
285
304
  }
286
- // step 4/4shape routing (typed entry for pin/tier/prefer from the highlighted row)
305
+ // step 4/5 — routing mode (selection is in-memory; the write happens only through the diff confirm).
306
+ // Candidate floor tables come from the ONE preset compiler via resolveRunMode — no mode math here.
307
+ const modeCfgs = Object.fromEntries(ROUTING_MODES.map((m) => [m, m === rm.mode.mode ? rm : resolveRunMode(cwd, { flag: m, globalDir })]));
308
+ const modeRows = () => ROUTING_MODES.map((m) => `${m === rm.mode.mode ? toggleActive() : " "} ${m.padEnd(11)} ${MODE_GLOSS[m]}`);
309
+ // preview: the highlighted mode's resolved floor table diffed against the current mode
310
+ const floorPreview = (cand) => {
311
+ if (cand === rm.mode.mode)
312
+ return [];
313
+ const cur = cfg.routing.floors;
314
+ const next = modeCfgs[cand].cfg.routing.floors;
315
+ const changed = SHAPES.filter((s) => cur[s] !== next[s]);
316
+ return [
317
+ ` floors vs ${rm.mode.mode}:`,
318
+ ...(changed.length ? changed.map((s) => ` ${s}: ${cur[s]} → ${next[s]}`) : [" (no floor changes)"]),
319
+ ];
320
+ };
321
+ const modeTitle = "step 4/5 · routing mode";
322
+ const modeLegend = "↑↓/jk move · enter select · esc/q quit";
323
+ let modeCursor = ROUTING_MODES.indexOf(rm.mode.mode);
324
+ let selectedMode = rm.mode.mode;
325
+ const modeFrame = () => [...listFrame(modeTitle, modeLegend, modeRows(), modeCursor), ...floorPreview(ROUTING_MODES[modeCursor])];
326
+ term.frame(modeFrame());
327
+ for (;;) {
328
+ const k = await term.key();
329
+ if (isAbort(k))
330
+ return QUIT;
331
+ if (isNext(k)) {
332
+ selectedMode = ROUTING_MODES[modeCursor];
333
+ break;
334
+ }
335
+ const moved = moveCursor(k, modeCursor, ROUTING_MODES.length);
336
+ if (moved !== modeCursor) {
337
+ modeCursor = moved;
338
+ term.frame(modeFrame());
339
+ }
340
+ }
341
+ // step 5/5 — shape routing (typed entry for pin/tier/prefer from the highlighted row);
342
+ // previews route under the SELECTED mode's resolved floors, never a floor edit of its own
287
343
  const channels = discoverChannels(cfg, adapters, health);
288
344
  const profile = loadRoutingProfile(cwd, cfg, { preview: true });
289
345
  const autoPrefer = readAutoPrefer(cwd);
@@ -291,7 +347,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
291
347
  const shapeRows = () => SHAPES.map((shape) => {
292
348
  let now;
293
349
  try {
294
- const r = route(previewTask(shape), { ...cfg, routing: { ...cfg.routing, map: editable.map, floors: editable.floors } }, channels, profile);
350
+ const r = route(previewTask(shape), { ...cfg, routing: { ...cfg.routing, map: editable.map, floors: modeCfgs[selectedMode].cfg.routing.floors } }, channels, profile);
295
351
  now = `${r.assignment.adapter}:${r.assignment.model} (${r.assignment.channel}, ${r.assignment.tier})`;
296
352
  }
297
353
  catch (e) {
@@ -300,8 +356,9 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
300
356
  const auto = autoPrefer?.[shape] && !overlayShapes.has(shape) ? " (auto-prefer active)" : "";
301
357
  return `${shape} → ${now}${auto}`;
302
358
  });
303
- const shapesTitle = "step 4/4 · shape routing";
304
- const shapesLegend = "↑↓/jk move · a auto · p pin · t tier · f prefer · enter next · esc/q quit";
359
+ const shapesTitle = "step 5/5 · shape routing";
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";
305
362
  let sCursor = 0;
306
363
  term.frame(listFrame(shapesTitle, shapesLegend, shapeRows(), sCursor));
307
364
  for (;;) {
@@ -332,15 +389,6 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
332
389
  editable.map[shape] = { pin: { via: pin.slice(0, i), model: pin.slice(i + 1) } };
333
390
  changed = true;
334
391
  }
335
- else if (k.name === "t") {
336
- const tier = (await term.askTyped(`tier (${TIERS.join("|")})> `));
337
- if (!TIERS.includes(tier))
338
- return { out: `fleet: invalid tier ${tier}`, code: 1 };
339
- const next = { ...entry, tier };
340
- delete next.pin;
341
- editable.map[shape] = next;
342
- changed = true;
343
- }
344
392
  else if (k.name === "f") {
345
393
  const pref = await term.askTyped("prefer (comma-separated adapters or adapter:model)> ");
346
394
  editable.map[shape] = { ...entry, prefer: pref.split(",").map((s) => s.trim()).filter(Boolean) };
@@ -349,10 +397,17 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
349
397
  if (changed)
350
398
  term.frame(listFrame(shapesTitle, shapesLegend, shapeRows(), sCursor));
351
399
  }
352
- // review — unified diff + typed confirm
400
+ // review — unified diff + typed confirm (the ONLY write path; the mode screen funnels through here)
353
401
  const before = currentRepoOverlayText(cwd);
354
- const after = proposedOverlayText(initial, editable, cwd);
355
- if (fleetEditableEquals(initial, editable) || before === after) {
402
+ const existing = readOverlayFile(repoOverlayPath(cwd));
403
+ const modeChanged = selectedMode !== rm.mode.mode;
404
+ // preserve a repo-declared mode line verbatim; write a new one only when the selection changed it
405
+ const writeMode = modeChanged ? selectedMode : existing.routing?.mode;
406
+ const merged = fleetEditableEquals(initial, editable)
407
+ ? structuredClone(existing)
408
+ : fleetRepoOverlayFromDelta(initial, editable, existing);
409
+ const after = withModeLine(repoOverlayYaml(merged, provenanceMap(editable)), writeMode);
410
+ if ((!modeChanged && fleetEditableEquals(initial, editable)) || before === after) {
356
411
  return "fleet: no overlay changes (empty diff)";
357
412
  }
358
413
  const diff = unifiedYamlDiff(before, after, repoOverlayPath(cwd));
@@ -360,11 +415,16 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
360
415
  const confirm = await term.askTyped("write overlay? [y/N] ");
361
416
  if (!/^y(?:es)?$/i.test(confirm))
362
417
  return "fleet: discarded overlay changes";
363
- const merged = fleetRepoOverlayFromDelta(initial, editable, readOverlayFile(repoOverlayPath(cwd)));
364
- const body = repoOverlayYaml(merged, provenanceMap(editable));
365
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
+ }
366
426
  mkdirSync(dirname(path), { recursive: true });
367
- writeFileSync(path, body);
427
+ writeFileSync(path, after);
368
428
  return `fleet: wrote ${path}`;
369
429
  }
370
430
  finally {
@@ -1,2 +1,2 @@
1
1
  import type { WorkerAdapter } from "../../adapters/types.js";
2
- export declare function plan(_argv: string[], cwd?: string, adapters?: WorkerAdapter[]): Promise<string>;
2
+ export declare function plan(argv: string[], cwd?: string, adapters?: WorkerAdapter[]): Promise<string>;
@@ -1,11 +1,14 @@
1
1
  import { allAdapters, discoverChannels, doctorAgeMs, modelAuthExclusions, probeAll, readDoctor, servableExclusions, servabilityLine } from "../../adapters/registry.js";
2
2
  import { formatModelAuthLine, contextWindowLints, modelLints, ttyVisual } from "../../adapters/model-lints.js";
3
3
  import { GLYPHS, dim, rule, title, warn } from "../../brand.js";
4
+ import { parseArgs } from "node:util";
4
5
  import { collateralLints } from "../../compile/collateral.js";
5
- import { loadConfig } from "../../config/config.js";
6
+ import { DEFAULT_CONFIG, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
6
7
  import { loadGraph } from "../../graph/graph.js";
8
+ import { resolveRunMode } from "../../run/daemon.js";
7
9
  import { excludedChannels, exclusionLine } from "../../route/preference.js";
8
- import { route, RoutingError } from "../../route/router.js";
10
+ import { staffLedEvidence } from "../../route/profile.js";
11
+ import { QUALITY_ENV, route, RoutingError } from "../../route/router.js";
9
12
  import { modelId } from "../../gates/review.js";
10
13
  import { loadRoutingProfile } from "../../run/journal.js";
11
14
  // T4 (v1.50): TTY-only brand pass — the title helper frames the routing table, lint/unroutable
@@ -31,12 +34,19 @@ const fleetCanCrossVendorReview = (channels) => {
31
34
  return true;
32
35
  return false;
33
36
  };
34
- export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters()) {
37
+ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters()) {
35
38
  // ponytail: hardcoded 24h TTL — promote to config when an operator asks. mtime is the signal because
36
39
  // doctor.json has no probe timestamp and a schema field would break the existing-files compat invariant.
37
40
  const DOCTOR_STALE_MS = 24 * 60 * 60 * 1000;
38
- const cfg = loadConfig(cwd);
41
+ // v1.51 T2: plan previews any mode without a config edit — same source precedence as run
42
+ // (flag > spec front-matter > repo > global > default), same preset compiler.
43
+ const { values } = parseArgs({ args: argv, options: { mode: { type: "string" } }, allowPositionals: true });
44
+ if (values.mode !== undefined && !ROUTING_MODES.includes(values.mode)) {
45
+ throw new Error(`--mode must be one of ${ROUTING_MODES.join(" | ")} (got ${values.mode})`);
46
+ }
47
+ delete process.env[QUALITY_ENV];
39
48
  const g = loadGraph(cwd);
49
+ const { cfg, mode, source } = resolveRunMode(cwd, { flag: values.mode, spec: g.mode });
40
50
  // readDoctor cache path: staleness line only fires here (probeAll fallback is fresh by construction).
41
51
  const cached = readDoctor(cwd);
42
52
  const health = cached ?? (await probeAll(adapters));
@@ -46,7 +56,17 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
46
56
  // output byte-identical to today.
47
57
  const profile = loadRoutingProfile(cwd, cfg, { preview: true });
48
58
  let deviations = 0;
49
- const lines = [`tickmarkr plandry run (${channels.length} channels available)`, ""];
59
+ // v1.51 T4: the mode is never invisible the header names the resolved mode, its winning
60
+ // source, and the explore posture; each task row carries a floor-derivation line below.
61
+ const lines = [
62
+ `tickmarkr plan — dry run (${channels.length} channels available)`,
63
+ `mode: ${mode.mode} (${source}) · explore ${cfg.routing.explore?.mode ?? "on"}`,
64
+ "",
65
+ ];
66
+ const derivation = (shape) => {
67
+ const floor = cfg.routing.floors[shape];
68
+ return floor ? ` floor ${floor} ← ${mode.provenance[shape] ?? "config floors"}` : null;
69
+ };
50
70
  const excluded = excludedChannels(cfg, adapters, health);
51
71
  if (excluded.length)
52
72
  lines.push(exclusionLine(excluded), "");
@@ -78,7 +98,24 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
78
98
  return ` — ${m.key} is unauthed (${m.reason}, probed ${m.probedAt.split("T")[0]})`;
79
99
  return "";
80
100
  };
81
- const lints = [];
101
+ // v1.51 T1: mode-resolution lints (shadowed deltas, below-integrity operator floors) surface here every run.
102
+ const lints = [...mode.lints];
103
+ // v1.51 T5: staff-led economics guard — when the MODE (never an explicit operator line) lowered a
104
+ // shape's floor and warm evidence shows the cheap band materially under the mid incumbent, say so.
105
+ // Advisory only: no floor is raised here or anywhere on prediction — raises stay evidence-triggered
106
+ // (task-hint floors, the in-run retry ladder), each journaled with provenance.
107
+ if (mode.mode === "staff-led") {
108
+ const fmtScore = (s) => `${s < 0 ? "" : "+"}${s.toFixed(3)}`;
109
+ for (const [shape, floor] of Object.entries(cfg.routing.floors)) {
110
+ const dflt = DEFAULT_CONFIG.routing.floors[shape];
111
+ if (mode.provenance[shape] !== "mode staff-led" || !dflt || TIER_RANK[floor] >= TIER_RANK[dflt])
112
+ continue;
113
+ const ev = staffLedEvidence(profile, shape, channels);
114
+ if (ev) {
115
+ lints.push(`staff-led may cost more than risk-based on ${shape} (cheap best ${fmtScore(ev.cheapBest)} vs mid ${fmtScore(ev.midBest)}, n=${ev.n}) — advisory only, floor stays ${floor}`);
116
+ }
117
+ }
118
+ }
82
119
  for (const [role, sel] of [["judge", cfg.judge], ["consult", cfg.consult]]) {
83
120
  if (!health[sel.adapter]?.installed)
84
121
  lints.push(`${role}: ${sel.adapter}:${sel.model} not installed — that gate/consult will fail closed`);
@@ -100,6 +137,9 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
100
137
  const est = r.assignment.channel === "sub" ? 0 : cfg.pricing[r.assignment.tier];
101
138
  cost += est;
102
139
  lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} c${String(t.complexity).padEnd(3)}→ ${r.assignment.adapter}:${r.assignment.model} [${r.assignment.channel}/${r.assignment.tier}]${t.timeoutMinutes !== undefined ? ` (timeout ${t.timeoutMinutes}m)` : ""}${t.humanGate ? " (human gate)" : ""}${est ? ` ~$${est.toFixed(2)}` : ""} — ${r.provenance}`);
140
+ const d = derivation(t.shape);
141
+ if (d)
142
+ lines.push(d);
103
143
  if (r.deviation) {
104
144
  deviations++;
105
145
  const d = r.deviation;
@@ -111,6 +151,9 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
111
151
  throw e;
112
152
  const msg = `${e.message}${exclusionReason(e.message)}`;
113
153
  lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} !! ${msg}`);
154
+ const d = derivation(t.shape);
155
+ if (d)
156
+ lines.push(d);
114
157
  lints.push(`${t.id}: unroutable — ${msg}`);
115
158
  }
116
159
  }
@@ -1,10 +1,10 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import { allAdapters, discoverChannels, probeAll, readDoctor } from "../../adapters/registry.js";
3
- import { loadConfig } from "../../config/config.js";
3
+ import { ROUTING_MODES } from "../../config/config.js";
4
4
  import { pickDriver } from "../../drivers/index.js";
5
5
  import { loadGraph } from "../../graph/graph.js";
6
- import { formatSummary, runDaemon } from "../../run/daemon.js";
7
- import { route, QUALITY_ENV, NO_EXPLORE_ENV } from "../../route/router.js";
6
+ import { formatSummary, resolveRunMode, runDaemon } from "../../run/daemon.js";
7
+ import { route, NO_EXPLORE_ENV, QUALITY_ENV } from "../../route/router.js";
8
8
  import { formatJournalNarration, loadRoutingProfile } from "../../run/journal.js";
9
9
  import { ttyVisual } from "../../adapters/model-lints.js";
10
10
  import { statusRow } from "../../brand.js";
@@ -24,6 +24,11 @@ export const narrationLine = (event) => {
24
24
  };
25
25
  const summaryGreen = (s) => s.failed.length === 0 && s.human.length === 0 && s.blocked.length === 0 && s.pending.length === 0
26
26
  && s.tipVerify !== "failed";
27
+ // v1.51 T2: --quality is a pure compatibility alias for `--mode partner-led` (this run only). It
28
+ // carries no one-band floor raise of its own: the flag never sets ExploreContext.quality or the
29
+ // TICKMARKR_QUALITY env, so no downstream code raises a floor on its behalf (proven in mode-sources).
30
+ const QUALITY_ALIAS_NOTICE = "tickmarkr: --quality is a compatibility alias for --mode partner-led (this run only) — "
31
+ + "the v1.47 one-band floor raise is retired (deprecated); use --mode partner-led";
27
32
  export async function run(argv, cwd = process.cwd()) {
28
33
  const { values } = parseArgs({
29
34
  args: argv,
@@ -32,6 +37,7 @@ export async function run(argv, cwd = process.cwd()) {
32
37
  driver: { type: "string" },
33
38
  "route-strict": { type: "boolean" },
34
39
  "no-explore": { type: "boolean" },
40
+ mode: { type: "string" },
35
41
  quality: { type: "boolean" },
36
42
  },
37
43
  });
@@ -40,14 +46,28 @@ export async function run(argv, cwd = process.cwd()) {
40
46
  if (!Number.isInteger(n) || n <= 0)
41
47
  throw new Error(`--concurrency must be a positive integer (got ${values.concurrency})`);
42
48
  }
43
- const cfg = loadConfig(cwd);
44
- const quality = !!values.quality;
45
- const noExplore = !!values["no-explore"] || quality;
46
- const exploreCtx = noExplore || quality ? { noExplore, quality } : undefined;
49
+ if (values.quality && values.mode !== undefined) {
50
+ throw new Error("--quality is a compatibility alias for --mode partner-led and cannot be combined with an explicit --mode — pass one or the other");
51
+ }
52
+ if (values.mode !== undefined && !ROUTING_MODES.includes(values.mode)) {
53
+ throw new Error(`--mode must be one of ${ROUTING_MODES.join(" | ")} (got ${values.mode})`);
54
+ }
55
+ if (values.quality)
56
+ console.warn(QUALITY_ALIAS_NOTICE);
57
+ const flagMode = values.mode ?? (values.quality ? "partner-led" : undefined);
58
+ delete process.env[QUALITY_ENV];
59
+ const graph = loadGraph(cwd);
60
+ const { cfg, conflict } = resolveRunMode(cwd, { flag: flagMode, spec: graph.mode });
61
+ if (conflict) {
62
+ // Loud, never silent: live intent (the flag) may override compiled intent (the spec) — strict refuses.
63
+ if (values["route-strict"])
64
+ throw new Error(`--route-strict: refusing to dispatch — ${conflict}`);
65
+ console.warn(`tickmarkr: !! ${conflict}`);
66
+ }
67
+ const noExplore = !!values["no-explore"];
68
+ const exploreCtx = noExplore ? { noExplore } : undefined;
47
69
  if (noExplore)
48
70
  process.env[NO_EXPLORE_ENV] = "1";
49
- if (quality)
50
- process.env[QUALITY_ENV] = "1";
51
71
  try {
52
72
  if (values["route-strict"]) {
53
73
  const adapters = allAdapters();
@@ -55,13 +75,14 @@ export async function run(argv, cwd = process.cwd()) {
55
75
  const channels = discoverChannels(cfg, adapters, health);
56
76
  // no preview: the strict pre-flight routes through exactly what the daemon will use (honors the switch)
57
77
  const profile = loadRoutingProfile(cwd, cfg);
58
- const lints = loadGraph(cwd).tasks.flatMap((t) => route(t, cfg, channels, profile, undefined, undefined, exploreCtx).lints);
78
+ const lints = graph.tasks.flatMap((t) => route(t, cfg, channels, profile, undefined, undefined, exploreCtx).lints);
59
79
  if (lints.length)
60
80
  throw new Error(`--route-strict: routing lints present, refusing to dispatch:\n${lints.join("\n")}`);
61
81
  }
62
82
  const s = await runDaemon(cwd, {
63
83
  concurrency: values.concurrency ? Number(values.concurrency) : undefined,
64
84
  driver: pickDriver(cfg, values.driver),
85
+ mode: flagMode,
65
86
  narrate: (event) => console.log(narrationLine(event)),
66
87
  });
67
88
  const out = `run ${s.runId} finished — ${formatSummary(s)} (merge to main is a human decision)`;
@@ -70,7 +91,5 @@ export async function run(argv, cwd = process.cwd()) {
70
91
  finally {
71
92
  if (noExplore)
72
93
  delete process.env[NO_EXPLORE_ENV];
73
- if (quality)
74
- delete process.env[QUALITY_ENV];
75
94
  }
76
95
  }