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.
@@ -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,16 +7,20 @@ 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;
13
21
  model: z.ZodString;
14
22
  }, z.core.$strip>>;
15
- tier: z.ZodOptional<z.ZodEnum<{
16
- cheap: "cheap";
17
- mid: "mid";
18
- frontier: "frontier";
19
- }>>;
23
+ tier: z.ZodOptional<z.ZodPipe<z.ZodOptional<z.ZodNull>, z.ZodTransform<undefined, null | undefined>>>;
20
24
  prefer: z.ZodOptional<z.ZodArray<z.ZodString>>;
21
25
  escalate: z.ZodOptional<z.ZodBoolean>;
22
26
  }, z.core.$strip>;
@@ -60,16 +64,17 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
60
64
  contextWarnTokens: z.ZodNumber;
61
65
  setup: z.ZodOptional<z.ZodString>;
62
66
  routing: z.ZodObject<{
67
+ mode: z.ZodOptional<z.ZodEnum<{
68
+ "partner-led": "partner-led";
69
+ "risk-based": "risk-based";
70
+ "staff-led": "staff-led";
71
+ }>>;
63
72
  map: z.ZodRecord<z.ZodString, z.ZodObject<{
64
73
  pin: z.ZodOptional<z.ZodObject<{
65
74
  via: z.ZodString;
66
75
  model: z.ZodString;
67
76
  }, z.core.$strip>>;
68
- tier: z.ZodOptional<z.ZodEnum<{
69
- cheap: "cheap";
70
- mid: "mid";
71
- frontier: "frontier";
72
- }>>;
77
+ tier: z.ZodOptional<z.ZodPipe<z.ZodOptional<z.ZodNull>, z.ZodTransform<undefined, null | undefined>>>;
73
78
  prefer: z.ZodOptional<z.ZodArray<z.ZodString>>;
74
79
  escalate: z.ZodOptional<z.ZodBoolean>;
75
80
  }, z.core.$strip>>;
@@ -195,9 +200,31 @@ export declare function globalConfigDir(): string;
195
200
  export declare function overlayPreferShapes(repoRoot: string, opts?: {
196
201
  globalDir?: string;
197
202
  }): ReadonlySet<string>;
203
+ export type ModeResolution = {
204
+ mode: RoutingMode;
205
+ /** floor shape → "mode <name>" | "config floors" */
206
+ provenance: Record<string, string>;
207
+ /** plan lints: shadowed mode deltas + operator floors below the integrity minimum (standing) */
208
+ lints: string[];
209
+ };
210
+ /** loadConfig plus the mode-resolution record (floor provenance + plan lints). The resolved floors are
211
+ * already applied to cfg.routing.floors — route() consumes floors only and never sees the mode. */
212
+ export declare function loadConfigWithMode(repoRoot: string, opts?: {
213
+ globalDir?: string;
214
+ repoOverlayText?: string;
215
+ }): {
216
+ cfg: TickmarkrConfig;
217
+ mode: ModeResolution;
218
+ };
198
219
  export declare function loadConfig(repoRoot: string, opts?: {
199
220
  globalDir?: string;
200
221
  }): TickmarkrConfig;
222
+ /** v1.52 T2 write-time reload guard: run candidate repo-overlay bytes through the SAME production
223
+ * loader path every later command uses (parse → merge → schema → mode resolution). Returns null
224
+ * when the bytes load, else the loader's failure message — the caller must refuse the write. */
225
+ export declare function overlayBytesLoadError(repoRoot: string, bytes: string, opts?: {
226
+ globalDir?: string;
227
+ }): string | null;
201
228
  export type InitConfigOverlay = {
202
229
  concurrency?: number;
203
230
  driver?: TickmarkrConfig["driver"];
@@ -10,9 +10,31 @@ 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"];
23
+ // v1.52 T5: tier is no longer a map-entry band authority — routing.floors is the only tier
24
+ // source left (v1.51 T3 deprecated this field with a promise to remove it here). Only the legacy
25
+ // `tier: null` tombstone still parses (and normalizes to absent); any real value fails config
26
+ // load fail-closed, naming routing.floors as the move target, so a band intent set here can never
27
+ // silently vanish. The field stays in the schema/type as an always-undefined marker (not deleted
28
+ // outright) so a map entry can never carry a real tier value again, structurally.
13
29
  export const MapEntrySchema = z.object({
14
30
  pin: z.object({ via: z.string(), model: z.string() }).optional(),
15
- tier: TierEnum.optional(),
31
+ tier: z
32
+ .null({
33
+ error: (iss) => `routing.map entry tier ${JSON.stringify(iss.input)} is no longer a band authority — move this value to routing.floors.<shape> (only "tier: null" tombstones still parse)`,
34
+ })
35
+ .optional()
36
+ .transform(() => undefined)
37
+ .optional(),
16
38
  prefer: z.array(z.string()).optional(),
17
39
  escalate: z.boolean().optional(),
18
40
  });
@@ -80,6 +102,8 @@ export const TickmarkrConfigSchema = z.object({
80
102
  contextWarnTokens: z.number().int().positive(),
81
103
  setup: z.string().optional(),
82
104
  routing: z.object({
105
+ // v1.51 T1: preset expanded into floors by loadConfig — absent ⇒ risk-based ⇒ byte-identical routing.
106
+ mode: ModeEnum.optional(),
83
107
  map: z.record(z.string(), MapEntrySchema),
84
108
  floors: z.record(z.string(), TierEnum),
85
109
  learned: z.enum(["on", "off"]), // v1.6 ROUTE-09 kill switch; a typo (offf) fails loud via safeParse
@@ -155,12 +179,15 @@ export const DEFAULT_CONFIG = {
155
179
  taskTimeoutMinutes: 30,
156
180
  contextWarnTokens: 170_000, // v1.23 T2: overseer ctx-watch.sh proven threshold
157
181
  routing: {
182
+ // v1.51 T3 (round-2 consult) / v1.52 T5: map entries carry preferences/pins only — band
183
+ // policy lives in routing.floors, the single tier authority. A map entry can no longer carry
184
+ // a real tier value at all: MapEntrySchema fails config load fail-closed on one, naming
185
+ // routing.floors as the move target; only the legacy `tier: null` tombstone still parses.
158
186
  map: {
159
187
  plan: { pin: { via: "claude-code", model: "fable" } },
160
188
  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" },
189
+ implement: { prefer: ["cursor-agent", "codex"] },
190
+ tests: { prefer: ["opencode"] },
164
191
  },
165
192
  floors: {
166
193
  plan: "frontier", spec: "frontier", migration: "frontier",
@@ -278,7 +305,9 @@ function deepMerge(base, over) {
278
305
  delete out[k]; // v1.1 tombstone: an explicit null in an overlay removes the key (e.g. stale tiers model ids)
279
306
  continue;
280
307
  }
281
- out[k] = k in out ? deepMerge(out[k], v) : v;
308
+ // OBS-75 class: merge fresh-landing objects onto {} so nested tombstone nulls are pruned even when
309
+ // no lower layer set the key (a fleet-cleared deny writes {adapters: null} — the schema rejects raw null)
310
+ out[k] = k in out ? deepMerge(out[k], v) : deepMerge({}, v);
282
311
  }
283
312
  return out;
284
313
  }
@@ -304,14 +333,104 @@ export function overlayPreferShapes(repoRoot, opts = {}) {
304
333
  }
305
334
  return shapes;
306
335
  }
307
- export function loadConfig(repoRoot, opts = {}) {
336
+ const lowerTier = (t) => (t === "frontier" ? "mid" : "cheap");
337
+ const maxTier = (a, b) => (TIER_RANK[a] >= TIER_RANK[b] ? a : b);
338
+ const integrityMin = (shape) => INTEGRITY_FLOOR_SHAPES.includes(shape) ? "frontier" : "cheap";
339
+ // partner-led = frontier everywhere; staff-led = one band down, integrity-clamped (net effect on the
340
+ // defaults: implement/refactor → cheap, ui → frontier); risk-based = identity, so an absent mode key
341
+ // resolves byte-identically to pre-v1.51 routing.
342
+ function presetFloor(mode, shape, dflt) {
343
+ if (mode === "partner-led")
344
+ return "frontier";
345
+ if (mode === "staff-led")
346
+ return maxTier(lowerTier(dflt), integrityMin(shape));
347
+ return dflt;
348
+ }
349
+ /** Which floor shapes the operator wrote in an overlay layer (explicit beats mode; null tombstones stay removed). */
350
+ function overlayFloorEdits(layers) {
351
+ const explicit = new Set();
352
+ const tombstoned = new Set();
353
+ for (const layer of layers) {
354
+ const floors = layer?.routing?.floors;
355
+ if (!floors || typeof floors !== "object" || Array.isArray(floors))
356
+ continue;
357
+ for (const [shape, v] of Object.entries(floors)) {
358
+ if (v === null) {
359
+ explicit.delete(shape);
360
+ tombstoned.add(shape);
361
+ }
362
+ else {
363
+ tombstoned.delete(shape);
364
+ explicit.add(shape);
365
+ }
366
+ }
367
+ }
368
+ return { explicit, tombstoned };
369
+ }
370
+ // Mutates cfg.routing.floors (and explore under partner-led) to the mode-resolved values.
371
+ function resolveRoutingMode(cfg, layers) {
372
+ const mode = cfg.routing.mode ?? "risk-based";
373
+ const { explicit, tombstoned } = overlayFloorEdits(layers);
374
+ const provenance = {};
375
+ const lints = [];
376
+ for (const shape of SHAPES) {
377
+ if (tombstoned.has(shape))
378
+ continue; // operator removed the floor — a mode never resurrects it
379
+ const dflt = DEFAULT_CONFIG.routing.floors[shape];
380
+ const moded = presetFloor(mode, shape, dflt);
381
+ if (explicit.has(shape)) {
382
+ const val = cfg.routing.floors[shape];
383
+ provenance[shape] = "config floors";
384
+ if (moded !== val && moded !== dflt) {
385
+ lints.push(`floors.${shape}: ${val} (config floors) overrides mode ${mode} — shadowed delta: ${shape} ${dflt}→${moded}`);
386
+ }
387
+ if (TIER_RANK[val] < TIER_RANK[integrityMin(shape)]) {
388
+ lints.push(`floors.${shape}: ${val} is below integrity minimum frontier — integrity class ${INTEGRITY_FLOOR_SHAPES.join("/")} holds regardless of mode`);
389
+ }
390
+ continue;
391
+ }
392
+ cfg.routing.floors[shape] = moded;
393
+ provenance[shape] = `mode ${mode}`;
394
+ }
395
+ for (const shape of Object.keys(cfg.routing.floors)) {
396
+ if (!(shape in provenance))
397
+ provenance[shape] = "config floors"; // overlay floors on non-shape keys
398
+ }
399
+ // partner-led resolves exploration off (probes are the wrong spend under a premium declaration);
400
+ // staff-led and risk-based leave explore untouched — the round-2 fence rides through as configured.
401
+ if (mode === "partner-led")
402
+ cfg.routing.explore = { ...cfg.routing.explore, mode: "off" };
403
+ return { mode, provenance, lints };
404
+ }
405
+ /** loadConfig plus the mode-resolution record (floor provenance + plan lints). The resolved floors are
406
+ * already applied to cfg.routing.floors — route() consumes floors only and never sees the mode. */
407
+ export function loadConfigWithMode(repoRoot, opts = {}) {
308
408
  const globalCfg = readYaml(join(opts.globalDir ?? globalConfigDir(), "config.yaml"));
309
- const repoCfg = readYaml(join(repoRoot, stateDirName(repoRoot), "config.yaml"));
409
+ // v1.52 T2: repoOverlayText substitutes candidate bytes for the on-disk repo layer — the fleet
410
+ // write guard validates EXACTLY what it is about to write through this one loader path.
411
+ const repoCfg = opts.repoOverlayText === undefined
412
+ ? readYaml(join(repoRoot, stateDirName(repoRoot), "config.yaml"))
413
+ : parse(opts.repoOverlayText);
310
414
  const merged = deepMerge(deepMerge(structuredClone(DEFAULT_CONFIG), globalCfg), repoCfg);
311
415
  const r = TickmarkrConfigSchema.safeParse(merged);
312
416
  if (!r.success)
313
417
  throw new ConfigError(z.prettifyError(r.error));
314
- return r.data;
418
+ return { cfg: r.data, mode: resolveRoutingMode(r.data, [globalCfg, repoCfg]) };
419
+ }
420
+ export function loadConfig(repoRoot, opts = {}) {
421
+ return loadConfigWithMode(repoRoot, opts).cfg;
422
+ }
423
+ /** v1.52 T2 write-time reload guard: run candidate repo-overlay bytes through the SAME production
424
+ * loader path every later command uses (parse → merge → schema → mode resolution). Returns null
425
+ * when the bytes load, else the loader's failure message — the caller must refuse the write. */
426
+ export function overlayBytesLoadError(repoRoot, bytes, opts = {}) {
427
+ try {
428
+ loadConfigWithMode(repoRoot, { ...opts, repoOverlayText: bytes });
429
+ return null;
430
+ }
431
+ catch (e) {
432
+ return e.message;
433
+ }
315
434
  }
316
435
  export function configTemplate(overlay) {
317
436
  const base = `# tickmarkr config overlay — merges over built-in defaults (repo beats global beats defaults)
@@ -321,10 +440,15 @@ export function configTemplate(overlay) {
321
440
  # contextWarnTokens: 170000 # v1.23: journal+notify once per attempt when live worker context crosses this (status shows the sample)
322
441
  # setup: npm ci --prefer-offline # run in each fresh task worktree before dispatch
323
442
  # routing:
324
- # map: # per-shape routing; pin an exact CLI+model, or tier+prefer
325
- # implement: { tier: mid, prefer: [cursor-agent, codex] }
443
+ # mode: risk-based # v1.51: partner-led | risk-based | staff-led a preset compiled into floors at
444
+ # # load (partner-led: every shape frontier + explore off; staff-led: implement/refactor
445
+ # # cheap; integrity set plan/spec/migration/ui never resolves below frontier under any
446
+ # # mode). Absent = risk-based = byte-identical routing. Explicit floors below beat mode deltas (linted).
447
+ # map: # per-shape preferences/pins; band policy lives in routing.floors
448
+ # implement: { prefer: [cursor-agent, codex] }
326
449
  # migration: { pin: { via: claude-code, model: fable } }
327
- # floors: # advisory minimum tiers; 'tickmarkr plan' lints violations
450
+ # tests: { tier: null } # tombstone: null removes a legacy map tier (a real tier value now fails config load — move the band to routing.floors.tests)
451
+ # floors: # tier authority — advisory minimum bands; 'tickmarkr plan' lints violations
328
452
  # migration: frontier
329
453
  # learned: on # default ON (ROUTE-14); cold profile = exact v1.5 static routing, warms per workspace. Set 'off' to pin static routing; preview with 'tickmarkr plan'
330
454
  # learnedTuning: { halfLifeRuns: 5, availWeight: 0.05 } # optional; defaults byte-identical
@@ -561,7 +685,9 @@ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}) {
561
685
  }
562
686
  }
563
687
  if (Object.keys(modelDelta).length) {
564
- tiersOut[adapter] = { models: { ...tiersOut[adapter]?.models, ...modelDelta } };
688
+ // spread the existing entry so vendor/channel/windows survive the rewrite — dropping them
689
+ // makes the overlay unloadable for any adapter without a default seed (reload-guard class)
690
+ tiersOut[adapter] = { ...tiersOut[adapter], models: { ...tiersOut[adapter]?.models, ...modelDelta } };
565
691
  tiersTouched = true;
566
692
  }
567
693
  }
@@ -587,47 +713,58 @@ export function serializeFleetOverlay(overlay, provenance = {}) {
587
713
  return "";
588
714
  const lines = [];
589
715
  const today = new Date().toISOString().slice(0, 10);
716
+ // OBS-75: never glue stringify() output onto a key line — wrap the key into the object and
717
+ // re-indent the whole emitted block, so sequences/nested maps nest correctly and null
718
+ // tombstones/empty collections survive the serialize→parse round-trip.
719
+ const block = (obj, pad) => stringify(obj).trimEnd().split("\n").map((l) => `${pad}${l}`);
590
720
  const routing = overlay.routing;
591
721
  if (routing) {
592
722
  lines.push("routing:");
593
723
  const deny = routing.deny;
594
- if (deny) {
724
+ if (deny && (deny.adapters !== undefined || deny.models !== undefined)) {
595
725
  lines.push(" deny:");
596
- if (deny.adapters)
597
- lines.push(` adapters: ${stringify(deny.adapters).trim()}`);
598
- if (deny.models)
599
- lines.push(` models: ${stringify(deny.models).trim()}`);
600
- }
601
- if (routing.map) {
602
- lines.push(" map:");
603
- for (const [shape, entry] of Object.entries(routing.map)) {
604
- lines.push(` ${shape}:`);
605
- const body = stringify(entry, { indent: 2 }).trim().split("\n");
606
- lines.push(...body.map((l) => ` ${l}`));
607
- }
726
+ if (deny.adapters !== undefined)
727
+ lines.push(...block({ adapters: deny.adapters }, " "));
728
+ if (deny.models !== undefined)
729
+ lines.push(...block({ models: deny.models }, " "));
608
730
  }
731
+ if (routing.map)
732
+ lines.push(...block({ map: routing.map }, " "));
609
733
  if (routing.floors)
610
- lines.push(` floors: ${stringify(routing.floors).trim().replace(/^/gm, " ")}`);
734
+ lines.push(...block({ floors: routing.floors }, " "));
611
735
  }
612
736
  const tiers = overlay.tiers;
613
- if (tiers) {
737
+ if (tiers && Object.keys(tiers).length) {
614
738
  lines.push("tiers:");
615
739
  for (const [adapter, entry] of Object.entries(tiers)) {
616
- lines.push(` ${adapter}:`);
740
+ const body = [];
617
741
  if (entry.vendor)
618
- lines.push(` vendor: ${entry.vendor}`);
742
+ body.push(` vendor: ${entry.vendor}`);
619
743
  if (entry.channel)
620
- lines.push(` channel: ${entry.channel}`);
621
- lines.push(" models:");
622
- for (const [model, tier] of Object.entries(entry.models ?? {})) {
623
- if (tier === null)
624
- lines.push(` ${model}: null`);
625
- else {
626
- const note = provenance[adapter]?.[model];
627
- const suffix = note ? ` # ${note} — fleet ${today}` : "";
628
- lines.push(` ${model}: ${tier}${suffix}`);
744
+ body.push(` channel: ${entry.channel}`);
745
+ if (entry.windows)
746
+ body.push(...block({ windows: entry.windows }, " "));
747
+ const models = Object.entries(entry.models ?? {});
748
+ if (models.length) {
749
+ body.push(" models:");
750
+ for (const [model, tier] of models) {
751
+ if (tier === null)
752
+ body.push(` ${model}: null`);
753
+ else {
754
+ const note = provenance[adapter]?.[model];
755
+ const suffix = note ? ` # ${note} — fleet ${today}` : "";
756
+ body.push(` ${model}: ${tier}${suffix}`);
757
+ }
629
758
  }
630
759
  }
760
+ else if (entry.models) {
761
+ body.push(" models: {}"); // present-but-empty: explicit {} — a childless header parses as null and the loader rejects it
762
+ }
763
+ // a bare `adapter:` line parses as a null tombstone and would DELETE the adapter's default seeds on merge
764
+ if (body.length)
765
+ lines.push(` ${adapter}:`, ...body);
766
+ else
767
+ lines.push(` ${adapter}: {}`);
631
768
  }
632
769
  }
633
770
  return `${lines.join("\n")}\n`;
@@ -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
@@ -32,6 +32,7 @@ export interface ExploreContext {
32
32
  }
33
33
  export declare const NO_EXPLORE_ENV = "TICKMARKR_NO_EXPLORE";
34
34
  export declare const QUALITY_ENV = "TICKMARKR_QUALITY";
35
+ export declare const ROUTING_ENV_SEAMS: readonly ["TICKMARKR_QUALITY", "TICKMARKR_NO_EXPLORE"];
35
36
  export declare const raiseTier: (tier: Tier) => Tier;
36
37
  export declare class RoutingError extends Error {
37
38
  constructor(msg: string);
@@ -4,6 +4,9 @@ import { disallowedBy } from "./preference.js";
4
4
  import { cellOf, EXPLORE_CAP, explorationBonus, learnedScore, MIN_SAMPLES } from "./profile.js";
5
5
  export const NO_EXPLORE_ENV = "TICKMARKR_NO_EXPLORE";
6
6
  export const QUALITY_ENV = "TICKMARKR_QUALITY";
7
+ // OBS-74: every routing env seam, in one list — the spawn seam (src/run/git.ts) scrubs exactly
8
+ // these from child env so gate/baseline/tip-verify children are hermetic by construction.
9
+ export const ROUTING_ENV_SEAMS = [QUALITY_ENV, NO_EXPLORE_ENV];
7
10
  const exploreCap = (cfg) => cfg.routing.explore?.cap ?? EXPLORE_CAP;
8
11
  const qualityOn = (exploreCtx) => !!exploreCtx?.quality || process.env[QUALITY_ENV] === "1";
9
12
  export const raiseTier = (tier) => tier === "cheap" ? "mid" : tier === "mid" ? "frontier" : "frontier";
@@ -160,15 +163,13 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
160
163
  maybeSlaLint(lints, task, profile, slaMinutes, c);
161
164
  return { assignment: toAssignment(c), ladder: ladderFor(task, entry), lints, provenance: `${degraded}pin ${entry.pin.via}:${entry.pin.model} (config routing.map)` };
162
165
  }
163
- const baseTier = entry?.tier ?? floor ?? "cheap";
166
+ const baseTier = floor ?? "cheap";
164
167
  let minTier = taskFloor && TIER_RANK[taskFloor] > TIER_RANK[baseTier] ? taskFloor : baseTier; // task floor is a hard >= constraint in all paths (D-04)
165
168
  if (quality && advisoryFloor) {
166
169
  const raised = raiseTier(advisoryFloor);
167
170
  if (TIER_RANK[raised] > TIER_RANK[minTier])
168
171
  minTier = raised;
169
172
  }
170
- if (entry?.tier)
171
- lintFloor(entry.tier, "map tier");
172
173
  if (prefActive)
173
174
  for (const p of prefer ?? [])
174
175
  preflightPrefer(p);
@@ -253,9 +254,8 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
253
254
  }
254
255
  const bound = qualityBound(quality, advisoryFloor, taskFloorRaw) ??
255
256
  (taskFloor && TIER_RANK[taskFloor] >= TIER_RANK[baseTier] ? `floor ${taskFloor} (task hint${src})` :
256
- entry?.tier ? `tier ${entry.tier} (config routing.map)` :
257
- floor ? `floor ${floor} (config floors)` :
258
- "tier cheap (default)");
257
+ floor ? `floor ${floor} (config floors)` :
258
+ "tier cheap (default)");
259
259
  // name the key that actually broke the tie: prefer outranks the marginal-cost/tier keys, so if the
260
260
  // winner matched a prefer entry, prefer decided it — not "cheapest sufficient tier" (ROUTE-03, WR-01)
261
261
  const preferVia = preferFromAuto(task.shape, preferCtx)
@@ -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;