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.
@@ -1,12 +1,32 @@
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
+ import { GLYPHS, dim, rule, title, warn } from "../../brand.js";
4
+ import { parseArgs } from "node:util";
3
5
  import { collateralLints } from "../../compile/collateral.js";
4
- import { loadConfig } from "../../config/config.js";
6
+ import { DEFAULT_CONFIG, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
5
7
  import { loadGraph } from "../../graph/graph.js";
8
+ import { resolveRunMode } from "../../run/daemon.js";
6
9
  import { excludedChannels, exclusionLine } from "../../route/preference.js";
7
- import { route, RoutingError } from "../../route/router.js";
10
+ import { staffLedEvidence } from "../../route/profile.js";
11
+ import { QUALITY_ENV, route, RoutingError } from "../../route/router.js";
8
12
  import { modelId } from "../../gates/review.js";
9
13
  import { loadRoutingProfile } from "../../run/journal.js";
14
+ // T4 (v1.50): TTY-only brand pass — the title helper frames the routing table, lint/unroutable
15
+ // markers carry the attention glyph, section labels dim to chrome (the doctor/status system).
16
+ // Gated on ttyVisual(): the non-TTY surface returns untouched (byte-pinned, machine-consumable).
17
+ const stylizePlan = (out) => {
18
+ if (!ttyVisual())
19
+ return out;
20
+ return out.split("\n").map((line, i) => {
21
+ if (i === 0)
22
+ return `${title(line)}\n${rule()}`;
23
+ if (/^(routing|context window|scope) lints:$/.test(line))
24
+ return dim(line);
25
+ return line
26
+ .replace(/^(\s+)! /, (_, s) => `${s}${warn(GLYPHS.attention)} `)
27
+ .replace(/^( \S+\s+\S+\s+)!! /, (_, p) => `${p}${warn("!!")} `);
28
+ }).join("\n");
29
+ };
10
30
  const fleetCanCrossVendorReview = (channels) => {
11
31
  for (let i = 0; i < channels.length; i++)
12
32
  for (let j = 0; j < channels.length; j++)
@@ -14,12 +34,19 @@ const fleetCanCrossVendorReview = (channels) => {
14
34
  return true;
15
35
  return false;
16
36
  };
17
- export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters()) {
37
+ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters()) {
18
38
  // ponytail: hardcoded 24h TTL — promote to config when an operator asks. mtime is the signal because
19
39
  // doctor.json has no probe timestamp and a schema field would break the existing-files compat invariant.
20
40
  const DOCTOR_STALE_MS = 24 * 60 * 60 * 1000;
21
- 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];
22
48
  const g = loadGraph(cwd);
49
+ const { cfg, mode, source } = resolveRunMode(cwd, { flag: values.mode, spec: g.mode });
23
50
  // readDoctor cache path: staleness line only fires here (probeAll fallback is fresh by construction).
24
51
  const cached = readDoctor(cwd);
25
52
  const health = cached ?? (await probeAll(adapters));
@@ -29,7 +56,17 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
29
56
  // output byte-identical to today.
30
57
  const profile = loadRoutingProfile(cwd, cfg, { preview: true });
31
58
  let deviations = 0;
32
- 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
+ };
33
70
  const excluded = excludedChannels(cfg, adapters, health);
34
71
  if (excluded.length)
35
72
  lines.push(exclusionLine(excluded), "");
@@ -61,7 +98,24 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
61
98
  return ` — ${m.key} is unauthed (${m.reason}, probed ${m.probedAt.split("T")[0]})`;
62
99
  return "";
63
100
  };
64
- 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
+ }
65
119
  for (const [role, sel] of [["judge", cfg.judge], ["consult", cfg.consult]]) {
66
120
  if (!health[sel.adapter]?.installed)
67
121
  lints.push(`${role}: ${sel.adapter}:${sel.model} not installed — that gate/consult will fail closed`);
@@ -83,6 +137,9 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
83
137
  const est = r.assignment.channel === "sub" ? 0 : cfg.pricing[r.assignment.tier];
84
138
  cost += est;
85
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);
86
143
  if (r.deviation) {
87
144
  deviations++;
88
145
  const d = r.deviation;
@@ -94,6 +151,9 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
94
151
  throw e;
95
152
  const msg = `${e.message}${exclusionReason(e.message)}`;
96
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);
97
157
  lints.push(`${t.id}: unroutable — ${msg}`);
98
158
  }
99
159
  }
