tickmarkr 1.49.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.
@@ -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,4 +1,5 @@
1
1
  import { shq } from "../adapters/types.js";
2
+ import { PANE_IDENTITY_ENV, paneIdentityLine } from "../brand.js";
2
3
  import { createWorktree, sh } from "../run/git.js";
3
4
  import { herdrSealShellPrefix } from "./subprocess.js";
4
5
  import { canonicalizeLegacyName, formatOwnedName, panesToClose, parseOwnedName } from "./types.js";
@@ -115,6 +116,8 @@ export class HerdrDriver {
115
116
  const rootPane = res?.root_pane?.pane_id; // auto-created shell pane (SKILL:343)
116
117
  if (typeof tabId !== "string" || !tabId)
117
118
  throw new Error(`herdr tab create returned no tab_id (refusing untargeted placement): ${t.stdout}`);
119
+ // T5: the banner's pane identity line, derived from the T1 owned name (legacy names pass through).
120
+ const identity = shq(paneIdentityLine(canonicalizeLegacyName(name, "")));
118
121
  let r = await this.herdr(`agent start ${shq(name)} --cwd ${shq(cwd)} --tab ${shq(tabId)} --no-focus -- bash`);
119
122
  if (r.code !== 0 && /agent_name_taken/.test(r.stderr + r.stdout)) {
120
123
  // DEFECT-01: a prior (killed) process's kept pane still holds this durable name — reclaim it.
@@ -149,7 +152,7 @@ export class HerdrDriver {
149
152
  // worker GETS, not a rule it must remember (P40-02 probe leak). Fail closed: a failed seed rejects.
150
153
  // v1.22 T3 / OBS-17: also strip HERDR_ENV + socket path so the worker cannot open/mutate panes in
151
154
  // the operator's session. Daemon-side this.herdr() calls keep process.env (unsealed).
152
- const seed = await this.herdr(`pane run ${shq(id)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; ${herdrSealShellPrefix()}`)}`);
155
+ const seed = await this.herdr(`pane run ${shq(id)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; export ${PANE_IDENTITY_ENV}=${identity}; ${herdrSealShellPrefix()}`)}`);
153
156
  if (seed.code !== 0)
154
157
  throw new Error(`herdr workspace-id seed failed (refusing untargeted pane): ${seed.stderr || seed.stdout}`);
155
158
  return { id, name, cwd, tabId };
@@ -260,7 +263,8 @@ export class HerdrDriver {
260
263
  // like a failed cd (return null → caller falls back to a per-slot tab). this.ws is guaranteed set —
261
264
  // joinGroup runs only after the first member's tabSlot succeeded, which requires it (VIS-10).
262
265
  // v1.22 T3: same env seal as tabSlot — strip control-plane vars after the workspace seed.
263
- const seed = await this.herdr(`pane run ${shq(pane)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; ${herdrSealShellPrefix()}`)}`);
266
+ // T5: same brand identity seed as tabSlot every group member's banner announces its own name.
267
+ const seed = await this.herdr(`pane run ${shq(pane)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; export ${PANE_IDENTITY_ENV}=${shq(paneIdentityLine(canonicalizeLegacyName(name, "")))}; ${herdrSealShellPrefix()}`)}`);
264
268
  if (seed.code !== 0) {
265
269
  await this.herdr(`pane close ${shq(pane)}`);
266
270
  return null;
@@ -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,11 +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
- import { loadConfig } from "../config/config.js";
10
+ import { bannerShell } from "../brand.js";
11
+ import { globalConfigDir, loadConfigWithMode, readOverlayFile, repoOverlayPath, } from "../config/config.js";
9
12
  import { herdrSealShellPrefix, SubprocessDriver } from "../drivers/subprocess.js";
10
13
  import { formatOwnedName } from "../drivers/types.js";
11
14
  import { captureBaseline, detectGateCommands } from "../gates/baseline.js";
@@ -16,8 +19,41 @@ import { cleanupRunWorktrees, gitHead, linkNodeModules, sh, shGit, WORKTREE_LAYO
16
19
  import { classifyWorkerResultCause, engagementComparable, Journal, loadRoutingProfile, newRunId } from "./journal.js";
17
20
  import { acquireRunLock, releaseRunLock } from "./lock.js";
18
21
  import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
19
- import { nextChannel, route } from "../route/router.js";
22
+ import { nextChannel, QUALITY_ENV, route } from "../route/router.js";
20
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
+ }
21
57
  // VIS-01: one formatter, four readers (run-end journal event, run/resume CLI, run-end notify).
22
58
  // Parity by construction — every caller renders the same complete bucket line.
23
59
  export function formatSummary(s) {
@@ -53,14 +89,11 @@ async function cherryPickCommits(wt, commits) {
53
89
  return carried;
54
90
  }
55
91
  export async function runDaemon(repoRoot, opts = {}) {
56
- 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];
57
95
  const adapters = opts.adapters ?? allAdapters();
58
96
  const health = readDoctor(repoRoot) ?? (await probeAll(adapters));
59
- const channels = discoverChannels(cfg, adapters, health);
60
- // v1.6 ROUTE-06: build the learned profile ONCE at startup (never per task, never in the comparator).
61
- // No preview — the daemon honors routing.learned:off and gets undefined; this snapshot is immutable
62
- // for the run, so this run's own telemetry never feeds back into its own routing.
63
- const profile = loadRoutingProfile(repoRoot, cfg);
64
97
  const driver = opts.driver ?? new SubprocessDriver();
65
98
  // HARD-01/02: hold the run lock across the whole read-modify-write of graph.json. Acquire
66
99
  // BEFORE loadGraph; release in the finally below (every exit path, incl. throws).
@@ -71,6 +104,18 @@ export async function runDaemon(repoRoot, opts = {}) {
71
104
  const lock = acquireRunLock(repoRoot, runId);
72
105
  try {
73
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);
74
119
  const journal = opts.resume ? Journal.open(repoRoot, runId, opts.narrate) : Journal.create(repoRoot, runId, opts.narrate);
75
120
  const branchEvent = opts.resume
76
121
  ? [...journal.read()].reverse().find((e) => (e.event === "run-start" || e.event === "run-end" || e.event === "merge") && typeof e.data.branch === "string")
@@ -130,7 +175,7 @@ export async function runDaemon(repoRoot, opts = {}) {
130
175
  baseRef = await gitHead(repoRoot);
131
176
  baseline = await captureBaseline(repoRoot, commands);
132
177
  writeFileSync(join(journal.dir, "baseline.json"), JSON.stringify(baseline, null, 2));
133
- 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
134
179
  }
135
180
  // T6: open the narrator AFTER run-start/run-resume is journaled so the watch surface has a run to
136
181
  // show. driver.narrator is undefined on subprocess → no-op (subprocess spawns nothing). Swallowed:
@@ -388,7 +433,7 @@ export async function runDaemon(repoRoot, opts = {}) {
388
433
  lastContextTokens = undefined;
389
434
  graph = setStatus(graph, t.id, "running");
390
435
  saveGraph(repoRoot, graph);
391
- 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 });
392
437
  const taskBase = await integrationHead(intWt); // deps are merged → visible to this task
393
438
  const taskBranch = `${branch}--${t.id}`; // "--": a ref can't nest under the existing integration branch (locked decision 10)
394
439
  const priorWt = worktreePath(repoRoot, taskBranch);
@@ -489,7 +534,9 @@ export async function runDaemon(repoRoot, opts = {}) {
489
534
  if (interactive) {
490
535
  // v1.2 interactive: the TUI doesn't exit on completion — the trailer is the finish line.
491
536
  // The exit wrapper still fires if the TUI dies (crash/quit): fast-fail instead of burning the timeout.
492
- await driver.run(slot, `${icmd}; ${exitMarkerCmd}`);
537
+ // T5: the pane announces itself first — same brand header (logo + dim identity) as the
538
+ // judge/review/consult dispatch scripts; one printf one-liner, dispatch mechanics unchanged.
539
+ await driver.run(slot, `${bannerShell()}; ${icmd}; ${exitMarkerCmd}`);
493
540
  let paged = false;
494
541
  // v1.22 T5 / OBS-19: auto-answer a fingerprint-matched trust dialog exactly once per slot.
495
542
  // Any other blocked/idle dialog still pages the operator (paged latch below).
@@ -576,7 +623,8 @@ export async function runDaemon(repoRoot, opts = {}) {
576
623
  }
577
624
  else {
578
625
  const inv = adapter.invoke(t, wt, assignment, { promptFile });
579
- await driver.run(slot, `${inv.command}; ${exitMarkerCmd}`);
626
+ // T5: same brand header as the interactive path and the gate/consult dispatch scripts.
627
+ await driver.run(slot, `${bannerShell()}; ${inv.command}; ${exitMarkerCmd}`);
580
628
  // OBS-54: headless workers have the same output-inactivity budget as visible panes.
581
629
  const stallWindowMs = taskTimeoutMinutes * 60_000;
582
630
  let lastOutput = await driver.read(slot, 500);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.49.0",
3
+ "version": "1.51.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",