tickmarkr 1.72.0 → 1.73.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.
Files changed (47) hide show
  1. package/dist/cli/commands/approve.js +21 -3
  2. package/dist/cli/commands/resume.js +10 -2
  3. package/dist/cli/commands/status.d.ts +2 -0
  4. package/dist/cli/commands/status.js +219 -30
  5. package/dist/cli/commands/ui.d.ts +6 -2
  6. package/dist/cli/commands/ui.js +2 -4
  7. package/dist/config/config.d.ts +4 -0
  8. package/dist/config/config.js +6 -1
  9. package/dist/drivers/herdr.d.ts +4 -0
  10. package/dist/drivers/herdr.js +72 -8
  11. package/dist/drivers/subprocess.d.ts +3 -3
  12. package/dist/drivers/subprocess.js +10 -5
  13. package/dist/route/preference.d.ts +2 -1
  14. package/dist/route/preference.js +8 -2
  15. package/dist/run/daemon.d.ts +1 -0
  16. package/dist/run/daemon.js +139 -4
  17. package/dist/run/git.d.ts +2 -0
  18. package/dist/run/git.js +55 -0
  19. package/dist/run/journal.d.ts +6 -1
  20. package/dist/run/journal.js +28 -1
  21. package/dist/tui/ink/components.d.ts +18 -0
  22. package/dist/tui/ink/components.js +30 -2
  23. package/dist/tui/ink/studio-app.d.ts +59 -0
  24. package/dist/tui/ink/studio-app.js +320 -0
  25. package/package.json +1 -1
  26. package/dist/tui/app.d.ts +0 -72
  27. package/dist/tui/app.js +0 -334
  28. package/dist/tui/engine.d.ts +0 -52
  29. package/dist/tui/engine.js +0 -92
  30. package/dist/tui/frame.d.ts +0 -22
  31. package/dist/tui/frame.js +0 -56
  32. package/dist/tui/input.d.ts +0 -41
  33. package/dist/tui/input.js +0 -100
  34. package/dist/tui/views/consult-dossier.d.ts +0 -49
  35. package/dist/tui/views/consult-dossier.js +0 -169
  36. package/dist/tui/views/diff-modal.d.ts +0 -9
  37. package/dist/tui/views/diff-modal.js +0 -19
  38. package/dist/tui/views/fleet-view.d.ts +0 -91
  39. package/dist/tui/views/fleet-view.js +0 -337
  40. package/dist/tui/views/preview-view.d.ts +0 -7
  41. package/dist/tui/views/preview-view.js +0 -80
  42. package/dist/tui/views/profile-view.d.ts +0 -15
  43. package/dist/tui/views/profile-view.js +0 -93
  44. package/dist/tui/views/routing-view.d.ts +0 -48
  45. package/dist/tui/views/routing-view.js +0 -234
  46. package/dist/tui/views/runs-view.d.ts +0 -70
  47. package/dist/tui/views/runs-view.js +0 -387
@@ -1,5 +1,6 @@
1
1
  import { userInfo } from "node:os";
2
- import { ATTEMPT_CAP_RELEASE, Journal } from "../../run/journal.js";
2
+ import { GATE_NAMES } from "../../graph/schema.js";
3
+ import { ATTEMPT_CAP_RELEASE, GATE_SATISFIED_RELEASE, Journal } from "../../run/journal.js";
3
4
  // GATE-08 (v1.12): approve a parked human gate so the next `tickmarkr resume <runId>` dispatches it.
4
5
  //
5
6
  // The approval is a JOURNAL EVENT (task-approved) carrying who and when — it touches ONLY the
@@ -31,15 +32,32 @@ export async function approve(argv, cwd = process.cwd()) {
31
32
  }
32
33
  // OBS-18: only the most recent task-human for this task decides whether this approval grants a
33
34
  // fresh attempt budget. The closed daemon-issued kind, never a human prose string, controls release.
34
- const lastHuman = journal.read().filter((e) => e.event === "task-human" && e.taskId === taskId).at(-1);
35
+ const events = journal.read();
36
+ let lastHumanIndex = -1;
37
+ for (let i = events.length - 1; i >= 0; i--) {
38
+ if (events[i].event === "task-human" && events[i].taskId === taskId) {
39
+ lastHumanIndex = i;
40
+ break;
41
+ }
42
+ }
43
+ const lastHuman = events[lastHumanIndex];
35
44
  const capPark = lastHuman?.data.kind === ATTEMPT_CAP_RELEASE;
45
+ const gateFailPark = lastHuman?.data.kind === "gate-fail";
46
+ const failedGate = gateFailPark
47
+ ? events.slice(0, lastHumanIndex).reverse().find((e) => e.event === "gate-result" && e.taskId === taskId && e.data.pass === false
48
+ && typeof e.data.gate === "string" && GATE_NAMES.includes(e.data.gate))?.data.gate
49
+ : undefined;
50
+ if (gateFailPark && !failedGate) {
51
+ throw new Error(`task ${taskId} is parked on gate-fail but has no failed gate result — refusing to infer one`);
52
+ }
36
53
  journal.append("task-approved", taskId, {
37
54
  by,
38
55
  ...(reason ? { reason } : {}),
39
56
  via: "cli",
40
57
  ...(capPark ? { release: ATTEMPT_CAP_RELEASE } : {}),
58
+ ...(failedGate ? { release: GATE_SATISFIED_RELEASE, gate: failedGate } : {}),
41
59
  });
