tickmarkr 1.72.0 → 1.74.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 (52) 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 +260 -41
  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 +5 -0
  10. package/dist/drivers/herdr.js +100 -10
  11. package/dist/drivers/subprocess.d.ts +3 -3
  12. package/dist/drivers/subprocess.js +10 -5
  13. package/dist/gates/acceptance.js +19 -1
  14. package/dist/gates/llm.d.ts +4 -0
  15. package/dist/gates/llm.js +16 -3
  16. package/dist/gates/run-gates.d.ts +1 -1
  17. package/dist/gates/run-gates.js +25 -3
  18. package/dist/route/preference.d.ts +2 -1
  19. package/dist/route/preference.js +8 -2
  20. package/dist/run/daemon.d.ts +1 -0
  21. package/dist/run/daemon.js +139 -4
  22. package/dist/run/git.d.ts +2 -0
  23. package/dist/run/git.js +55 -0
  24. package/dist/run/journal.d.ts +21 -1
  25. package/dist/run/journal.js +83 -5
  26. package/dist/tui/ink/components.d.ts +18 -0
  27. package/dist/tui/ink/components.js +30 -2
  28. package/dist/tui/ink/studio-app.d.ts +59 -0
  29. package/dist/tui/ink/studio-app.js +320 -0
  30. package/package.json +1 -1
  31. package/dist/tui/app.d.ts +0 -72
  32. package/dist/tui/app.js +0 -334
  33. package/dist/tui/engine.d.ts +0 -52
  34. package/dist/tui/engine.js +0 -92
  35. package/dist/tui/frame.d.ts +0 -22
  36. package/dist/tui/frame.js +0 -56
  37. package/dist/tui/input.d.ts +0 -41
  38. package/dist/tui/input.js +0 -100
  39. package/dist/tui/views/consult-dossier.d.ts +0 -49
  40. package/dist/tui/views/consult-dossier.js +0 -169
  41. package/dist/tui/views/diff-modal.d.ts +0 -9
  42. package/dist/tui/views/diff-modal.js +0 -19
  43. package/dist/tui/views/fleet-view.d.ts +0 -91
  44. package/dist/tui/views/fleet-view.js +0 -337
  45. package/dist/tui/views/preview-view.d.ts +0 -7
  46. package/dist/tui/views/preview-view.js +0 -80
  47. package/dist/tui/views/profile-view.d.ts +0 -15
  48. package/dist/tui/views/profile-view.js +0 -93
  49. package/dist/tui/views/routing-view.d.ts +0 -48
  50. package/dist/tui/views/routing-view.js +0 -234
  51. package/dist/tui/views/runs-view.d.ts +0 -70
  52. 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,15 +1,100 @@
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
- const NOT_COMPARABLE_NOTICE = "graph recompiled since this run — task states not comparable; run `tickmarkr run` to execute";
11
+ const NOT_COMPARABLE_NOTICE = "graph recompiled since this run — task states not comparable; resume with `--graph-changed` to audit this recompile";
12
+ const PRIOR_GRAPH_MARKER = "prior graph";
9
13
  // The timer must keep the process ALIVE: an unref'd timer here let the event loop drain after the
10
14
  // first frame, so a live `--watch` printed once and exited 0 (OBS-11). Never unref this.
11
15
  const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
12
16
  export const GATE_KEYS = { build: "B", test: "T", lint: "L", evidence: "E", scope: "S", acceptance: "A", review: "R" };
