tickmarkr 1.50.0 → 1.51.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.
@@ -17,6 +17,7 @@ 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[];
20
21
  export declare function modelLints(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], opts?: {
21
22
  tty?: boolean;
22
23
  stateDir?: string;
@@ -95,6 +95,14 @@ 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
+ }
98
106
  // Diffs detected models (doctor.json) against configured tiers, both directions, per adapter id in cfg.tiers.
99
107
  // No ` ! ` prefix here — the consumer (doctor rows / plan lints) owns that. Pre-v1.5 doctor.json (models:[], no
100
108
  // modelsDetectedAt) is the compat baseline: `?.`/`?? []` everywhere, no zod (would reject old files).
@@ -137,6 +145,7 @@ export function modelLints(cfg, health, adapters, opts) {
137
145
  }
138
146
  }
139
147
  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
140
149
  return lints;
141
150
  }
142
151
  // T2/T6: one lint per exclusion, naming the probe reason and date. TTY truncates reasons to 60 chars and
@@ -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, 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,7 +356,7 @@ 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";
359
+ const shapesTitle = "step 5/5 · shape routing";
304
360
  const shapesLegend = "↑↓/jk move · a auto · p pin · t tier · f prefer · enter next · esc/q quit";
305
361
  let sCursor = 0;
306
362
  term.frame(listFrame(shapesTitle, shapesLegend, shapeRows(), sCursor));
@@ -349,10 +405,17 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
349
405
  if (changed)
350
406
  term.frame(listFrame(shapesTitle, shapesLegend, shapeRows(), sCursor));
351
407
  }
352
- // review — unified diff + typed confirm
408
+ // review — unified diff + typed confirm (the ONLY write path; the mode screen funnels through here)
353
409
  const before = currentRepoOverlayText(cwd);
354
- const after = proposedOverlayText(initial, editable, cwd);
355
- if (fleetEditableEquals(initial, editable) || before === after) {
410
+ const existing = readOverlayFile(repoOverlayPath(cwd));
411
+ const modeChanged = selectedMode !== rm.mode.mode;
412
+ // preserve a repo-declared mode line verbatim; write a new one only when the selection changed it
413
+ const writeMode = modeChanged ? selectedMode : existing.routing?.mode;
414
+ const merged = fleetEditableEquals(initial, editable)
415
+ ? structuredClone(existing)
416
+ : fleetRepoOverlayFromDelta(initial, editable, existing);
417
+ const after = withModeLine(repoOverlayYaml(merged, provenanceMap(editable)), writeMode);
418
+ if ((!modeChanged && fleetEditableEquals(initial, editable)) || before === after) {
356
419
  return "fleet: no overlay changes (empty diff)";
357
420
  }
358
421
  const diff = unifiedYamlDiff(before, after, repoOverlayPath(cwd));
@@ -360,11 +423,9 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
360
423
  const confirm = await term.askTyped("write overlay? [y/N] ");
361
424
  if (!/^y(?:es)?$/i.test(confirm))
362
425
  return "fleet: discarded overlay changes";
363
- const merged = fleetRepoOverlayFromDelta(initial, editable, readOverlayFile(repoOverlayPath(cwd)));
364
- const body = repoOverlayYaml(merged, provenanceMap(editable));
365
426
  const path = repoOverlayPath(cwd);
366
427
  mkdirSync(dirname(path), { recursive: true });
367
- writeFileSync(path, body);
428
+ writeFileSync(path, after);
368
429
  return `fleet: wrote ${path}`;
369
430
  }
370
431
  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
  }
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { GATE_NAMES, ORACLES, SHAPES, TIERS, validateGraph } from "../graph/schema.js";
2
+ import { GATE_NAMES, GRAPH_ROUTING_MODES, ORACLES, SHAPES, TIERS, validateGraph } from "../graph/schema.js";
3
3
  import { CompileError, inferShape, sha256 } from "./common.js";
4
4
  export const LEGACY_PREFIX = ["dro", "vr"].join("");
5
5
  export const TICKMARKR_NATIVE_MARKER = /^<!--\s*tickmarkr:spec(?:\s+v1)?\s*-->\s*$/m;