42
- return `approved ${taskId} in ${runId} — by ${by}; run \`tickmarkr resume ${runId}\` to dispatch it`;
60
+ return `approved ${taskId} in ${runId} — by ${by}; run \`tickmarkr resume ${runId}\` to ${failedGate ? "continue past the approved gate" : "dispatch it"}`;
43
61
  }
44
62
  // hand-parsed argv — no CLI framework (house style). Flags --by <name> and --reason <text>; positionals
45
63
  // are runId then taskId. Throws usage on missing positionals (mirrors resume.ts/unlock.ts).
@@ -8,17 +8,25 @@ const summaryGreen = (s) => s.failed.length === 0 && s.human.length === 0 && s.b
8
8
  export async function resume(argv, cwd = process.cwd()) {
9
9
  const runId = argv[0];
10
10
  if (!runId)
11
- throw new Error("usage: tickmarkr resume <run-id>");
11
+ throw new Error("usage: tickmarkr resume <run-id> [--graph-changed] [--retry-failed]");
12
12
  // T3: --graph-changed is the operator's audited release of the engagement-identity guard (Sol #2 /
13
13
  // Fable F2) — the daemon refuses a mismatched/unbound journal unless this is set, then journals a
14
14
  // graph-rehash event naming both hashes. Strip the flag before runId resolution so a bare id still wins.
15
15
  const graphChanged = argv.includes("--graph-changed");
16
+ const retryFailed = argv.includes("--retry-failed");
16
17
  const cfg = loadConfig(cwd);
17
18
  const collisions = denyPreferCollisions(cfg);
18
19
  if (collisions.length) {
19
20
  throw new Error(collisions.map(denyPreferCollisionLine).join("; "));
20
21
  }
21
- const s = await runDaemon(cwd, { runId, resume: true, graphChanged, driver: pickDriver(cfg), narrate: (event) => console.log(formatJournalNarration(event)) });
22
+ const s = await runDaemon(cwd, {
23
+ runId,
24
+ resume: true,
25
+ graphChanged,
26
+ retryFailed,
27
+ driver: pickDriver(cfg),
28
+ narrate: (event) => console.log(formatJournalNarration(event)),
29
+ });
22
30
  const out = `resumed ${s.runId} — ${formatSummary(s)}`;
23
31
  return { out, code: summaryGreen(s) ? 0 : 2 };
24
32
  }
@@ -3,6 +3,8 @@ import { type JournalEvent } from "../../run/journal.js";
3
3
  export type StatusOpts = {
4
4
  iterations?: number;
5
5
  sleep?: (ms: number) => Promise<void>;
6
+ now?: () => number;
7
+ readWorkerOutput?: (taskId: string, attempt: number, runId: string) => Promise<string | undefined>;
6
8
  };