17
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
18
+ const ASCII_SPINNER = ["|", "/", "-", "\\"];
19
+ const SAVE_TERMINAL_TITLE = "\x1b[22;0t";
20
+ const RESTORE_TERMINAL_TITLE = "\x1b[23;0t";
21
+ const taskPhase = (value) => {
22
+ if (value === "worker" || value === "gates" || value === "judge" || value === "review" || value === "merge")
23
+ return value;
24
+ if (typeof value !== "string" || !value.startsWith("gate:"))
25
+ return undefined;
26
+ return GATE_NAMES.includes(value.slice("gate:".length)) ? value : undefined;
27
+ };
28
+ const phaseGate = (phase) => {
29
+ if (phase === "judge")
30
+ return "acceptance";
31
+ if (phase === "review")
32
+ return "review";
33
+ return phase.startsWith("gate:") ? phase.slice("gate:".length) : undefined;
34
+ };
35
+ // A phase is live only between its append-only start marker and the matching real outcome. A newer
36
+ // start for the same task supersedes the prior phase (gates → named gate, worker retry → worker).
37
+ // No clock participates in this fold: time only decorates the derived live interval at render time.
38
+ const livePhases = (events) => {
39
+ const live = new Map();
40
+ for (let order = 0; order < events.length; order++) {
41
+ const event = events[order];
42
+ if (event.event === "run-start" || event.event === "run-resume" || event.event === "run-end" || event.event === "superseded") {
43
+ live.clear();
44
+ continue;
45
+ }
46
+ if (event.event === "phase-start" && event.taskId) {
47
+ const phase = taskPhase(event.data.phase);
48
+ const startedAt = Date.parse(event.ts);
49
+ const attempt = typeof event.data.attempt === "number" && Number.isInteger(event.data.attempt) && event.data.attempt >= 0
50
+ ? event.data.attempt
51
+ : 0;
52
+ if (phase && Number.isFinite(startedAt))
53
+ live.set(event.taskId, { taskId: event.taskId, phase, startedAt, order, attempt });
54
+ continue;
55
+ }
56
+ if (!event.taskId)
57
+ continue;
58
+ const active = live.get(event.taskId);
59
+ if (!active)
60
+ continue;
61
+ const gate = phaseGate(active.phase);
62
+ const matched = (active.phase === "worker" && event.event === "worker-result")
63
+ || (active.phase === "gates" && event.event === "gate-result")
64
+ || (gate !== undefined && event.event === "gate-result" && event.data.gate === gate)
65
+ || (active.phase === "merge" && event.event === "merge")
66
+ || event.event === "task-done"
67
+ || event.event === "task-failed"
68
+ || event.event === "task-human"
69
+ || event.event === "task-approved"
70
+ || event.event === "task-dispatch";
71
+ if (matched)
72
+ live.delete(event.taskId);
73
+ }
74
+ return live;
75
+ };
76
+ const phaseLabel = (phase) => phase.startsWith("gate:") ? `gate ${phase.slice("gate:".length)}` : phase;
77
+ const fmtElapsed = (ms) => {
78
+ const seconds = Math.max(0, Math.floor(ms / 1000));
79
+ if (seconds < 60)
80
+ return `${seconds}s`;
81
+ const minutes = Math.floor(seconds / 60);
82
+ if (minutes < 60)
83
+ return `${minutes}m${seconds % 60}s`;
84
+ const hours = Math.floor(minutes / 60);
85
+ return `${hours}h${minutes % 60}m${seconds % 60}s`;
86
+ };
87
+ const workerOutputAge = (phase, now, worker) => now - (worker?.phaseStartedAt === phase.startedAt && worker.hasOutput ? worker.lastOutputAt : phase.startedAt);
88
+ const phaseDetail = (phase, now, worker) => {
89
+ const elapsed = fmtElapsed(now - phase.startedAt);
90
+ if (phase.phase !== "worker")
91
+ return `${phaseLabel(phase.phase)} · ${elapsed} elapsed`;
92
+ const age = fmtElapsed(workerOutputAge(phase, now, worker));
93
+ const output = worker?.phaseStartedAt === phase.startedAt && worker.hasOutput
94
+ ? `last output ${age} ago`
95
+ : `no output ${age}`;
96
+ return `${phaseLabel(phase.phase)} · ${elapsed} elapsed · ${output}`;
97
+ };
13
98
  const attemptStartIdx = (events, taskId) => {
14
99
  let idx = -1;
15
100
  for (let i = 0; i < events.length; i++) {
@@ -36,23 +121,28 @@ export const gateBox = (state, unicode) => {
36
121
  return state === "pass" ? "[x]" : state === "fail" ? "[!]" : state === "skip" ? "." : "[ ]";
37
122
  };
38
123
  export const defaultGateStates = (task) => GATE_NAMES.map((gate) => task.gates.includes(gate) ? "open" : "skip");
39
- export const gateStates = (task, events) => {
124
+ const gateSnapshot = (task, events, rehashAt) => {
40
125
  const outcomes = new Map();
41
126
  const start = attemptStartIdx(events, task.id);
42
127
  if (start >= 0) {
43
- for (const e of events.slice(start)) {
128
+ for (let eventIndex = start; eventIndex < events.length; eventIndex++) {
129
+ const e = events[eventIndex];
44
130
  if (e.taskId !== task.id || e.event !== "gate-result" || typeof e.data.gate !== "string")
45
131
  continue;
46
132
  if (e.data.skipped === true)
47
- outcomes.set(e.data.gate, "skip");
133
+ outcomes.set(e.data.gate, { state: "skip", eventIndex });
48
134
  else if (e.data.pass === true)
49
- outcomes.set(e.data.gate, "pass");
135
+ outcomes.set(e.data.gate, { state: "pass", eventIndex });
50
136
  else if (e.data.pass === false)
51
- outcomes.set(e.data.gate, "fail");
137
+ outcomes.set(e.data.gate, { state: "fail", eventIndex });
52
138
  }
53
139
  }
54
- return GATE_NAMES.map((gate) => task.gates.includes(gate) ? outcomes.get(gate) ?? "open" : "skip");
140
+ return {
141
+ states: GATE_NAMES.map((gate) => task.gates.includes(gate) ? outcomes.get(gate)?.state ?? "open" : "skip"),
142
+ priorGraph: rehashAt !== undefined && [...outcomes.values()].some((outcome) => outcome.eventIndex < rehashAt),
143
+ };
55
144
  };
145
+ export const gateStates = (task, events) => gateSnapshot(task, events).states;
56
146
  // verdict semantics only: pass brand green, fail red, skip/open dim chrome — everything else stays quiet
57
147
  const GATE_STATE_TOKEN = { pass: ok, fail, skip: dim, open: dim };
58
148
  // TTY cells are bare glyphs in fixed GATE_NAMES order — gate identity lives once in the frame
@@ -114,11 +204,11 @@ const terminalFailureCause = (events) => {
114
204
  }
115
205
  return undefined;
116
206
  };
117
- const liveness = (events) => {
207
+ const liveness = (events, now = Date.now()) => {
118
208
  const last = events.at(-1);
119
209
  if (!last)
120
210
  return "last event unknown · daemon pid unknown";
121
- const age = fmtAge(Date.now() - Date.parse(last.ts));
211
+ const age = fmtAge(now - Date.parse(last.ts));
122
212
  const pid = daemonPid(events);
123
213
  const cause = terminalFailureCause(events);
124
214
  if (pid === undefined)
@@ -136,7 +226,25 @@ const liveness = (events) => {
136
226
  } // EPERM ⇒ alive
137
227
  return `last event ${age} ago · daemon pid ${pid} ${state}${cause ? ` · ${cause}` : ""}`;
138
228
  };
139
- const renderFrame = (cwd) => {
229
+ // Resume's shared comparator remains the fail-closed baseline. Status may additionally accept the
230
+ // daemon's audited graph-rehash release, but only when its `from` names that baseline identity (or
231
+ // null for an explicitly released legacy journal) and its `to` names the graph loaded now.
232
+ const statusEngagement = (events, loadedHash) => {
233
+ const baseline = engagementComparable(events, loadedHash);
234
+ for (let i = events.length - 1; i >= 0; i--) {
235
+ const event = events[i];
236
+ if (event.event !== "graph-rehash")
237
+ continue;
238
+ const auditsBaseline = baseline.comparable || baseline.reason === "mismatch"
239
+ ? event.data.from === baseline.recorded
240
+ : event.data.from === null;
241
+ return event.data.to === loadedHash && auditsBaseline
242
+ ? { comparable: true, rehashAt: i }
243
+ : { comparable: false };
244
+ }
245
+ return { comparable: baseline.comparable };
246
+ };
247
+ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness = new Map()) => {
140
248
  const g = loadGraph(cwd);
141
249
  const runId = Journal.latestRunId(cwd, { withJournal: true });
142
250
  const assignments = new Map();
@@ -144,16 +252,18 @@ const renderFrame = (cwd) => {
144
252
  let events = [];
145
253
  const contexts = new Map();
146
254
  let comparable = false;
255
+ let rehashAt;
147
256
  let supersededBy; // v1.53 T5: this run is dead — a newer run replaced it
148
257
  if (runId) {
149
258
  const j = Journal.open(cwd, runId);
150
259
  events = j.read();
151
260
  const sup = [...events].reverse().find((e) => e.event === "superseded" && typeof e.data.by === "string");
152
261
  supersededBy = sup?.data.by;
153
- // T3 (Sol #2 / Fable F2): the SAME comparator resume uses (engagementComparable) — one decision,
154
- // two consumers. graphDefinitionHash (compiled task definitions only) survives status/evidence
155
- // mutation but changes when a task definition changes, so a recompiled graph is detected here too.
156
- comparable = engagementComparable(events, graphDefinitionHash(g)).comparable;
262
+ // The resume comparator is the fail-closed baseline; a matching graph-rehash is the daemon's
263
+ // append-only audit that authorizes this status replay after stop-amend-resume.
264
+ const engagement = statusEngagement(events, graphDefinitionHash(g));
265
+ comparable = engagement.comparable;
266
+ rehashAt = engagement.rehashAt;
157
267
  if (comparable) {
158
268
  replayed = j.replayStatuses();
159
269
  for (const e of events) {
@@ -179,6 +289,12 @@ const renderFrame = (cwd) => {
179
289
  const width = process.stdout.columns ?? 120;
180
290
  const done = effective.tasks.filter((t) => t.status === "done").length;
181
291
  const ended = comparable && runHasEnded(events);
292
+ const taskIds = new Set(g.tasks.map((task) => task.id));
293
+ const phases = comparable ? livePhases(events) : new Map();
294
+ for (const taskId of phases.keys())
295
+ if (!taskIds.has(taskId))
296
+ phases.delete(taskId);
297
+ const hotPhase = [...phases.values()].sort((a, b) => a.order - b.order).at(-1);
182
298
  const cells = g.tasks.map((t) => {
183
299
  const st = replayed?.get(t.id) ?? t.status;
184
300
  const failureKind = comparable ? recordedTaskFailureKind(events, t.id) : undefined;
@@ -188,27 +304,37 @@ const renderFrame = (cwd) => {
188
304
  const redTier = terminal && (ended
189
305
  || (failureKind !== undefined && isQualityFailureParkKind(failureKind))
190
306
  || (st === "failed" && failureKind === undefined));
191
- const isStarved = starved.has(t.id);
192
- const phrase = isStarved ? undefined : activity.cells.get(t.id);
307
+ const livePhase = phases.get(t.id);
308
+ const isStarved = !livePhase && starved.has(t.id);
309
+ const phrase = livePhase ? phaseDetail(livePhase, now, workerLiveness.get(t.id)) : isStarved ? undefined : activity.cells.get(t.id);
193
310
  const label = isStarved ? " starved" : phrase ? ` ${phrase}` : "";
194
311
  const channel = assignments.get(t.id) ?? "-";
195
312
  const ctx = contexts.get(t.id);
196
313
  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) };
314
+ const gates = comparable
315
+ ? gateSnapshot(t, events, rehashAt)
316
+ : { states: defaultGateStates(t), priorGraph: false };
317
+ return { t, st, failureKind, redTier, label, assignCol, isStarved, phrase, channel, ctx, livePhase, ...gates };
198
318
  });
199
319
  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 }) => {
320
+ // machine/CI surface — journals without phase-start stay byte-identical; new phase-aware frames
321
+ // use an ASCII spinner so pipes never receive terminal-only braille/ANSI.
322
+ const rows = cells.map(({ t, st, label, assignCol, livePhase, states, priorGraph }) => {
202
323
  const chain = gateChain(states, false);
203
- const prefix = ` ${taskBox(st)} ${t.id} `;
204
- const suffix = ` ${chain} ${String(st)}${label} ${assignCol}`;
324
+ const prefix = livePhase ? ` ${ASCII_SPINNER[animationFrame % ASCII_SPINNER.length]} ${t.id} ` : ` ${taskBox(st)} ${t.id} `;
325
+ const suffix = ` ${chain}${priorGraph ? ` ${PRIOR_GRAPH_MARKER}` : ""} ${livePhase ? "running" : String(st)}${label} ${assignCol}`;
205
326
  return `${prefix}${shortGoal(t.goal, Math.max(0, width - prefix.length - suffix.length))}${suffix}`;
206
327
  });
207
328
  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`
329
+ ? `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
330
  : `tickmarkr status${divider}no runs yet${divider}${done}/${g.tasks.length} done`;
210
331
  const legendLine = ` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(divider)}`;
211
- return [header, legendLine, ...rows].join("\n");
332
+ return {
333
+ content: [header, legendLine, ...rows].join("\n"),
334
+ ...(hotPhase ? { hotPhase } : {}),
335
+ ...(runId ? { runId } : {}),
336
+ workerPhases: [...phases.values()].filter((phase) => phase.phase === "worker"),
337
+ };
212
338
  }
213
339
  // TTY: cockpit frame composed through src/brand.ts (CLI-DESIGN.md) — dominant run title,
214
340
  // dim chrome, semantic color only on verdicts, completion gauge, and column-aligned status
@@ -219,7 +345,7 @@ const renderFrame = (cwd) => {
219
345
  const gaugeCells = 10;
220
346
  const fill = g.tasks.length ? Math.round((done / g.tasks.length) * gaugeCells) : 0;
221
347
  const gauge = (fill ? (anyFailed ? fail : ok)("█".repeat(fill)) : "") + (fill < gaugeCells ? dim("░".repeat(gaugeCells - fill)) : "");
222
- const live = liveness(events)
348
+ const live = liveness(events, now)
223
349
  .replace(/\bdead\b/, fail("dead"))
224
350
  .replace(/\bfinished\b/, dim("finished"))
225
351
  .replace(/\balive\b/, ok("alive"))
@@ -241,7 +367,7 @@ const renderFrame = (cwd) => {
241
367
  // aligned under the goal: gate chain, live activity phrase (or channel), ctx. Long channel names
242
368
  // and activity phrases live on line 2 only, so they can never squeeze the goal or wrap line 1.
243
369
  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);
370
+ const statusWord = (c) => c.livePhase ? "running" : c.redTier ? "failed" : c.st === "failed" ? "warn" : String(c.st);
245
371
  const idW = Math.max(...cells.map((c) => c.t.id.length), 2);
246
372
  // plain-text status suffixes for width math only — starved / failed gate names / approval hint
247
373
  const suffixPlain = (c) => (c.isStarved ? " · starved" : "") + failedSuffix(c.states) + humanGateSuffix(c.t, c.st, c.states);
@@ -251,9 +377,11 @@ const renderFrame = (cwd) => {
251
377
  const goalW = Math.max(8, ...goals.map((s) => s.length));
252
378
  const indent = " ".repeat(idW + 5); // line 2 starts under the goal column
253
379
  const rows = cells.map((c, i) => {
254
- const { t, st, failureKind, redTier, states, isStarved, phrase, channel, ctx } = c;
380
+ const { t, st, failureKind, redTier, states, priorGraph, isStarved, phrase, channel, ctx, livePhase } = c;
255
381
  const word = statusWord(c);
256
- const stWord = st === "done" ? ok(word) : redTier ? fail(word) : st === "failed" || st === "human" ? warn(word) : word;
382
+ const staleWorker = livePhase?.phase === "worker"
383
+ && workerOutputAge(livePhase, now, workerLiveness.get(t.id)) >= 60_000;
384
+ const stWord = staleWorker ? warn(word) : st === "done" ? ok(word) : redTier ? fail(word) : st === "failed" || st === "human" ? warn(word) : word;
257
385
  // a fail names its gate in words right here — the one moment gate identity is needed on a row
258
386
  const f = failedGates(states);
259
387
  const human = humanGateSuffix(t, st, states);
@@ -261,9 +389,13 @@ const renderFrame = (cwd) => {
261
389
  (isStarved ? dot + fail("starved") : "") +
262
390
  (f.length ? dot + fail(f.join(", ")) : "") +
263
391
  (human ? dot + warn("awaiting approval") : "");
264
- const line1 = ` ${statusRow(taskVerdict(c), `${t.id.padEnd(idW)} ${goals[i].padEnd(goalW)} ${statusCell}`)}`;
392
+ const taskLabel = `${t.id.padEnd(idW)} ${goals[i].padEnd(goalW)} ${statusCell}`;
393
+ const line1 = livePhase
394
+ ? ` ${(staleWorker ? warn : dim)(SPINNER[animationFrame % SPINNER.length])} ${taskLabel}`
395
+ : ` ${statusRow(taskVerdict(c), taskLabel)}`;
265
396
  // activity already names its channel for in-flight attempts — never repeat it
266
397
  const detail = [
398
+ ...(priorGraph ? [PRIOR_GRAPH_MARKER] : []),
267
399
  ...(phrase ? [phrase, ...(phrase.includes(channel) || channel === "-" ? [] : [channel])] : [channel]),
268
400
  ...(failureKind && !phrase?.includes(failureKind) ? [failureKind] : []),
269
401
  ...(ctx !== undefined ? [`ctx ${ctx}`] : []),
@@ -272,28 +404,115 @@ const renderFrame = (cwd) => {
272
404
  }).flat();
273
405
  if (!nowLine.length)
274
406
  rows.pop(); // cards end blank-separated; drop the dangling one
275
- return [header, hr, gatesLegend, "", ...rows, ...nowLine].join("\n");
407
+ return {
408
+ content: [header, hr, gatesLegend, "", ...rows, ...nowLine].join("\n"),
409
+ ...(hotPhase ? { hotPhase } : {}),
410
+ ...(runId ? { runId } : {}),
411
+ workerPhases: [...phases.values()].filter((phase) => phase.phase === "worker"),
412
+ };
276
413
  };
277
414
  export async function status(argv, cwd = process.cwd(), opts = {}) {
278
415
  // 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);
416
+ if (!argv.includes("--watch")) {
417
+ const { content } = renderFrame(cwd, opts.now?.() ?? Date.now());
418
+ return visual() ? BANNER + content : content;
419
+ }
281
420
  const iterations = opts.iterations ?? Infinity;
282
421
  const sleep = opts.sleep ?? defaultSleep;
422
+ const now = opts.now ?? Date.now;
283
423
  const bounded = Number.isFinite(iterations);
284
424
  const frames = [];
285
425
  const sep = "\n---\n";
286
426
  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);
427
+ const workerLiveness = new Map();
428
+ const herdr = HerdrDriver.available() ? new HerdrDriver() : undefined;
429
+ const readWorkerOutput = opts.readWorkerOutput ?? (herdr
430
+ ? async (taskId, attempt, runId) => {
431
+ const name = formatOwnedName({ role: "worker", taskId, attempt, runId });
432
+ try {
433
+ return await herdr.read({ id: name, name, cwd }, 80);
434
+ }
435
+ catch {
436
+ return undefined;
437
+ }
438
+ }
439
+ : undefined);
440
+ const observeWorkerOutput = async (active, runId, observedAt) => {
441
+ const activeIds = new Set(active.map((phase) => phase.taskId));
442
+ for (const taskId of workerLiveness.keys())
443
+ if (!activeIds.has(taskId))
444
+ workerLiveness.delete(taskId);
445
+ if (!readWorkerOutput || !runId)
446
+ return;
447
+ await Promise.all(active.map(async (phase) => {
448
+ const output = await readWorkerOutput(phase.taskId, phase.attempt, runId);
449
+ if (output === undefined)
450
+ return;
451
+ const snapshot = normalizeStallSnapshot(output);
452
+ const prior = workerLiveness.get(phase.taskId);
453
+ if (!prior || prior.phaseStartedAt !== phase.startedAt) {
454
+ const hasOutput = snapshot.trim().length > 0;
455
+ workerLiveness.set(phase.taskId, {
456
+ phaseStartedAt: phase.startedAt,
457
+ snapshot,
458
+ hasOutput,
459
+ lastOutputAt: hasOutput ? observedAt : phase.startedAt,
460
+ });
461
+ return;
462
+ }
463
+ if (snapshot !== prior.snapshot && snapshot.trim().length > 0) {
464
+ prior.hasOutput = true;
465
+ prior.lastOutputAt = observedAt;
466
+ }
467
+ prior.snapshot = snapshot;
468
+ }));
469
+ };
470
+ let titleSaved = false;
471
+ const restoreTitle = () => {
472
+ if (!titleSaved)
473
+ return;
474
+ titleSaved = false;
475
+ process.stdout.write(RESTORE_TERMINAL_TITLE);
476
+ };
477
+ const updateTitle = (hotPhase, nowMs) => {
478
+ if (!hotPhase) {
479
+ if (titleSaved) {
480
+ process.removeListener("exit", restoreTitle);
481
+ restoreTitle();
482
+ }
483
+ return;
484
+ }
485
+ if (!titleSaved) {
486
+ process.stdout.write(SAVE_TERMINAL_TITLE);
487
+ titleSaved = true;
488
+ process.once("exit", restoreTitle);
489
+ }
490
+ process.stdout.write(`\x1b]0;⏳ ${hotPhase.taskId} ${phaseLabel(hotPhase.phase)} ${fmtElapsed(nowMs - hotPhase.startedAt)}\x07`);
491
+ };
492
+ try {
493
+ for (let i = 0; i < iterations; i++) {
494
+ const nowMs = now();
495
+ const frame = renderFrame(cwd, nowMs, i, workerLiveness);
496
+ if (tty) {
497
+ updateTitle(frame.hotPhase, nowMs);
498
+ process.stdout.write(`\x1b[2J\x1b[H${BANNER}${frame.content}\n${legend(` watching · refresh ${REFRESH_MS / 1000}s · ^C to quit`)}`);
499
+ }
500
+ else {
501
+ process.stdout.write(frame.content + sep);
502
+ }
503
+ if (bounded)
504
+ frames.push(frame.content);
505
+ if (i + 1 < iterations) {
506
+ await observeWorkerOutput(frame.workerPhases, frame.runId, nowMs);
507
+ await sleep(REFRESH_MS);
508
+ }
509
+ }
510
+ }
511
+ finally {
512
+ if (titleSaved) {
513
+ process.removeListener("exit", restoreTitle);
514
+ restoreTitle();
515
+ }
297
516
  }
298
517
  return bounded ? frames.join(sep) : "";
299
518
  }
@@ -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;
@@ -35,6 +39,7 @@ export declare class HerdrDriver implements ExecutorDriver {
35
39
  private joinGroup;
36
40
  run(slot: Slot, cmd: string): Promise<void>;
37
41
  private deliver;
42
+ private settleDeliveryLine;
38
43
  private deliveryReadMatches;
39
44
  private waitOk;
40
45
  waitOutput(slot: Slot, pattern: string, timeoutMs: number, opts?: {