@@ -20,6 +20,7 @@ export function compileNative(file) {
20
20
  const content = readFileSync(file, "utf8");
21
21
  const drafts = [];
22
22
  let plainCount = 0; // v1.19: plain-string acceptance items compiled as judge oracles (compat) — warn once
23
+ let specMode;
23
24
  for (const line of content.split("\n")) {
24
25
  const heading = line.match(HEAD_RE);
25
26
  if (heading) {
@@ -27,8 +28,18 @@ export function compileNative(file) {
27
28
  continue;
28
29
  }
29
30
  const draft = drafts.at(-1);
30
- if (!draft)
31
+ if (!draft) {
32
+ // v1.51 T2: spec front-matter — a top-level `mode: <name>` line before the first task heading
33
+ // declares the engagement's routing mode (loses only to an explicit run flag).
34
+ const fm = line.match(/^mode:\s*(\S+)\s*$/);
35
+ if (fm) {
36
+ if (!GRAPH_ROUTING_MODES.includes(fm[1])) {
37
+ throw new CompileError(`spec front-matter mode must be one of ${GRAPH_ROUTING_MODES.join(", ")} (got ${JSON.stringify(fm[1])})`);
38
+ }
39
+ specMode = fm[1];
40
+ }
31
41
  continue;
42
+ }
32
43
  const field = line.match(FIELD_RE);
33
44
  if (field) {
34
45
  const name = field[1].toLowerCase();
@@ -151,6 +162,7 @@ export function compileNative(file) {
151
162
  });
152
163
  const result = validateGraph({
153
164
  version: 1,
165
+ ...(specMode ? { mode: specMode } : {}),
154
166
  spec: { source: "native", paths: [file], hash: sha256(content) },
155
167
  tasks,
156
168
  });
@@ -184,6 +196,10 @@ Each task is a "## Tn: Title" heading with "- field: value" bullets.
184
196
  acceptance is required on every task (a nested list of observable outcomes).
185
197
 
186
198
  <!--
199
+ Spec front-matter (top-level, before the first task heading):
200
+ mode: partner-led | risk-based | staff-led — this engagement's routing mode
201
+ (loses only to an explicit \`run --mode\` flag)
202
+
187
203
  Fields available per task:
188
204
  goal: outcome the task must achieve (defaults to the title if omitted)
189
205
  shape: plan | spec | implement | tests | docs | migration | ui | refactor | chore
@@ -7,6 +7,14 @@ declare const TierEnum: z.ZodEnum<{
7
7
  export type Tier = z.infer<typeof TierEnum>;
8
8
  export declare const TIER_RANK: Record<Tier, number>;
9
9
  export declare const DEFAULT_DIFF_CAP = 60000;
10
+ export declare const ROUTING_MODES: readonly ["partner-led", "risk-based", "staff-led"];
11
+ declare const ModeEnum: z.ZodEnum<{
12
+ "partner-led": "partner-led";
13
+ "risk-based": "risk-based";
14
+ "staff-led": "staff-led";
15
+ }>;
16
+ export type RoutingMode = z.infer<typeof ModeEnum>;
17
+ export declare const INTEGRITY_FLOOR_SHAPES: readonly ["plan", "spec", "migration", "ui"];
10
18
  export declare const MapEntrySchema: z.ZodObject<{
11
19
  pin: z.ZodOptional<z.ZodObject<{
12
20
  via: z.ZodString;
@@ -60,6 +68,11 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
60
68
  contextWarnTokens: z.ZodNumber;
61
69
  setup: z.ZodOptional<z.ZodString>;
62
70
  routing: z.ZodObject<{
71
+ mode: z.ZodOptional<z.ZodEnum<{
72
+ "partner-led": "partner-led";
73
+ "risk-based": "risk-based";
74
+ "staff-led": "staff-led";
75
+ }>>;
63
76
  map: z.ZodRecord<z.ZodString, z.ZodObject<{
64
77
  pin: z.ZodOptional<z.ZodObject<{
65
78
  via: z.ZodString;
@@ -195,6 +208,21 @@ export declare function globalConfigDir(): string;
195
208
  export declare function overlayPreferShapes(repoRoot: string, opts?: {
196
209
  globalDir?: string;
197
210
  }): ReadonlySet<string>;
211
+ export type ModeResolution = {
212
+ mode: RoutingMode;
213
+ /** floor shape → "mode <name>" | "config floors" */
214
+ provenance: Record<string, string>;
215
+ /** plan lints: shadowed mode deltas + operator floors below the integrity minimum (standing) */
216
+ lints: string[];
217
+ };
218
+ /** loadConfig plus the mode-resolution record (floor provenance + plan lints). The resolved floors are
219
+ * already applied to cfg.routing.floors — route() consumes floors only and never sees the mode. */
220
+ export declare function loadConfigWithMode(repoRoot: string, opts?: {
221
+ globalDir?: string;
222
+ }): {
223
+ cfg: TickmarkrConfig;
224
+ mode: ModeResolution;
225
+ };
198
226
  export declare function loadConfig(repoRoot: string, opts?: {
199
227
  globalDir?: string;
200
228
  }): TickmarkrConfig;
@@ -10,6 +10,16 @@ const TierEnum = z.enum(TIERS, {
10
10
  });
11
11
  export const TIER_RANK = { cheap: 0, mid: 1, frontier: 2 };
12
12
  export const DEFAULT_DIFF_CAP = 60_000;
13
+ // v1.51 T1: routing.mode — a preset COMPILED INTO FLOORS at config load. The router never sees the
14
+ // mode; it receives resolved floors only (the structural defense against the quality-silently-loses
15
+ // class — no fourth runtime authority, no new precedence key).
16
+ export const ROUTING_MODES = ["partner-led", "risk-based", "staff-led"];
17
+ const ModeEnum = z.enum(ROUTING_MODES, {
18
+ error: (iss) => `Invalid option: expected one of "partner-led"|"risk-based"|"staff-led" (got ${JSON.stringify(iss.input)})`,
19
+ });
20
+ // Integrity set (weak-oracle shapes, OBS-68..70 class): no mode may resolve these below frontier.
21
+ // Only an explicit operator floor line can — and it draws a standing plan lint every load.
22
+ export const INTEGRITY_FLOOR_SHAPES = ["plan", "spec", "migration", "ui"];
13
23
  export const MapEntrySchema = z.object({
14
24
  pin: z.object({ via: z.string(), model: z.string() }).optional(),
15
25
  tier: TierEnum.optional(),
@@ -80,6 +90,8 @@ export const TickmarkrConfigSchema = z.object({
80
90
  contextWarnTokens: z.number().int().positive(),
81
91
  setup: z.string().optional(),
82
92
  routing: z.object({
93
+ // v1.51 T1: preset expanded into floors by loadConfig — absent ⇒ risk-based ⇒ byte-identical routing.
94
+ mode: ModeEnum.optional(),
83
95
  map: z.record(z.string(), MapEntrySchema),
84
96
  floors: z.record(z.string(), TierEnum),
85
97
  learned: z.enum(["on", "off"]), // v1.6 ROUTE-09 kill switch; a typo (offf) fails loud via safeParse
@@ -155,12 +167,16 @@ export const DEFAULT_CONFIG = {
155
167
  taskTimeoutMinutes: 30,
156
168
  contextWarnTokens: 170_000, // v1.23 T2: overseer ctx-watch.sh proven threshold
157
169
  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).
158
175
  map: {
159
176
  plan: { pin: { via: "claude-code", model: "fable" } },
160
177
  spec: { pin: { via: "claude-code", model: "fable" } },
161
- implement: { tier: "mid", prefer: ["cursor-agent", "codex"] },
162
- tests: { tier: "cheap", prefer: ["opencode"] },
163
- docs: { tier: "cheap" },
178
+ implement: { prefer: ["cursor-agent", "codex"] },
179
+ tests: { prefer: ["opencode"] },
164
180
  },
165
181
  floors: {
166
182
  plan: "frontier", spec: "frontier", migration: "frontier",
@@ -304,14 +320,88 @@ export function overlayPreferShapes(repoRoot, opts = {}) {
304
320
  }
305
321
  return shapes;
306
322
  }
307
- export function loadConfig(repoRoot, opts = {}) {
323
+ const lowerTier = (t) => (t === "frontier" ? "mid" : "cheap");
324
+ const maxTier = (a, b) => (TIER_RANK[a] >= TIER_RANK[b] ? a : b);
325
+ const integrityMin = (shape) => INTEGRITY_FLOOR_SHAPES.includes(shape) ? "frontier" : "cheap";
326
+ // partner-led = frontier everywhere; staff-led = one band down, integrity-clamped (net effect on the
327
+ // defaults: implement/refactor → cheap, ui → frontier); risk-based = identity, so an absent mode key
328
+ // resolves byte-identically to pre-v1.51 routing.
329
+ function presetFloor(mode, shape, dflt) {
330
+ if (mode === "partner-led")
331
+ return "frontier";
332
+ if (mode === "staff-led")
333
+ return maxTier(lowerTier(dflt), integrityMin(shape));
334
+ return dflt;
335
+ }
336
+ /** Which floor shapes the operator wrote in an overlay layer (explicit beats mode; null tombstones stay removed). */
337
+ function overlayFloorEdits(layers) {
338
+ const explicit = new Set();
339
+ const tombstoned = new Set();
340
+ for (const layer of layers) {
341
+ const floors = layer?.routing?.floors;
342
+ if (!floors || typeof floors !== "object" || Array.isArray(floors))
343
+ continue;
344
+ for (const [shape, v] of Object.entries(floors)) {
345
+ if (v === null) {
346
+ explicit.delete(shape);
347
+ tombstoned.add(shape);
348
+ }
349
+ else {
350
+ tombstoned.delete(shape);
351
+ explicit.add(shape);
352
+ }
353
+ }
354
+ }
355
+ return { explicit, tombstoned };
356
+ }
357
+ // Mutates cfg.routing.floors (and explore under partner-led) to the mode-resolved values.
358
+ function resolveRoutingMode(cfg, layers) {
359
+ const mode = cfg.routing.mode ?? "risk-based";
360
+ const { explicit, tombstoned } = overlayFloorEdits(layers);
361
+ const provenance = {};
362
+ const lints = [];
363
+ for (const shape of SHAPES) {
364
+ if (tombstoned.has(shape))
365
+ continue; // operator removed the floor — a mode never resurrects it
366
+ const dflt = DEFAULT_CONFIG.routing.floors[shape];
367
+ const moded = presetFloor(mode, shape, dflt);
368
+ if (explicit.has(shape)) {
369
+ const val = cfg.routing.floors[shape];
370
+ provenance[shape] = "config floors";
371
+ if (moded !== val && moded !== dflt) {
372
+ lints.push(`floors.${shape}: ${val} (config floors) overrides mode ${mode} — shadowed delta: ${shape} ${dflt}→${moded}`);
373
+ }
374
+ if (TIER_RANK[val] < TIER_RANK[integrityMin(shape)]) {
375
+ lints.push(`floors.${shape}: ${val} is below integrity minimum frontier — integrity class ${INTEGRITY_FLOOR_SHAPES.join("/")} holds regardless of mode`);
376
+ }
377
+ continue;
378
+ }
379
+ cfg.routing.floors[shape] = moded;
380
+ provenance[shape] = `mode ${mode}`;
381
+ }
382
+ for (const shape of Object.keys(cfg.routing.floors)) {
383
+ if (!(shape in provenance))
384
+ provenance[shape] = "config floors"; // overlay floors on non-shape keys
385
+ }
386
+ // partner-led resolves exploration off (probes are the wrong spend under a premium declaration);
387
+ // staff-led and risk-based leave explore untouched — the round-2 fence rides through as configured.
388
+ if (mode === "partner-led")
389
+ cfg.routing.explore = { ...cfg.routing.explore, mode: "off" };
390
+ return { mode, provenance, lints };
391
+ }
392
+ /** loadConfig plus the mode-resolution record (floor provenance + plan lints). The resolved floors are
393
+ * already applied to cfg.routing.floors — route() consumes floors only and never sees the mode. */
394
+ export function loadConfigWithMode(repoRoot, opts = {}) {
308
395
  const globalCfg = readYaml(join(opts.globalDir ?? globalConfigDir(), "config.yaml"));
309
396
  const repoCfg = readYaml(join(repoRoot, stateDirName(repoRoot), "config.yaml"));
310
397
  const merged = deepMerge(deepMerge(structuredClone(DEFAULT_CONFIG), globalCfg), repoCfg);
311
398
  const r = TickmarkrConfigSchema.safeParse(merged);
312
399
  if (!r.success)
313
400
  throw new ConfigError(z.prettifyError(r.error));
314
- return r.data;
401
+ return { cfg: r.data, mode: resolveRoutingMode(r.data, [globalCfg, repoCfg]) };
402
+ }
403
+ export function loadConfig(repoRoot, opts = {}) {
404
+ return loadConfigWithMode(repoRoot, opts).cfg;
315
405
  }
316
406
  export function configTemplate(overlay) {
317
407
  const base = `# tickmarkr config overlay — merges over built-in defaults (repo beats global beats defaults)
@@ -321,10 +411,15 @@ export function configTemplate(overlay) {
321
411
  # contextWarnTokens: 170000 # v1.23: journal+notify once per attempt when live worker context crosses this (status shows the sample)
322
412
  # setup: npm ci --prefer-offline # run in each fresh task worktree before dispatch
323
413
  # routing:
324
- # map: # per-shape routing; pin an exact CLI+model, or tier+prefer
325
- # implement: { tier: mid, prefer: [cursor-agent, codex] }
414
+ # mode: risk-based # v1.51: partner-led | risk-based | staff-led a preset compiled into floors at
415
+ # # load (partner-led: every shape frontier + explore off; staff-led: implement/refactor
416
+ # # cheap; integrity set plan/spec/migration/ui never resolves below frontier under any
417
+ # # mode). Absent = risk-based = byte-identical routing. Explicit floors below beat mode deltas (linted).
418
+ # map: # per-shape preferences/pins; band policy lives in routing.floors
419
+ # implement: { prefer: [cursor-agent, codex] }
326
420
  # migration: { pin: { via: claude-code, model: fable } }
327
- # floors: # advisory minimum tiers; 'tickmarkr plan' lints violations
421
+ # tests: { tier: null } # tombstone: null removes a legacy map tier (deprecated — move the band to routing.floors.tests; schema removal lands next version)
422
+ # floors: # tier authority — advisory minimum bands; 'tickmarkr plan' lints violations
328
423
  # migration: frontier
329
424
  # 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'
330
425
  # learnedTuning: { halfLifeRuns: 5, availWeight: 0.05 } # optional; defaults byte-identical
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  export declare const SHAPES: readonly ["plan", "spec", "implement", "tests", "docs", "migration", "ui", "refactor", "chore"];
3
+ export declare const GRAPH_ROUTING_MODES: readonly ["partner-led", "risk-based", "staff-led"];
3
4
  export declare const STATUSES: readonly ["pending", "running", "gated", "failed", "done", "human"];
4
5
  export declare const GATE_NAMES: readonly ["build", "test", "lint", "evidence", "scope", "acceptance", "review"];
5
6
  export declare const TIERS: readonly ["cheap", "mid", "frontier"];
@@ -94,6 +95,11 @@ export declare const TaskSchema: z.ZodObject<{
94
95
  }, z.core.$strip>;
95
96
  export declare const RunGraphSchema: z.ZodObject<{
96
97
  version: z.ZodLiteral<1>;
98
+ mode: z.ZodOptional<z.ZodEnum<{
99
+ "partner-led": "partner-led";
100
+ "risk-based": "risk-based";
101
+ "staff-led": "staff-led";
102
+ }>>;
97
103
  spec: z.ZodObject<{
98
104
  source: z.ZodEnum<{
99
105
  speckit: "speckit";
@@ -1,5 +1,8 @@
1
1
  import { z } from "zod";
2
2
  export const SHAPES = ["plan", "spec", "implement", "tests", "docs", "migration", "ui", "refactor", "chore"];
3
+ // v1.51 T2: spec-declared routing mode. Mirrors config ROUTING_MODES literally — config.ts imports this
4
+ // module, so a runtime import back would be circular; parity is pinned by test (tests/cli/mode-sources).
5
+ export const GRAPH_ROUTING_MODES = ["partner-led", "risk-based", "staff-led"];
3
6
  export const STATUSES = ["pending", "running", "gated", "failed", "done", "human"];
4
7
  export const GATE_NAMES = ["build", "test", "lint", "evidence", "scope", "acceptance", "review"];
5
8
  const MANDATORY_GATES = ["build", "test", "lint", "evidence", "scope"];
@@ -88,6 +91,9 @@ export const TaskSchema = z.object({
88
91
  export const RunGraphSchema = z
89
92
  .object({
90
93
  version: z.literal(1),
94
+ // v1.51 T2: spec front-matter mode — the spec author's routing-mode declaration. Source
95
+ // precedence: run flag > this > repo config > global config > default (risk-based).
96
+ mode: z.enum(GRAPH_ROUTING_MODES).optional(),
91
97
  spec: z.object({
92
98
  source: z.enum(["speckit", "gsd", "prd", "native", "taskmaster"]),
93
99
  paths: z.array(z.string()),
@@ -83,6 +83,18 @@ export declare function learnedScoreTerms(profile: RoutingProfile | undefined, s
83
83
  availWeight?: number;
84
84
  slaMinutes?: number;
85
85
  }): LearnedScoreTerms;
86
+ export declare const STAFF_LED_MARGIN = 0.1;
87
+ export interface StaffLedEvidence {
88
+ cheapBest: number;
89
+ midBest: number;
90
+ n: number;
91
+ }
92
+ export declare function staffLedEvidence(profile: RoutingProfile | undefined, shape: string, channels: readonly {
93
+ adapter: string;
94
+ model: string;
95
+ channel: string;
96
+ tier: string;
97
+ }[]): StaffLedEvidence | null;
86
98
  export declare function learnedScore(profile: RoutingProfile | undefined, shape: string, chKey: string, channel: string, opts?: {
87
99
  availWeight?: number;
88
100
  slaMinutes?: number;
@@ -217,6 +217,37 @@ export function learnedScoreTerms(profile, shape, chKey, channel, opts = {}) {
217
217
  const overrun = cell.dispatches < MIN_SAMPLES ? 0 : -OVERRUN_WEIGHT * ((cell.overruns ?? 0) / cell.dispatches); // ∈ [−OVERRUN_WEIGHT, 0]
218
218
  return { quality, perf, avail, overrun };
219
219
  }
220
+ // v1.51 T5 staff-led economics guard (round-3 Fable §3.3): ONE comparison over existing warm cells,
221
+ // consumed by plan as an ADVISORY lint only. Evidence display, never a floor input — no routing path
222
+ // reads this (anticipatory raises are refused house law: bands move on declarations, structural
223
+ // signals, or the failure ladder — never on the profile's prediction).
224
+ export const STAFF_LED_MARGIN = 0.1; // "materially below" = two quality quanta (2 × the 0.05 term weights)
225
+ // Best WARM learned score per band (cold cells — n < MIN_SAMPLES — are not evidence; the warm best is
226
+ // the band's incumbent proxy). Null unless BOTH bands are warm AND the mid incumbent leads by at least
227
+ // STAFF_LED_MARGIN: silence is the default, so a cold profile can never fire the lint.
228
+ export function staffLedEvidence(profile, shape, channels) {
229
+ if (!profile)
230
+ return null;
231
+ const best = (tier) => {
232
+ let top = null;
233
+ for (const c of channels) {
234
+ if (c.tier !== tier)
235
+ continue;
236
+ const cell = cellOf(profile, shape, channelKey(c), c.channel);
237
+ if (!cell || cell.n < MIN_SAMPLES)
238
+ continue;
239
+ const score = learnedScore(profile, shape, channelKey(c), c.channel);
240
+ if (!top || score > top.score)
241
+ top = { score, nRaw: cell.nRaw ?? 0 };
242
+ }
243
+ return top;
244
+ };
245
+ const cheap = best("cheap");
246
+ const mid = best("mid");
247
+ if (!cheap || !mid || mid.score - cheap.score < STAFF_LED_MARGIN)
248
+ return null;
249
+ return { cheapBest: cheap.score, midBest: mid.score, n: cheap.nRaw + mid.nRaw };
250
+ }
220
251
  // Total by construction: every miss returns the explicit NEUTRAL constant; every denominator is a
221
252
  // compile-time-positive constant sum; no Record indexing by row strings (the TIER_RANK[typo] scar).
222
253
  // Score 0 defers to the static sort keys, which already encode the benchmark-dated tier seeds — a
@@ -1,4 +1,5 @@
1
1
  import { type WorkerAdapter } from "../adapters/types.js";
2
+ import { type ModeResolution, type RoutingMode, type TickmarkrConfig } from "../config/config.js";
2
3
  import { type ExecutorDriver } from "../drivers/types.js";
3
4
  import { type JournalEvent } from "./journal.js";
4
5
  export interface RunOptions {
@@ -9,8 +10,23 @@ export interface RunOptions {
9
10
  driver?: ExecutorDriver;
10
11
  adapters?: WorkerAdapter[];
11
12
  globalDir?: string;
13
+ mode?: RoutingMode;
12
14
  narrate?: (event: JournalEvent) => void;
13
15
  }
16
+ export type ModeSource = "run flag" | "spec" | "repo config" | "global config" | "default";
17
+ export interface ResolvedRunMode {
18
+ cfg: TickmarkrConfig;
19
+ /** effective mode + per-floor provenance + standing lints, from the ONE preset compiler in config.ts */
20
+ mode: ModeResolution;
21
+ source: ModeSource;
22
+ /** set when the run flag picked a mode below the spec-declared mode (loud warn; --route-strict refuses) */
23
+ conflict?: string;
24
+ }
25
+ export declare function resolveRunMode(repoRoot: string, opts?: {
26
+ flag?: RoutingMode;
27
+ spec?: RoutingMode;
28
+ globalDir?: string;
29
+ }): ResolvedRunMode;
14
30
  export interface RunSummary {
15
31
  runId: string;
16
32
  branch: string;
@@ -1,12 +1,14 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { shq } from "../adapters/types.js";
3
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
4
5
  import { join } from "node:path";
6
+ import { stringify } from "yaml";
5
7
  import { trailerPattern, writePrompt } from "../adapters/prompt.js";
6
8
  import { allAdapters, discoverChannels, getAdapter, probeAll, readDoctor } from "../adapters/registry.js";
7
9
  import { addUsage, channelKey, matchesTrustDialog, QUOTA_RE } from "../adapters/types.js";
8
10
  import { bannerShell } from "../brand.js";
9
- import { loadConfig } from "../config/config.js";
11
+ import { globalConfigDir, loadConfigWithMode, readOverlayFile, repoOverlayPath, } from "../config/config.js";
10
12
  import { herdrSealShellPrefix, SubprocessDriver } from "../drivers/subprocess.js";
11
13
  import { formatOwnedName } from "../drivers/types.js";
12
14
  import { captureBaseline, detectGateCommands } from "../gates/baseline.js";
@@ -17,8 +19,41 @@ import { cleanupRunWorktrees, gitHead, linkNodeModules, sh, shGit, WORKTREE_LAYO
17
19
  import { classifyWorkerResultCause, engagementComparable, Journal, loadRoutingProfile, newRunId } from "./journal.js";
18
20
  import { acquireRunLock, releaseRunLock } from "./lock.js";
19
21
  import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
20
- import { nextChannel, route } from "../route/router.js";
22
+ import { nextChannel, QUALITY_ENV, route } from "../route/router.js";
21
23
  import { desiredPanes } from "./reconcile.js";
24
+ const MODE_RANK = { "staff-led": 0, "risk-based": 1, "partner-led": 2 };
25
+ // An override (flag/spec) re-resolves through loadConfigWithMode itself, via a synthesized repo overlay
26
+ // carrying routing.mode — floors, explore, lints, and provenance all come from config.ts's preset
27
+ // compiler, never duplicated mode math here (the quality-silently-loses defense holds by construction).
28
+ function withOverlayMode(repoRoot, mode, globalDir) {
29
+ const overlay = readOverlayFile(repoOverlayPath(repoRoot));
30
+ const tmp = mkdtempSync(join(tmpdir(), "tickmarkr-mode-"));
31
+ try {
32
+ mkdirSync(join(tmp, ".tickmarkr"), { recursive: true });
33
+ writeFileSync(join(tmp, ".tickmarkr", "config.yaml"), stringify({ ...overlay, routing: { ...overlay.routing, mode } }));
34
+ return loadConfigWithMode(tmp, { globalDir });
35
+ }
36
+ finally {
37
+ rmSync(tmp, { recursive: true, force: true });
38
+ }
39
+ }
40
+ export function resolveRunMode(repoRoot, opts = {}) {
41
+ const overlayMode = (path) => readOverlayFile(path).routing?.mode;
42
+ const source = opts.flag !== undefined ? "run flag"
43
+ : opts.spec !== undefined ? "spec"
44
+ : overlayMode(repoOverlayPath(repoRoot)) !== undefined ? "repo config"
45
+ : overlayMode(join(opts.globalDir ?? globalConfigDir(), "config.yaml")) !== undefined ? "global config"
46
+ : "default";
47
+ const override = opts.flag ?? opts.spec;
48
+ const base = loadConfigWithMode(repoRoot, { globalDir: opts.globalDir });
49
+ const resolved = override === undefined || override === base.mode.mode
50
+ ? base
51
+ : withOverlayMode(repoRoot, override, opts.globalDir);
52
+ const conflict = opts.flag !== undefined && opts.spec !== undefined && MODE_RANK[opts.flag] < MODE_RANK[opts.spec]
53
+ ? `mode conflict: run flag ${opts.flag} selects a mode below the spec-declared ${opts.spec} — the run flag wins this run`
54
+ : undefined;
55
+ return { cfg: resolved.cfg, mode: resolved.mode, source, ...(conflict ? { conflict } : {}) };
56
+ }
22
57
  // VIS-01: one formatter, four readers (run-end journal event, run/resume CLI, run-end notify).
23
58
  // Parity by construction — every caller renders the same complete bucket line.
24
59
  export function formatSummary(s) {
@@ -54,14 +89,11 @@ async function cherryPickCommits(wt, commits) {
54
89
  return carried;
55
90
  }
56
91
  export async function runDaemon(repoRoot, opts = {}) {
57
- const cfg = loadConfig(repoRoot, { globalDir: opts.globalDir });
92
+ // v1.51 T2: retired --quality env seam. Mode resolution owns premium routing now; an exported
93
+ // TICKMARKR_QUALITY must not resurrect the old one-band floor raise in daemon dispatches.
94
+ delete process.env[QUALITY_ENV];
58
95
  const adapters = opts.adapters ?? allAdapters();
59
96
  const health = readDoctor(repoRoot) ?? (await probeAll(adapters));
60
- const channels = discoverChannels(cfg, adapters, health);
61
- // v1.6 ROUTE-06: build the learned profile ONCE at startup (never per task, never in the comparator).
62
- // No preview — the daemon honors routing.learned:off and gets undefined; this snapshot is immutable
63
- // for the run, so this run's own telemetry never feeds back into its own routing.
64
- const profile = loadRoutingProfile(repoRoot, cfg);
65
97
  const driver = opts.driver ?? new SubprocessDriver();
66
98
  // HARD-01/02: hold the run lock across the whole read-modify-write of graph.json. Acquire
67
99
  // BEFORE loadGraph; release in the finally below (every exit path, incl. throws).
@@ -72,6 +104,18 @@ export async function runDaemon(repoRoot, opts = {}) {
72
104
  const lock = acquireRunLock(repoRoot, runId);
73
105
  try {
74
106
  let graph = loadGraph(repoRoot);
107
+ // v1.51 T2: the routing mode resolves BEFORE any routing input is built — run flag > spec front-matter
108
+ // > repo > global > default. The resolved cfg carries mode-compiled floors; route() never sees the mode.
109
+ const rm = resolveRunMode(repoRoot, { flag: opts.mode, spec: graph.mode, globalDir: opts.globalDir });
110
+ const cfg = rm.cfg;
111
+ // v1.51 T4: every dispatch provenance line begins with the mode and its source; when a pin won
112
+ // the route (the final "→ " segment is a pin, not a degraded-to-auto tail) it names the mode it bypassed.
113
+ const dispatchProvenance = (p) => `mode ${rm.mode.mode} (${rm.source})${p.split("→ ").pop().startsWith("pin ") ? ` — pin bypasses mode ${rm.mode.mode}` : ""} · ${p}`;
114
+ const channels = discoverChannels(cfg, adapters, health);
115
+ // v1.6 ROUTE-06: build the learned profile ONCE at startup (never per task, never in the comparator).
116
+ // No preview — the daemon honors routing.learned:off and gets undefined; this snapshot is immutable
117
+ // for the run, so this run's own telemetry never feeds back into its own routing.
118
+ const profile = loadRoutingProfile(repoRoot, cfg);
75
119
  const journal = opts.resume ? Journal.open(repoRoot, runId, opts.narrate) : Journal.create(repoRoot, runId, opts.narrate);
76
120
  const branchEvent = opts.resume
77
121
  ? [...journal.read()].reverse().find((e) => (e.event === "run-start" || e.event === "run-end" || e.event === "merge") && typeof e.data.branch === "string")
@@ -131,7 +175,7 @@ export async function runDaemon(repoRoot, opts = {}) {
131
175
  baseRef = await gitHead(repoRoot);
132
176
  baseline = await captureBaseline(repoRoot, commands);
133
177
  writeFileSync(join(journal.dir, "baseline.json"), JSON.stringify(baseline, null, 2));
134
- journal.append("run-start", undefined, { pid: process.pid, baseRef, commands, channels: channels.map(channelKey), branch, graphDefinitionHash: graphDefinitionHash(graph) }); // graphDefinitionHash: T3 engagement identity (status+resume share it); pid: v1.13 (VIS-11) liveness
178
+ journal.append("run-start", undefined, { pid: process.pid, baseRef, commands, channels: channels.map(channelKey), branch, graphDefinitionHash: graphDefinitionHash(graph), mode: rm.mode.mode, modeSource: rm.source }); // graphDefinitionHash: T3 engagement identity (status+resume share it); pid: v1.13 (VIS-11) liveness; mode/modeSource: v1.51 T2
135
179
  }
136
180
  // T6: open the narrator AFTER run-start/run-resume is journaled so the watch surface has a run to
137
181
  // show. driver.narrator is undefined on subprocess → no-op (subprocess spawns nothing). Swallowed:
@@ -389,7 +433,7 @@ export async function runDaemon(repoRoot, opts = {}) {
389
433
  lastContextTokens = undefined;
390
434
  graph = setStatus(graph, t.id, "running");
391
435
  saveGraph(repoRoot, graph);
392
- journal.append("task-dispatch", t.id, { assignment, attempt, provenance: r.provenance, retryMode });
436
+ journal.append("task-dispatch", t.id, { assignment, attempt, provenance: dispatchProvenance(r.provenance), retryMode });
393
437
  const taskBase = await integrationHead(intWt); // deps are merged → visible to this task
394
438
  const taskBranch = `${branch}--${t.id}`; // "--": a ref can't nest under the existing integration branch (locked decision 10)
395
439
  const priorWt = worktreePath(repoRoot, taskBranch);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.50.0",
3
+ "version": "1.51.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",