@@ -114,5 +174,5 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
114
174
  const scopeLints = collateralLints(g.tasks, cwd);
115
175
  if (scopeLints.length)
116
176
  lines.push("", "scope lints:", ...scopeLints.map((l) => ` ! ${l}`));
117
- return lines.join("\n");
177
+ return stylizePlan(lines.join("\n"));
118
178
  }
@@ -1,5 +1,7 @@
1
1
  import { parseArgs } from "node:util";
2
+ import { ttyVisual } from "../../adapters/model-lints.js";
2
3
  import { addUsage } from "../../adapters/types.js";
4
+ import { dim, rule, title } from "../../brand.js";
3
5
  import { loadConfig } from "../../config/config.js";
4
6
  import { estimateCosts } from "../../report/cost.js";
5
7
  import { cellsOf, cellSummary } from "../../route/profile.js";
@@ -287,6 +289,16 @@ function textReport(runId, events, rows, cwd) {
287
289
  ` probes this run (route-deviation explore): ${probes}`,
288
290
  ].join("\n");
289
291
  }
292
+ // T4 (v1.50): TTY-only brand pass over the text report — title frame + dim section chrome; row
293
+ // text and alignment untouched (the doctor/status system). Gated on ttyVisual(): the non-TTY
294
+ // surface returns untouched, and --md never styles (the record is a document surface).
295
+ const stylizeReport = (out) => {
296
+ if (!ttyVisual())
297
+ return out;
298
+ return out
299
+ .replace(/^.*$/m, (first) => `${title(first)}\n${rule()}`) // non-global /m ⇒ first line only
300
+ .replace(/^(engagement summary — audit trail:|spend — tokens[^\n]*|spend — money:|learning \([^\n]*)$/gm, (l) => dim(l));
301
+ };
290
302
  export async function report(argv, cwd = process.cwd()) {
291
303
  const { values, positionals } = parseArgs({
292
304
  args: argv,
@@ -302,5 +314,5 @@ export async function report(argv, cwd = process.cwd()) {
302
314
  const rows = j.readTelemetry();
303
315
  return renderMarkdownRecord(runId, events, estimateCosts(rows, loadConfig(cwd).cost), rows);
304
316
  }
305
- return textReport(runId, events, j.readTelemetry(), cwd);
317
+ return stylizeReport(textReport(runId, events, j.readTelemetry(), cwd));
306
318
  }
@@ -1,3 +1,5 @@
1
+ import { type JournalEvent } from "../../run/journal.js";
2
+ export declare const narrationLine: (event: JournalEvent) => string;
1
3
  export declare function run(argv: string[], cwd?: string): Promise<{
2
4
  out: string;
3
5
  code: number;
@@ -1,13 +1,34 @@
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
+ import { ttyVisual } from "../../adapters/model-lints.js";
10
+ import { statusRow } from "../../brand.js";
11
+ // T4 (v1.50): lifecycle verdict glyphs on the live narration stream — glyph-first, message text
12
+ // unchanged (the doctor/status visual system). TTY-gated: the piped narration surface stays
13
+ // byte-identical to formatJournalNarration.
14
+ const NARRATION_VERDICTS = {
15
+ "task-dispatch": "neutral",
16
+ "task-done": "pass",
17
+ "task-failed": "fail",
18
+ "task-human": "warn",
19
+ };
20
+ export const narrationLine = (event) => {
21
+ const line = formatJournalNarration(event);
22
+ const verdict = NARRATION_VERDICTS[event.event];
23
+ return verdict !== undefined && ttyVisual() ? statusRow(verdict, line) : line;
24
+ };
9
25
  const summaryGreen = (s) => s.failed.length === 0 && s.human.length === 0 && s.blocked.length === 0 && s.pending.length === 0
10
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";
11
32
  export async function run(argv, cwd = process.cwd()) {
12
33
  const { values } = parseArgs({
13
34
  args: argv,
@@ -16,6 +37,7 @@ export async function run(argv, cwd = process.cwd()) {
16
37
  driver: { type: "string" },
17
38
  "route-strict": { type: "boolean" },
18
39
  "no-explore": { type: "boolean" },
40
+ mode: { type: "string" },
19
41
  quality: { type: "boolean" },
20
42
  },
21
43
  });
@@ -24,14 +46,28 @@ export async function run(argv, cwd = process.cwd()) {
24
46
  if (!Number.isInteger(n) || n <= 0)
25
47
  throw new Error(`--concurrency must be a positive integer (got ${values.concurrency})`);
26
48
  }
27
- const cfg = loadConfig(cwd);
28
- const quality = !!values.quality;
29
- const noExplore = !!values["no-explore"] || quality;
30
- 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;
31
69
  if (noExplore)
32
70
  process.env[NO_EXPLORE_ENV] = "1";
33
- if (quality)
34
- process.env[QUALITY_ENV] = "1";
35
71
  try {
36
72
  if (values["route-strict"]) {
37
73
  const adapters = allAdapters();
@@ -39,14 +75,15 @@ export async function run(argv, cwd = process.cwd()) {
39
75
  const channels = discoverChannels(cfg, adapters, health);
40
76
  // no preview: the strict pre-flight routes through exactly what the daemon will use (honors the switch)
41
77
  const profile = loadRoutingProfile(cwd, cfg);
42
- 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);
43
79
  if (lints.length)
44
80
  throw new Error(`--route-strict: routing lints present, refusing to dispatch:\n${lints.join("\n")}`);
45
81
  }
46
82
  const s = await runDaemon(cwd, {
47
83
  concurrency: values.concurrency ? Number(values.concurrency) : undefined,
48
84
  driver: pickDriver(cfg, values.driver),
49
- narrate: (event) => console.log(formatJournalNarration(event)),
85
+ mode: flagMode,
86
+ narrate: (event) => console.log(narrationLine(event)),
50
87
  });
51
88
  const out = `run ${s.runId} finished — ${formatSummary(s)} (merge to main is a human decision)`;
52
89
  return { out, code: summaryGreen(s) ? 0 : 2 };
@@ -54,7 +91,5 @@ export async function run(argv, cwd = process.cwd()) {
54
91
  finally {
55
92
  if (noExplore)
56
93
  delete process.env[NO_EXPLORE_ENV];
57
- if (quality)
58
- delete process.env[QUALITY_ENV];
59
94
  }
60
95
  }
@@ -1,4 +1,4 @@
1
- import { BANNER } from "../../brand.js";
1
+ import { BANNER, GLYPHS, dim, fail, legend, ok, rule, statusRow, title, warn } from "../../brand.js";
2
2
  import { blockedTasks, graphDefinitionHash, loadGraph, pendingTasks } from "../../graph/graph.js";
3
3
  import { GATE_NAMES } from "../../graph/schema.js";
4
4
  import { Journal, engagementComparable } from "../../run/journal.js";
@@ -19,17 +19,8 @@ const attemptStartIdx = (events, taskId) => {
19
19
  return idx;
20
20
  };
21
21
  const visual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
22
- const color = (text, code, enabled) => enabled ? `\x1b[${code}m${text}\x1b[0m` : text;
23
- const taskBox = (status, unicode) => {
24
- if (unicode) {
25
- if (status === "done")
26
- return "✓";
27
- if (status === "failed")
28
- return "✗";
29
- if (status === "human")
30
- return "⏸";
31
- return "☐";
32
- }
22
+ // non-TTY machine surface only the TTY frame draws task verdicts via statusRow
23
+ const taskBox = (status) => {
33
24
  if (status === "done")
34
25
  return "[x]";
35
26
  if (status === "failed" || status === "human")
@@ -37,8 +28,10 @@ const taskBox = (status, unicode) => {
37
28
  return "[ ]";
38
29
  };
39
30
  const gateBox = (state, unicode) => {
40
- if (unicode)
41
- return state === "pass" ? "✓" : state === "fail" ? "✗" : state === "skip" ? "·" : "☐";
31
+ if (unicode) {
32
+ // shared glyph vocabulary: pass/fail verdicts, dash for skip, dim circle for not-yet-run
33
+ return state === "pass" ? GLYPHS.pass : state === "fail" ? GLYPHS.fail : state === "skip" ? GLYPHS.neutral : GLYPHS.toggleInactive;
34
+ }
42
35
  return state === "pass" ? "[x]" : state === "fail" ? "[!]" : state === "skip" ? "." : "[ ]";
43
36
  };
44
37
  const defaultGateStates = (task) => GATE_NAMES.map((gate) => task.gates.includes(gate) ? "open" : "skip");
@@ -59,9 +52,12 @@ const gateStates = (task, events) => {
59
52
  }
60
53
  return GATE_NAMES.map((gate) => task.gates.includes(gate) ? outcomes.get(gate) ?? "open" : "skip");
61
54
  };
62
- // verdict semantics only: pass green, fail red, skip/open dim chrome — everything else stays quiet
63
- const GATE_STATE_COLOR = { pass: 32, fail: 31, skip: 2, open: 2 };
64
- const gateChain = (states, unicode) => GATE_NAMES.map((gate, i) => color(`${GATE_KEYS[gate]}${gateBox(states[i], unicode)}`, GATE_STATE_COLOR[states[i]], unicode)).join(" ");
55
+ // verdict semantics only: pass brand green, fail red, skip/open dim chrome — everything else stays quiet
56
+ const GATE_STATE_TOKEN = { pass: ok, fail, skip: dim, open: dim };
57
+ const gateChain = (states, unicode) => GATE_NAMES.map((gate, i) => {
58
+ const chip = `${GATE_KEYS[gate]}${gateBox(states[i], unicode)}`;
59
+ return unicode ? GATE_STATE_TOKEN[states[i]](chip) : chip;
60
+ }).join(" ");
65
61
  // plain (uncolored) chip width for column math — ANSI codes have zero display width
66
62
  const gateChainWidth = (unicode) => GATE_NAMES.reduce((w, gate) => w + GATE_KEYS[gate].length + (unicode ? 1 : 3) + 1, -1);
67
63
  const shortGoal = (goal, max) => {
@@ -159,61 +155,62 @@ const renderFrame = (cwd) => {
159
155
  const assignCol = contexts.has(t.id) ? `${channel}${divider}ctx ${contexts.get(t.id)}` : channel;
160
156
  return { t, st, label, assignCol, states: comparable ? gateStates(t, events) : defaultGateStates(t) };
161
157
  });
162
- const statusColor = (st) => st === "done" ? 32 : st === "failed" ? 31 : st === "human" ? 33 : 36;
163
158
  if (!unicode) {
164
159
  // machine/CI surface — layout unchanged (pipes, greps, and the golden pins depend on it)
165
160
  const rows = cells.map(({ t, st, label, assignCol, states }) => {
166
161
  const chain = gateChain(states, false);
167
- const prefix = ` ${taskBox(st, false)} ${t.id} `;
162
+ const prefix = ` ${taskBox(st)} ${t.id} `;
168
163
  const suffix = ` ${chain} ${String(st)}${label} ${assignCol}`;
169
164
  return `${prefix}${shortGoal(t.goal, Math.max(0, width - prefix.length - suffix.length))}${suffix}`;
170
165
  });
171
166
  const header = runId
172
167
  ? `tickmarkr status${divider}run ${runId}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events).replaceAll(" · ", divider)}${divider}${done}/${g.tasks.length} done`
173
168
  : `tickmarkr status${divider}no runs yet${divider}${done}/${g.tasks.length} done`;
174
- const legend = ` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(divider)}`;
175
- return [header, legend, ...rows].join("\n");
169
+ const legendLine = ` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(divider)}`;
170
+ return [header, legendLine, ...rows].join("\n");
176
171
  }
177
- // TTY: audit-ledger frame bold brand mark, dim chrome, semantic color only on verdicts,
178
- // completion gauge, and column-aligned rows (pad plain text FIRST, colorize after: ANSI has
179
- // zero display width and would corrupt padEnd math)
180
- const dim = (s) => color(s, 2, true);
172
+ // TTY: cockpit frame composed through src/brand.ts (CLI-DESIGN.md) dominant run title,
173
+ // dim chrome, semantic color only on verdicts, completion gauge, and column-aligned status
174
+ // rows (pad plain text FIRST, colorize after: ANSI has zero display width and would corrupt
175
+ // padEnd math)
181
176
  const dot = dim(" · ");
182
177
  const anyFailed = cells.some((c) => c.st === "failed");
183
178
  const gaugeCells = 10;
184
179
  const fill = g.tasks.length ? Math.round((done / g.tasks.length) * gaugeCells) : 0;
185
- const gauge = (fill ? color("█".repeat(fill), anyFailed ? 31 : 32, true) : "") + (fill < gaugeCells ? dim("░".repeat(gaugeCells - fill)) : "");
180
+ const gauge = (fill ? (anyFailed ? fail : ok)("█".repeat(fill)) : "") + (fill < gaugeCells ? dim("░".repeat(gaugeCells - fill)) : "");
186
181
  const live = liveness(events)
187
- .replace(/\bdead\b/, color("dead", 31, true))
188
- .replace(/\bfinished\b/, color("finished", 2, true))
189
- .replace(/\balive\b/, color("alive", 32, true))
182
+ .replace(/\bdead\b/, fail("dead"))
183
+ .replace(/\bfinished\b/, dim("finished"))
184
+ .replace(/\balive\b/, ok("alive"))
190
185
  .replaceAll(" · ", dot);
191
186
  const tally = `${done}/${g.tasks.length} done`;
192
- const header = ` ${color("✓", 32, true)} ${color("tickmarkr", 1, true)}${dot}` +
187
+ const header = ` ${title(runId ? `run ${runId}` : "tickmarkr")}${dot}` +
193
188
  (runId
194
- ? `run ${runId}${dot}${!comparable ? `${NOT_COMPARABLE_NOTICE}${dot}` : ""}${live}${dot}`
189
+ ? `${!comparable ? `${warn(NOT_COMPARABLE_NOTICE)}${dot}` : ""}${live}${dot}`
195
190
  : `no runs yet${dot}`) +
196
- `${gauge} ${done === g.tasks.length && g.tasks.length > 0 ? color(tally, 32, true) : tally}`;
197
- const rule = dim("─".repeat(Math.min(width, 100)));
198
- const legend = dim(` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(" · ")}`);
191
+ `${gauge} ${done === g.tasks.length && g.tasks.length > 0 ? ok(tally) : tally}`;
192
+ const hr = rule(Math.min(width, 100));
193
+ const gatesLegend = legend(` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(" · ")}`);
194
+ const taskVerdict = (st) => st === "done" ? "pass" : st === "failed" ? "fail" : st === "human" ? "warn" : "neutral";
199
195
  const idW = Math.max(...cells.map((c) => c.t.id.length), 2);
200
196
  const stW = Math.max(...cells.map((c) => (String(c.st) + c.label).length));
201
197
  const chainW = gateChainWidth(true);
202
198
  const assignW = Math.max(...cells.map((c) => c.assignCol.length));
203
199
  const goalW = Math.max(8, width - (5 + idW) - 2 - chainW - 2 - stW - 2 - assignW);
204
200
  const rows = cells.map(({ t, st, label, assignCol, states }) => {
205
- const box = color(taskBox(st, true), st === "done" ? 32 : st === "failed" ? 31 : st === "human" ? 33 : 2, true);
206
201
  const goal = shortGoal(t.goal, goalW).padEnd(goalW);
207
- const statusCell = color(String(st), statusColor(st), true) +
208
- (label ? (label === " starved" ? color(label, 31, true) : dim(label)) : "") +
202
+ const stWord = st === "done" ? ok(String(st)) : st === "failed" ? fail(String(st)) : st === "human" ? warn(String(st)) : String(st);
203
+ const statusCell = stWord +
204
+ (label ? (label === " starved" ? fail(label) : dim(label)) : "") +
209
205
  " ".repeat(stW - (String(st) + label).length);
210
- return ` ${box} ${t.id.padEnd(idW)} ${goal} ${gateChain(states, true)} ${statusCell} ${dim(assignCol)}`;
206
+ return ` ${statusRow(taskVerdict(st), `${t.id.padEnd(idW)} ${goal} ${gateChain(states, true)} ${statusCell} ${dim(assignCol)}`)}`;
211
207
  });
212
- return [header, rule, legend, ...rows].join("\n");
208
+ return [header, hr, gatesLegend, ...rows].join("\n");
213
209
  };
214
210
  export async function status(argv, cwd = process.cwd(), opts = {}) {
211
+ // cockpit surface: banner + frame on a TTY (doctor's pattern); pipes get the bare frame
215
212
  if (!argv.includes("--watch"))
216
- return renderFrame(cwd);
213
+ return visual() ? BANNER + renderFrame(cwd) : renderFrame(cwd);
217
214
  const iterations = opts.iterations ?? Infinity;
218
215
  const sleep = opts.sleep ?? defaultSleep;
219
216
  const bounded = Number.isFinite(iterations);
@@ -223,7 +220,7 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
223
220
  for (let i = 0; i < iterations; i++) {
224
221
  const frame = renderFrame(cwd);
225
222
  if (tty)
226
- process.stdout.write(`\x1b[2J\x1b[H${BANNER}${frame}\n\x1b[2m watching · refresh ${REFRESH_MS / 1000}s · ^C to quit\x1b[0m`);
223
+ process.stdout.write(`\x1b[2J\x1b[H${BANNER}${frame}\n${legend(` watching · refresh ${REFRESH_MS / 1000}s · ^C to quit`)}`);
227
224
  else
228
225
  process.stdout.write(frame + sep);
229
226
  if (bounded)
@@ -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;