7
9
  export declare const GATE_KEYS: {
8
10
  readonly build: "B";
@@ -1,8 +1,11 @@
1
1
  import { BANNER, GLYPHS, dim, fail, legend, ok, rule, statusRow, title, warn } from "../../brand.js";
2
+ import { HerdrDriver } from "../../drivers/herdr.js";
3
+ import { formatOwnedName } from "../../drivers/types.js";
2
4
  import { blockedTasks, graphDefinitionHash, loadGraph } from "../../graph/graph.js";
3
5
  import { GATE_NAMES } from "../../graph/schema.js";
4
6
  import { foldActivity } from "../../run/activity.js";
5
7
  import { Journal, engagementComparable, isQualityFailureParkKind, recordedTaskFailureKind, runHasEnded, } from "../../run/journal.js";
8
+ import { normalizeStallSnapshot } from "../../run/stall.js";
6
9
  // ponytail: fixed 2s refresh; promote to config.visibility.* only when an operator asks.
7
10
  const REFRESH_MS = 2000;
8
11
  const NOT_COMPARABLE_NOTICE = "graph recompiled since this run — task states not comparable; run `tickmarkr run` to execute";
@@ -10,6 +13,87 @@ const NOT_COMPARABLE_NOTICE = "graph recompiled since this run — task states n
10
13
  // first frame, so a live `--watch` printed once and exited 0 (OBS-11). Never unref this.
11
14
  const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
12
15
  export const GATE_KEYS = { build: "B", test: "T", lint: "L", evidence: "E", scope: "S", acceptance: "A", review: "R" };
16
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
17
+ const ASCII_SPINNER = ["|", "/", "-", "\\"];
18
+ const SAVE_TERMINAL_TITLE = "\x1b[22;0t";
19
+ const RESTORE_TERMINAL_TITLE = "\x1b[23;0t";
20
+ const taskPhase = (value) => {
21
+ if (value === "worker" || value === "gates" || value === "judge" || value === "review" || value === "merge")
22
+ return value;
23
+ if (typeof value !== "string" || !value.startsWith("gate:"))
24
+ return undefined;
25
+ return GATE_NAMES.includes(value.slice("gate:".length)) ? value : undefined;
26
+ };
27
+ const phaseGate = (phase) => {
28
+ if (phase === "judge")
29
+ return "acceptance";
30
+ if (phase === "review")
31
+ return "review";
32
+ return phase.startsWith("gate:") ? phase.slice("gate:".length) : undefined;
33
+ };
34
+ // A phase is live only between its append-only start marker and the matching real outcome. A newer
35
+ // start for the same task supersedes the prior phase (gates → named gate, worker retry → worker).
36
+ // No clock participates in this fold: time only decorates the derived live interval at render time.
37
+ const livePhases = (events) => {
38
+ const live = new Map();
39
+ for (let order = 0; order < events.length; order++) {
40
+ const event = events[order];
41
+ if (event.event === "run-start" || event.event === "run-resume" || event.event === "run-end" || event.event === "superseded") {
42
+ live.clear();
43
+ continue;
44
+ }
45
+ if (event.event === "phase-start" && event.taskId) {
46
+ const phase = taskPhase(event.data.phase);
47
+ const startedAt = Date.parse(event.ts);
48
+ const attempt = typeof event.data.attempt === "number" && Number.isInteger(event.data.attempt) && event.data.attempt >= 0
49
+ ? event.data.attempt
50
+ : 0;
51
+ if (phase && Number.isFinite(startedAt))
52
+ live.set(event.taskId, { taskId: event.taskId, phase, startedAt, order, attempt });
53
+ continue;
54
+ }
55
+ if (!event.taskId)
56
+ continue;
57
+ const active = live.get(event.taskId);
58
+ if (!active)
59
+ continue;
60
+ const gate = phaseGate(active.phase);
61
+ const matched = (active.phase === "worker" && event.event === "worker-result")
62
+ || (active.phase === "gates" && event.event === "gate-result")
63
+ || (gate !== undefined && event.event === "gate-result" && event.data.gate === gate)
64
+ || (active.phase === "merge" && event.event === "merge")
65
+ || event.event === "task-done"
66
+ || event.event === "task-failed"
67
+ || event.event === "task-human"
68
+ || event.event === "task-approved"
69
+ || event.event === "task-dispatch";
70
+ if (matched)
71
+ live.delete(event.taskId);
72
+ }
73
+ return live;
74
+ };
75
+ const phaseLabel = (phase) => phase.startsWith("gate:") ? `gate ${phase.slice("gate:".length)}` : phase;
76
+ const fmtElapsed = (ms) => {
77
+ const seconds = Math.max(0, Math.floor(ms / 1000));
78
+ if (seconds < 60)
79
+ return `${seconds}s`;
80
+ const minutes = Math.floor(seconds / 60);
81
+ if (minutes < 60)
82
+ return `${minutes}m${seconds % 60}s`;
83
+ const hours = Math.floor(minutes / 60);
84
+ return `${hours}h${minutes % 60}m${seconds % 60}s`;
85
+ };
86
+ const workerOutputAge = (phase, now, worker) => now - (worker?.phaseStartedAt === phase.startedAt && worker.hasOutput ? worker.lastOutputAt : phase.startedAt);
87
+ const phaseDetail = (phase, now, worker) => {
88
+ const elapsed = fmtElapsed(now - phase.startedAt);
89
+ if (phase.phase !== "worker")
90
+ return `${phaseLabel(phase.phase)} · ${elapsed} elapsed`;
91
+ const age = fmtElapsed(workerOutputAge(phase, now, worker));
92
+ const output = worker?.phaseStartedAt === phase.startedAt && worker.hasOutput
93
+ ? `last output ${age} ago`
94
+ : `no output ${age}`;
95
+ return `${phaseLabel(phase.phase)} · ${elapsed} elapsed · ${output}`;
96
+ };
13
97
  const attemptStartIdx = (events, taskId) => {
14
98
  let idx = -1;
15
99
  for (let i = 0; i < events.length; i++) {
@@ -114,11 +198,11 @@ const terminalFailureCause = (events) => {
114
198
  }
115
199
  return undefined;
116
200
  };
117
- const liveness = (events) => {
201
+ const liveness = (events, now = Date.now()) => {
118
202
  const last = events.at(-1);
119
203
  if (!last)
120
204
  return "last event unknown · daemon pid unknown";
121
- const age = fmtAge(Date.now() - Date.parse(last.ts));
205
+ const age = fmtAge(now - Date.parse(last.ts));
122
206
  const pid = daemonPid(events);
123
207
  const cause = terminalFailureCause(events);
124
208
  if (pid === undefined)
@@ -136,7 +220,7 @@ const liveness = (events) => {
136
220
  } // EPERM ⇒ alive
137
221
  return `last event ${age} ago · daemon pid ${pid} ${state}${cause ? ` · ${cause}` : ""}`;
138
222
  };
139
- const renderFrame = (cwd) => {
223
+ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness = new Map()) => {
140
224
  const g = loadGraph(cwd);
141
225
  const runId = Journal.latestRunId(cwd, { withJournal: true });
142
226
  const assignments = new Map();
@@ -179,6 +263,12 @@ const renderFrame = (cwd) => {
179
263
  const width = process.stdout.columns ?? 120;
180
264
  const done = effective.tasks.filter((t) => t.status === "done").length;
181
265
  const ended = comparable && runHasEnded(events);
266
+ const taskIds = new Set(g.tasks.map((task) => task.id));
267
+ const phases = comparable ? livePhases(events) : new Map();
268
+ for (const taskId of phases.keys())
269
+ if (!taskIds.has(taskId))
270
+ phases.delete(taskId);
271
+ const hotPhase = [...phases.values()].sort((a, b) => a.order - b.order).at(-1);
182
272
  const cells = g.tasks.map((t) => {
183
273
  const st = replayed?.get(t.id) ?? t.status;
184
274
  const failureKind = comparable ? recordedTaskFailureKind(events, t.id) : undefined;
@@ -188,27 +278,34 @@ const renderFrame = (cwd) => {
188
278
  const redTier = terminal && (ended
189
279
  || (failureKind !== undefined && isQualityFailureParkKind(failureKind))
190
280
  || (st === "failed" && failureKind === undefined));
191
- const isStarved = starved.has(t.id);
192
- const phrase = isStarved ? undefined : activity.cells.get(t.id);
281
+ const livePhase = phases.get(t.id);
282
+ const isStarved = !livePhase && starved.has(t.id);
283
+ const phrase = livePhase ? phaseDetail(livePhase, now, workerLiveness.get(t.id)) : isStarved ? undefined : activity.cells.get(t.id);
193
284
  const label = isStarved ? " starved" : phrase ? ` ${phrase}` : "";
194
285
  const channel = assignments.get(t.id) ?? "-";
195
286
  const ctx = contexts.get(t.id);
196
287
  const assignCol = ctx !== undefined ? `${channel}${divider}ctx ${ctx}` : channel;
197
- return { t, st, failureKind, redTier, label, assignCol, isStarved, phrase, channel, ctx, states: comparable ? gateStates(t, events) : defaultGateStates(t) };
288
+ return { t, st, failureKind, redTier, label, assignCol, isStarved, phrase, channel, ctx, livePhase, states: comparable ? gateStates(t, events) : defaultGateStates(t) };
198
289
  });
199
290
  if (!unicode) {
200
- // machine/CI surface — layout unchanged (pipes, greps, and the golden pins depend on it)
201
- const rows = cells.map(({ t, st, label, assignCol, states }) => {
291
+ // machine/CI surface — journals without phase-start stay byte-identical; new phase-aware frames
292
+ // use an ASCII spinner so pipes never receive terminal-only braille/ANSI.
293
+ const rows = cells.map(({ t, st, label, assignCol, livePhase, states }) => {
202
294
  const chain = gateChain(states, false);
203
- const prefix = ` ${taskBox(st)} ${t.id} `;
204
- const suffix = ` ${chain} ${String(st)}${label} ${assignCol}`;
295
+ const prefix = livePhase ? ` ${ASCII_SPINNER[animationFrame % ASCII_SPINNER.length]} ${t.id} ` : ` ${taskBox(st)} ${t.id} `;
296
+ const suffix = ` ${chain} ${livePhase ? "running" : String(st)}${label} ${assignCol}`;
205
297
  return `${prefix}${shortGoal(t.goal, Math.max(0, width - prefix.length - suffix.length))}${suffix}`;
206
298
  });
207
299
  const header = runId
208
- ? `tickmarkr status${divider}run ${runId}${supersededBy ? `${divider}superseded by ${supersededBy}` : ""}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events).replaceAll(" · ", divider)}${divider}${done}/${g.tasks.length} done`
300
+ ? `tickmarkr status${divider}run ${runId}${supersededBy ? `${divider}superseded by ${supersededBy}` : ""}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events, now).replaceAll(" · ", divider)}${divider}${done}/${g.tasks.length} done`
209
301
  : `tickmarkr status${divider}no runs yet${divider}${done}/${g.tasks.length} done`;
210
302
  const legendLine = ` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(divider)}`;
211
- return [header, legendLine, ...rows].join("\n");
303
+ return {
304
+ content: [header, legendLine, ...rows].join("\n"),
305
+ ...(hotPhase ? { hotPhase } : {}),
306
+ ...(runId ? { runId } : {}),
307
+ workerPhases: [...phases.values()].filter((phase) => phase.phase === "worker"),
308
+ };
212
309
  }
213
310
  // TTY: cockpit frame composed through src/brand.ts (CLI-DESIGN.md) — dominant run title,
214
311
  // dim chrome, semantic color only on verdicts, completion gauge, and column-aligned status
@@ -219,7 +316,7 @@ const renderFrame = (cwd) => {
219
316
  const gaugeCells = 10;
220
317
  const fill = g.tasks.length ? Math.round((done / g.tasks.length) * gaugeCells) : 0;
221
318
  const gauge = (fill ? (anyFailed ? fail : ok)("█".repeat(fill)) : "") + (fill < gaugeCells ? dim("░".repeat(gaugeCells - fill)) : "");
222
- const live = liveness(events)
319
+ const live = liveness(events, now)
223
320
  .replace(/\bdead\b/, fail("dead"))
224
321
  .replace(/\bfinished\b/, dim("finished"))
225
322
  .replace(/\balive\b/, ok("alive"))
@@ -241,7 +338,7 @@ const renderFrame = (cwd) => {
241
338
  // aligned under the goal: gate chain, live activity phrase (or channel), ctx. Long channel names
242
339
  // and activity phrases live on line 2 only, so they can never squeeze the goal or wrap line 1.
243
340
  const taskVerdict = (c) => c.st === "done" ? "pass" : c.redTier ? "fail" : c.st === "failed" || c.st === "human" ? "warn" : "neutral";
244
- const statusWord = (c) => c.redTier ? "failed" : c.st === "failed" ? "warn" : String(c.st);
341
+ const statusWord = (c) => c.livePhase ? "running" : c.redTier ? "failed" : c.st === "failed" ? "warn" : String(c.st);
245
342
  const idW = Math.max(...cells.map((c) => c.t.id.length), 2);
246
343
  // plain-text status suffixes for width math only — starved / failed gate names / approval hint
247
344
  const suffixPlain = (c) => (c.isStarved ? " · starved" : "") + failedSuffix(c.states) + humanGateSuffix(c.t, c.st, c.states);
@@ -251,9 +348,11 @@ const renderFrame = (cwd) => {
251
348
  const goalW = Math.max(8, ...goals.map((s) => s.length));
252
349
  const indent = " ".repeat(idW + 5); // line 2 starts under the goal column
253
350
  const rows = cells.map((c, i) => {
254
- const { t, st, failureKind, redTier, states, isStarved, phrase, channel, ctx } = c;
351
+ const { t, st, failureKind, redTier, states, isStarved, phrase, channel, ctx, livePhase } = c;
255
352
  const word = statusWord(c);
256
- const stWord = st === "done" ? ok(word) : redTier ? fail(word) : st === "failed" || st === "human" ? warn(word) : word;
353
+ const staleWorker = livePhase?.phase === "worker"
354
+ && workerOutputAge(livePhase, now, workerLiveness.get(t.id)) >= 60_000;
355
+ const stWord = staleWorker ? warn(word) : st === "done" ? ok(word) : redTier ? fail(word) : st === "failed" || st === "human" ? warn(word) : word;
257
356
  // a fail names its gate in words right here — the one moment gate identity is needed on a row
258
357
  const f = failedGates(states);
259
358
  const human = humanGateSuffix(t, st, states);
@@ -261,7 +360,10 @@ const renderFrame = (cwd) => {
261
360
  (isStarved ? dot + fail("starved") : "") +
262
361
  (f.length ? dot + fail(f.join(", ")) : "") +
263
362
  (human ? dot + warn("awaiting approval") : "");
264
- const line1 = ` ${statusRow(taskVerdict(c), `${t.id.padEnd(idW)} ${goals[i].padEnd(goalW)} ${statusCell}`)}`;
363
+ const taskLabel = `${t.id.padEnd(idW)} ${goals[i].padEnd(goalW)} ${statusCell}`;
364
+ const line1 = livePhase
365
+ ? ` ${(staleWorker ? warn : dim)(SPINNER[animationFrame % SPINNER.length])} ${taskLabel}`
366
+ : ` ${statusRow(taskVerdict(c), taskLabel)}`;
265
367
  // activity already names its channel for in-flight attempts — never repeat it
266
368
  const detail = [
267
369
  ...(phrase ? [phrase, ...(phrase.includes(channel) || channel === "-" ? [] : [channel])] : [channel]),
@@ -272,28 +374,115 @@ const renderFrame = (cwd) => {
272
374
  }).flat();
273
375
  if (!nowLine.length)
274
376
  rows.pop(); // cards end blank-separated; drop the dangling one
275
- return [header, hr, gatesLegend, "", ...rows, ...nowLine].join("\n");
377
+ return {
378
+ content: [header, hr, gatesLegend, "", ...rows, ...nowLine].join("\n"),
379
+ ...(hotPhase ? { hotPhase } : {}),
380
+ ...(runId ? { runId } : {}),
381
+ workerPhases: [...phases.values()].filter((phase) => phase.phase === "worker"),
382
+ };
276
383
  };
277
384
  export async function status(argv, cwd = process.cwd(), opts = {}) {
278
385
  // cockpit surface: banner + frame on a TTY (doctor's pattern); pipes get the bare frame
279
- if (!argv.includes("--watch"))
280
- return visual() ? BANNER + renderFrame(cwd) : renderFrame(cwd);
386
+ if (!argv.includes("--watch")) {
387
+ const { content } = renderFrame(cwd, opts.now?.() ?? Date.now());
388
+ return visual() ? BANNER + content : content;
389
+ }
281
390
  const iterations = opts.iterations ?? Infinity;
282
391
  const sleep = opts.sleep ?? defaultSleep;
392
+ const now = opts.now ?? Date.now;
283
393
  const bounded = Number.isFinite(iterations);
284
394
  const frames = [];
285
395
  const sep = "\n---\n";
286
396
  const tty = visual();
287
- for (let i = 0; i < iterations; i++) {
288
- const frame = renderFrame(cwd);
289
- if (tty)
290
- process.stdout.write(`\x1b[2J\x1b[H${BANNER}${frame}\n${legend(` watching · refresh ${REFRESH_MS / 1000}s · ^C to quit`)}`);
291
- else
292
- process.stdout.write(frame + sep);
293
- if (bounded)
294
- frames.push(frame);
295
- if (i + 1 < iterations)
296
- await sleep(REFRESH_MS);
397
+ const workerLiveness = new Map();
398
+ const herdr = HerdrDriver.available() ? new HerdrDriver() : undefined;
399
+ const readWorkerOutput = opts.readWorkerOutput ?? (herdr
400
+ ? async (taskId, attempt, runId) => {
401
+ const name = formatOwnedName({ role: "worker", taskId, attempt, runId });
402
+ try {
403
+ return await herdr.read({ id: name, name, cwd }, 80);
404
+ }
405
+ catch {
406
+ return undefined;
407
+ }
408
+ }
409
+ : undefined);
410
+ const observeWorkerOutput = async (active, runId, observedAt) => {
411
+ const activeIds = new Set(active.map((phase) => phase.taskId));
412
+ for (const taskId of workerLiveness.keys())
413
+ if (!activeIds.has(taskId))
414
+ workerLiveness.delete(taskId);
415
+ if (!readWorkerOutput || !runId)
416
+ return;
417
+ await Promise.all(active.map(async (phase) => {
418
+ const output = await readWorkerOutput(phase.taskId, phase.attempt, runId);
419
+ if (output === undefined)
420
+ return;
421
+ const snapshot = normalizeStallSnapshot(output);
422
+ const prior = workerLiveness.get(phase.taskId);
423
+ if (!prior || prior.phaseStartedAt !== phase.startedAt) {
424
+ const hasOutput = snapshot.trim().length > 0;
425
+ workerLiveness.set(phase.taskId, {
426
+ phaseStartedAt: phase.startedAt,
427
+ snapshot,
428
+ hasOutput,
429
+ lastOutputAt: hasOutput ? observedAt : phase.startedAt,
430
+ });
431
+ return;
432
+ }
433
+ if (snapshot !== prior.snapshot && snapshot.trim().length > 0) {
434
+ prior.hasOutput = true;
435
+ prior.lastOutputAt = observedAt;
436
+ }
437
+ prior.snapshot = snapshot;
438
+ }));
439
+ };
440
+ let titleSaved = false;
441
+ const restoreTitle = () => {
442
+ if (!titleSaved)
443
+ return;
444
+ titleSaved = false;
445
+ process.stdout.write(RESTORE_TERMINAL_TITLE);
446
+ };
447
+ const updateTitle = (hotPhase, nowMs) => {
448
+ if (!hotPhase) {
449
+ if (titleSaved) {
450
+ process.removeListener("exit", restoreTitle);
451
+ restoreTitle();
452
+ }
453
+ return;
454
+ }
455
+ if (!titleSaved) {
456
+ process.stdout.write(SAVE_TERMINAL_TITLE);
457
+ titleSaved = true;
458
+ process.once("exit", restoreTitle);
459
+ }
460
+ process.stdout.write(`\x1b]0;⏳ ${hotPhase.taskId} ${phaseLabel(hotPhase.phase)} ${fmtElapsed(nowMs - hotPhase.startedAt)}\x07`);
461
+ };
462
+ try {
463
+ for (let i = 0; i < iterations; i++) {
464
+ const nowMs = now();
465
+ const frame = renderFrame(cwd, nowMs, i, workerLiveness);
466
+ if (tty) {
467
+ updateTitle(frame.hotPhase, nowMs);
468
+ process.stdout.write(`\x1b[2J\x1b[H${BANNER}${frame.content}\n${legend(` watching · refresh ${REFRESH_MS / 1000}s · ^C to quit`)}`);
469
+ }
470
+ else {
471
+ process.stdout.write(frame.content + sep);
472
+ }
473
+ if (bounded)
474
+ frames.push(frame.content);
475
+ if (i + 1 < iterations) {
476
+ await observeWorkerOutput(frame.workerPhases, frame.runId, nowMs);
477
+ await sleep(REFRESH_MS);
478
+ }
479
+ }
480
+ }
481
+ finally {
482
+ if (titleSaved) {
483
+ process.removeListener("exit", restoreTitle);
484
+ restoreTitle();
485
+ }
297
486
  }
298
487
  return bounded ? frames.join(sep) : "";
299
488
  }
@@ -1,5 +1,9 @@
1
- import { type StudioOptions } from "../../tui/app.js";
2
- export declare function ui(_argv: string[], io?: Partial<StudioOptions>): Promise<string | {
1
+ type StudioIO = {
2
+ input: NodeJS.ReadStream;
3
+ output: NodeJS.WriteStream;
4
+ };
5
+ export declare function ui(_argv: string[], io?: Partial<StudioIO>): Promise<string | {
3
6
  out: string;
4
7
  code: number;
5
8
  }>;
9
+ export {};
@@ -1,4 +1,3 @@
1
- import { StudioApp } from "../../tui/app.js";
2
1
  const NON_TTY_MSG = "tickmarkr ui: studio requires a TTY — use `tickmarkr fleet --print` or `tickmarkr status --watch` for line-mode output";
3
2
  export async function ui(_argv, io = {}) {
4
3
  const input = io.input ?? process.stdin;
@@ -6,8 +5,7 @@ export async function ui(_argv, io = {}) {
6
5
  if (input.isTTY !== true || output.isTTY !== true) {
7
6
  return { out: NON_TTY_MSG, code: 1 };
8
7
  }
9
- const app = new StudioApp({ input, output });
10
- app.start();
11
- await app.exited;
8
+ const { runStudioInk } = await import("../../tui/ink/studio-app.js");
9
+ await runStudioInk({ input, output });
12
10
  return "ui: closed";
13
11
  }
@@ -109,6 +109,10 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
109
109
  deny: z.ZodOptional<z.ZodObject<{
110
110
  adapters: z.ZodOptional<z.ZodArray<z.ZodString>>;
111
111
  models: z.ZodOptional<z.ZodArray<z.ZodString>>;
112
+ workers: z.ZodOptional<z.ZodObject<{
113
+ adapters: z.ZodOptional<z.ZodArray<z.ZodString>>;
114
+ models: z.ZodOptional<z.ZodArray<z.ZodString>>;
115
+ }, z.core.$strip>>;
112
116
  }, z.core.$strip>>;
113
117
  }, z.core.$strip>;
114
118
  tiers: z.ZodRecord<z.ZodString, z.ZodObject<{
@@ -51,6 +51,9 @@ const PrefBlockSchema = z.object({
51
51
  adapters: z.array(z.string()).optional(),
52
52
  models: z.array(z.string()).optional(),
53
53
  });
54
+ const DenyBlockSchema = PrefBlockSchema.extend({
55
+ workers: PrefBlockSchema.optional(),
56
+ });
54
57
  // v1.20 REC-02: operator-maintained price table for the usage/cost report (src/report/cost.ts). Two
55
58
  // channel economics, never conflated (spec cost model): API = tokens × per-Mtok rate; sub = flat plan
56
59
  // amortized over a windows/month RANGE (+ an API-equivalent counterfactual when the model has a rate).
@@ -125,7 +128,7 @@ export const TickmarkrConfigSchema = z.object({
125
128
  // Pre-v1.21 doctor.json lacks per-model verdicts. Keep legacy unknown-is-routable behavior opt-in.
126
129
  allowUnverifiedModels: z.boolean(),
127
130
  allow: PrefBlockSchema.optional(),
128
- deny: PrefBlockSchema.optional(),
131
+ deny: DenyBlockSchema.optional(),
129
132
  }),
130
133
  tiers: z.record(z.string(), TierEntrySchema),
131
134
  pricing: z.record(z.string(), z.number()),
@@ -471,6 +474,8 @@ export function configTemplate(overlay) {
471
474
  # deny: # optional fleet denylist; deny beats allow on conflict
472
475
  # models:
473
476
  # - pi:zai/glm-5.2 # OBS-57: pi passes run-start probe but hangs at finish without TICKMARKR_RESULT — remove after no-trailer demotion ships (v1.46 provider-outage taxonomy)
477
+ # workers: # optional worker-only deny; flat adapters/models still deny every role
478
+ # models: [kimi:kimi-code/k3]
474
479
  # # incident-born deny/pin entries MUST name OBS id + root cause + removal condition (see docs/codebase/CONVENTIONS.md)
475
480
  # # entry grammar: adapter id | model id | adapter:model (entries in either list accept all three forms)
476
481
  # # a hint pinning a denied channel FAILS at plan time (RoutingError) — never silent reroute
@@ -12,12 +12,16 @@ export declare class HerdrDriver implements ExecutorDriver {
12
12
  private groups;
13
13
  private groupSerial;
14
14
  private deliverySerial;
15
+ private dispatchLeases;
16
+ private deliveredPanes;
15
17
  private ws;
16
18
  private callerPane;
17
19
  private watches;
18
20
  constructor(bin?: string, workersPerTab?: number);
19
21
  private serial;
20
22
  private deliveryQueue;
23
+ private reserveDispatch;
24
+ private verifyPaneIdentityBinding;
21
25
  private deliveryMatches;
22
26
  static available(): boolean;
23
27
  private herdr;
@@ -25,8 +25,11 @@ export class HerdrDriver {
25
25
  // grouped slot()/close() mutate shared group state across awaits — serialize them so two
26
26
  // concurrent first members can never both create the group tab (mergeSerial idiom, daemon.ts)
27
27
  groupSerial = Promise.resolve();
28
- // OBS-119: concurrent deliveries contend on herdr's send path — one chain per driver (mergeSerial idiom)
28
+ // OBS-120: canonical dispatch slots hold this chain from allocation through verified binding and
29
+ // delivery. Legacy/manual slots still serialize each delivery on the same chain (OBS-119).
29
30
  deliverySerial = Promise.resolve();
31
+ dispatchLeases = new WeakMap();
32
+ deliveredPanes = new WeakMap();
30
33
  // VIS-10: the run's workspace id, captured once at construction (the daemon inherits it from the
31
34
  // operator's env before the driver is built). Required at slot() time, never in the constructor —
32
35
  // pickDriver and its unit test construct HerdrDriver without env, so slot() is the trust gate.
@@ -47,6 +50,41 @@ export class HerdrDriver {
47
50
  this.deliverySerial = p.catch(() => undefined);
48
51
  return p;
49
52
  }
53
+ // OBS-120: ExecutorDriver exposes slot() and run() separately, so a canonical tickmarkr slot
54
+ // carries a lease across that API seam. The next dispatch cannot allocate until this slot's first
55
+ // run either delivers or fails. Allocation errors release immediately; no failed dispatch poisons
56
+ // the queue. The daemon calls run() directly after preparing the command for every owned slot.
57
+ reserveDispatch(allocate) {
58
+ let release;
59
+ const held = new Promise((resolve) => { release = resolve; });
60
+ const start = this.deliverySerial.then(() => undefined, () => undefined);
61
+ this.deliverySerial = start.then(() => held);
62
+ return start.then(async () => {
63
+ let slot;
64
+ try {
65
+ slot = await allocate();
66
+ this.dispatchLeases.set(slot, { release });
67
+ return slot;
68
+ }
69
+ catch (error) {
70
+ if (slot) {
71
+ try {
72
+ await this.close(slot);
73
+ }
74
+ catch { /* allocation already failed closed; cleanup is best-effort */ }
75
+ }
76
+ release();
77
+ throw error;
78
+ }
79
+ });
80
+ }
81
+ async verifyPaneIdentityBinding(slot) {
82
+ const paneId = await this.namedPaneId(slot.name);
83
+ if (paneId !== slot.id) {
84
+ throw new Error(`herdr pane identity binding verification failed for ${slot.name}: allocated ${slot.id}, resolved ${paneId ?? "none"} — refusing delivery`);
85
+ }
86
+ return paneId;
87
+ }
50
88
  // ponytail: narrow panes hard-wrap the input line — collapse whitespace before comparing.
51
89
  deliveryMatches(transcript, cmd) {
52
90
  const norm = (s) => s.replace(/\s+/g, "");
@@ -78,8 +116,12 @@ export class HerdrDriver {
78
116
  return null;
79
117
  }
80
118
  }
81
- // pane ids compact when panes close — resolve fresh via the durable agent name (spec §5)
119
+ // Before delivery, resolve fresh via the durable name because pane ids can compact. After delivery,
120
+ // pin the verified target so early liveness cannot drift to a label rebound onto another pane.
82
121
  async paneId(slot) {
122
+ const delivered = this.deliveredPanes.get(slot);
123
+ if (delivered)
124
+ return delivered;
83
125
  return await this.namedPaneId(slot.name) ?? slot.id;
84
126
  }
85
127
  // VIS-09 P43-03: runtime width for the layout gate (43-MEASUREMENT.md licensing condition 2).
@@ -104,12 +146,18 @@ export class HerdrDriver {
104
146
  // reconcile.ts and this driver's own renameGroupTab/glyphFor decode role/taskId/attempt from
105
147
  // those shapes without a call-site migration; T2 retires this branch by always passing `owned`.
106
148
  const resolved = opts?.owned ? formatOwnedName(opts.owned) : name;
149
+ const allocate = opts?.group
150
+ ? () => this.serial(() => this.groupSlot(cwd, resolved, opts.group))
151
+ : () => this.tabSlot(cwd, resolved, opts?.label);
152
+ // Production dispatch names are canonical even when the gate call site supplies the already-
153
+ // formatted name rather than SlotOpts.owned. Hold one lease across slot() → run(); legacy/manual
154
+ // slots retain their existing allocation-only semantics for compatibility.
155
+ if (parseOwnedName(resolved))
156
+ return this.reserveDispatch(allocate);
107
157
  // group wins if both are set (a group tab is already stage-labeled; passing both is a caller bug).
108
158
  // label (without group) → dedicated labeled tab via tabSlot's third param: no groups-map entry, no
109
159
  // refcount, no groupSerial, no degrade latch — dedicated tabs have no shared state to guard (SUP-01).
110
- if (opts?.group)
111
- return this.serial(() => this.groupSlot(cwd, resolved, opts.group));
112
- return this.tabSlot(cwd, resolved, opts?.label); // label undefined → defaults to name (today's behavior)
160
+ return allocate(); // label undefined → defaults to name (today's behavior)
113
161
  }
114
162
  // today's per-slot tab path, plus the VIS-04 orphan reap
115
163
  // label defaults to the slot name; group tabs pass the STAGE name instead — a first-member label
@@ -320,10 +368,26 @@ export class HerdrDriver {
320
368
  // cleared (C-u), and retyped, bounded; persistent corruption fails closed WITH the captured
321
369
  // transcript — the dispatch-time pincer the ledger asks for, not post-hoc `git:` archaeology.
322
370
  async run(slot, cmd) {
323
- return this.deliveryQueue(() => this.deliver(slot, cmd));
371
+ const lease = this.dispatchLeases.get(slot);
372
+ if (lease) {
373
+ this.dispatchLeases.delete(slot);
374
+ try {
375
+ const paneId = await this.verifyPaneIdentityBinding(slot);
376
+ await this.deliver(slot, cmd, paneId);
377
+ this.deliveredPanes.set(slot, paneId);
378
+ }
379
+ finally {
380
+ lease.release();
381
+ }
382
+ return;
383
+ }
384
+ return this.deliveryQueue(async () => {
385
+ const paneId = await this.paneId(slot);
386
+ await this.deliver(slot, cmd, paneId);
387
+ this.deliveredPanes.set(slot, paneId);
388
+ });
324
389
  }
325
- async deliver(slot, cmd) {
326
- const pane = await this.paneId(slot);
390
+ async deliver(slot, cmd, pane) {
327
391
  let transcript = "";
328
392
  for (let attempt = 0; attempt < DELIVERY_ATTEMPTS; attempt++) {
329
393
  if (attempt > 0) {