tickmarkr 1.93.0 → 1.97.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,6 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { BANNER, GLYPHS, dim, fail, legend, ok, rule, statusRow, title, warn } from "../../brand.js";
3
+ import { brandChip, dim, fail, GLYPHS, legend, ok, rule, title, warn } from "../../brand.js";
4
4
  import { DEFAULT_CONFIG, loadConfig } from "../../config/config.js";
5
5
  import { HerdrDriver } from "../../drivers/herdr.js";
6
6
  import { formatOwnedName, parseOwnedName, } from "../../drivers/types.js";
@@ -8,11 +8,12 @@ import { blockedTasks, graphDefinitionHash, loadGraph, stateDirName } from "../.
8
8
  import { GATE_NAMES } from "../../graph/schema.js";
9
9
  import { foldActivity } from "../../run/activity.js";
10
10
  import { Journal, engagementComparable, isQualityFailureParkKind, recordedTaskFailureKind, runHasEnded, } from "../../run/journal.js";
11
+ import { normalizeGateOutcome } from "../../run/outcome.js";
11
12
  import { desiredPanes } from "../../run/reconcile.js";
12
13
  import { normalizeStallSnapshot } from "../../run/stall.js";
13
14
  import { readSupervision, supervisionText } from "../../run/supervision.js";
14
15
  import { deriveRunCockpitData, } from "../../tui/cockpit/derive.js";
15
- import { COCKPIT_COLUMN_FLOOR, LAYOUT_PRIORITY } from "../../tui/cockpit/layout.js";
16
+ import { COCKPIT_COLUMN_FLOOR } from "../../tui/cockpit/layout.js";
16
17
  import { cellWidth, fitCells, wrapCells } from "../../tui/cockpit/width.js";
17
18
  // ponytail: fixed 2s refresh; promote to config.visibility.* only when an operator asks.
18
19
  const REFRESH_MS = 2000;
@@ -216,6 +217,18 @@ const livePhases = (events) => {
216
217
  const active = live.get(event.taskId);
217
218
  if (!active)
218
219
  continue;
220
+ // The daemon proves a silent worker alive by hashing its worktree and journals that proof as
221
+ // `worker-contact`. It is evidence ABOUT the live phase, never its outcome, so it decorates the
222
+ // entry and is deliberately absent from the `matched` set below.
223
+ if (event.event === "worker-contact" && active.phase === "worker") {
224
+ const at = Date.parse(event.ts);
225
+ if (Number.isFinite(at)) {
226
+ active.lastContactAt = at;
227
+ if (typeof event.data.evidence === "string")
228
+ active.contactEvidence = event.data.evidence;
229
+ }
230
+ continue;
231
+ }
219
232
  const gate = active.gate ?? phaseGate(active.phase);
220
233
  const matched = (active.phase === "worker" && event.event === "worker-result")
221
234
  || (active.phase === "gates" && event.event === "gate-result")
@@ -249,16 +262,44 @@ const fmtElapsed = (ms) => {
249
262
  const hours = Math.floor(minutes / 60);
250
263
  return `${hours}h${minutes % 60}m${seconds % 60}s`;
251
264
  };
252
- const workerOutputAge = (phase, now, worker) => now - (worker?.phaseStartedAt === phase.startedAt && worker.hasOutput ? worker.lastOutputAt : phase.startedAt);
265
+ /**
266
+ * The freshest evidence that this worker is alive, and what produced it. ONE derivation, because the
267
+ * two readers of worker silence drifted apart the first time this was fixed: OBS-538 taught the detail
268
+ * phrase to read the daemon's journaled `worker-contact` and left the stall ALARM (`staleWorker`)
269
+ * clocking pane bytes alone. For the exact population OBS-538 is about — a headless channel (codex
270
+ * `sub`, an API worker) that prints nothing to its pane all run, so `hasOutput` never becomes true —
271
+ * the alarm then warn-painted every live row from 60s onward while line 2 of the same card read
272
+ * "last contact 10s ago (worktree)". A card cannot contradict itself if both halves ask this.
273
+ */
274
+ const workerActivity = (phase, worker) => {
275
+ const outputAt = worker?.phaseStartedAt === phase.startedAt && worker.hasOutput ? worker.lastOutputAt : undefined;
276
+ const contactAt = phase.lastContactAt;
277
+ if (outputAt !== undefined && (contactAt === undefined || outputAt >= contactAt))
278
+ return { at: outputAt, source: "output" };
279
+ if (contactAt !== undefined)
280
+ return { at: contactAt, source: "contact" };
281
+ // Nothing has been observed since the phase began: silence is the whole phase, which is what both
282
+ // the phrase ("no output 14m51s") and the alarm's 60s threshold must measure.
283
+ return { at: phase.startedAt, source: "none" };
284
+ };
285
+ /** How long this worker has been silent by the freshest evidence available — the alarm's clock. */
286
+ const workerSilenceMs = (phase, now, worker) => now - workerActivity(phase, worker).at;
287
+ /**
288
+ * What a live worker row says about silence. Pane bytes are not more truthful than a worktree delta,
289
+ * only different, so the row names whichever evidence is fresher AND names its source.
290
+ */
253
291
  const phaseDetail = (phase, now, worker) => {
254
292
  const elapsed = fmtElapsed(now - phase.startedAt);
255
293
  if (phase.phase !== "worker")
256
294
  return `${phaseLabel(phase)} · ${elapsed} elapsed`;
257
- const age = fmtElapsed(workerOutputAge(phase, now, worker));
258
- const output = worker?.phaseStartedAt === phase.startedAt && worker.hasOutput
259
- ? `last output ${age} ago`
260
- : `no output ${age}`;
261
- return `${phaseLabel(phase.phase)} · ${elapsed} elapsed · ${output}`;
295
+ const head = `${phaseLabel(phase.phase)} · ${elapsed} elapsed`;
296
+ const activity = workerActivity(phase, worker);
297
+ const age = fmtElapsed(now - activity.at);
298
+ if (activity.source === "output")
299
+ return `${head} · last output ${age} ago`;
300
+ if (activity.source === "contact")
301
+ return `${head} · last contact ${age} ago (${phase.contactEvidence ?? "worktree"})`;
302
+ return `${head} · no output ${age}`;
262
303
  };
263
304
  const attemptStartIdx = (events, taskId) => {
264
305
  let idx = -1;
@@ -335,11 +376,6 @@ export const gateChain = (states, unicode) => GATE_NAMES.map((gate, i) => unicod
335
376
  ? GATE_STATE_TOKEN[states[i]](gateBox(states[i], true))
336
377
  : `${GATE_KEYS[gate]}${gateBox(states[i], false)}`).join(" ");
337
378
  export const failedGates = (states) => GATE_NAMES.filter((_, i) => states[i] === "fail");
338
- // plain-text form of the failed-gate words for column math; rendered with a dim dot + red names
339
- const failedSuffix = (states) => {
340
- const f = failedGates(states);
341
- return f.length ? ` · ${f.join(", ")}` : "";
342
- };
343
379
  // a designed human gate parks the task BEFORE any gate result exists (daemon.ts execTask), so the
344
380
  // awaited approval is named from task state alone — never from gate results. Failed gates win the
345
381
  // cell when present: a post-approval park is not awaiting the designed gate. Plain text for column
@@ -358,19 +394,48 @@ export const shortGoal = (goal, max) => {
358
394
  return fitCells(clause, cells).trimEnd();
359
395
  return `${fitCells(clause, cells - 3).trimEnd()}...`;
360
396
  };
361
- const detailText = (segments, budget) => {
362
- const shedOrder = LAYOUT_PRIORITY.slice(LAYOUT_PRIORITY.indexOf("journal") + 1).reverse();
363
- const shed = new Set();
364
- const compose = () => segments
365
- .filter((segment) => segment.text.length > 0 && !shed.has(segment.element))
366
- .map((segment) => segment.text)
367
- .join(" · ");
368
- for (const element of shedOrder) {
369
- if (cellWidth(compose()) <= budget)
370
- break;
371
- shed.add(element);
397
+ /** Columns the machine row always spends on the task title, however long its machinery segment runs. */
398
+ const MACHINE_TITLE_FLOOR = 24;
399
+ // The approved task board derives an area from files[] rather than adding another graph field.
400
+ // Known tickmarkr domains keep their reviewed names; other repositories fall back to their first
401
+ // meaningful path segment so the shipped surface never turns a foreign project into UNMAPPED rows.
402
+ const TASK_AREA_RULES = [
403
+ ["SPEC", [/^src\/(compile|graph|plan)\//u]],
404
+ ["FLEET", [/^src\/(route|config|adapters)\//u]],
405
+ ["RUN", [/^src\/(run|drivers|eval)\//u]],
406
+ ["GATE", [/^src\/gates\//u]],
407
+ ["UI", [/^src\/(cli|tui|report)\//u, /^src\/brand\.ts/u]],
408
+ ["DOCS", [/^(docs|skills)\//u]],
409
+ ["REPO", [/^(scripts|schema)\//u, /^\.github\//u, /^tests\/(lint|repo|helpers)\//u, /^(vitest\.config|package\.json|tsconfig)/u]],
410
+ ];
411
+ const expandAreaBraces = (path) => {
412
+ const match = /^(.*?)\{([^}]*)\}(.*)$/u.exec(path);
413
+ return match
414
+ ? match[2].split(",").flatMap((part) => expandAreaBraces(`${match[1]}${part.trim()}${match[3]}`))
415
+ : [path];
416
+ };
417
+ const areaForPath = (path) => {
418
+ const candidates = [path, path.replace(/^tests\//u, "src/")];
419
+ for (const candidate of candidates) {
420
+ for (const [name, patterns] of TASK_AREA_RULES) {
421
+ if (patterns.some((pattern) => pattern.test(candidate)))
422
+ return name;
423
+ }
372
424
  }
373
- return compose();
425
+ const parts = path.replace(/^[!./]+/u, "").split("/").filter((part) => part && !/[*?[\]]/u.test(part));
426
+ const root = parts[0];
427
+ if (!root)
428
+ return "UNMAPPED";
429
+ const domain = ["src", "app", "packages", "tests"].includes(root) && parts[1] ? parts[1] : root;
430
+ return domain.replace(/\.[^.]+$/u, "").toUpperCase();
431
+ };
432
+ const taskArea = (task) => {
433
+ const paths = task.files.flatMap(expandAreaBraces);
434
+ if (paths.length === 0)
435
+ return "—";
436
+ const primary = paths.filter((path) => !path.startsWith("tests/") && !path.startsWith("src/cli/"));
437
+ const source = primary.length ? primary : paths;
438
+ return [...new Set(source.map(areaForPath))].join("+");
374
439
  };
375
440
  /**
376
441
  * Every board line leaves through the cockpit's own wrap, measured in display cells: no drawn line
@@ -378,6 +443,95 @@ const detailText = (segments, budget) => {
378
443
  * stay blank — `wrapCells` always answers at least one row.
379
444
  */
380
445
  const boardRows = (lines, columns) => lines.flatMap((line) => wrapCells(line, columns, { continuationPrefix: " " }));
446
+ /**
447
+ * A funded review round is a review that actually returned a verdict. `passed` and `failed` are the
448
+ * only two arms of the gate vocabulary that carry one (outcome.ts:23) — every other arm states why
449
+ * nothing ran, so counting it would bill the task for a reviewer it never spent.
450
+ */
451
+ const REVIEW_VERDICTS = new Set(["passed", "failed"]);
452
+ /**
453
+ * Fold the panel from parsed JournalEvent rows only. In particular, a review round is every
454
+ * gate-result whose typed gate field is `review` and whose outcome is a verdict, passed or failed.
455
+ * `review-raw-*` artifacts are unparseable-verdict captures, not round markers, and neither raw
456
+ * journal strings nor run-directory filenames participate in this count.
457
+ *
458
+ * A decline is not a round, and it is NOT readable off `data.skipped`: that field is one of four
459
+ * encodings the shipped record already writes, and the two it misses are both live on disk — a
460
+ * pre-R3 review decline states itself only as `{ pass: true, details: "skipped — …" }`, and a
461
+ * canonical row states it as `outcome.kind: "declined"`. Reading the flag alone bills both as funded
462
+ * rounds and can reorder the top four. `normalizeGateOutcome` is the ONE reader of all four
463
+ * encodings (outcome.ts:125), so the panel asks it rather than re-deriving a fifth answer; a row it
464
+ * cannot read stays `unavailable` and therefore uncounted, fail-closed. `replayMeasurement` is
465
+ * orthogonal to the outcome — a resume re-observing an interrupted round reports a real verdict —
466
+ * so it stays an explicit exclusion, the same clause journal.ts:110 uses.
467
+ */
468
+ const foldTaskEffort = (tasks, events) => {
469
+ const order = new Map(tasks.map((task, index) => [task.id, index]));
470
+ const effort = new Map(tasks.map((task) => [task.id, {
471
+ taskId: task.id,
472
+ dispatches: 0,
473
+ reviews: 0,
474
+ parks: 0,
475
+ total: 0,
476
+ }]));
477
+ for (const event of events) {
478
+ if (!event.taskId)
479
+ continue;
480
+ const task = effort.get(event.taskId);
481
+ if (!task)
482
+ continue;
483
+ if (event.event === "task-dispatch")
484
+ task.dispatches += 1;
485
+ else if (event.event === "gate-result" && event.data.gate === "review"
486
+ && event.data.replayMeasurement !== true
487
+ && REVIEW_VERDICTS.has(normalizeGateOutcome(event.data.outcome ?? event.data).kind))
488
+ task.reviews += 1;
489
+ else if (event.event === "task-human")
490
+ task.parks += 1;
491
+ task.total = task.dispatches + task.reviews + task.parks;
492
+ }
493
+ return [...effort.values()].sort((left, right) => right.total - left.total
494
+ || order.get(left.taskId) - order.get(right.taskId));
495
+ };
496
+ /**
497
+ * Prototype panel, fitted by the cockpit's display-cell authority before board-wide wrapping.
498
+ *
499
+ * `undefined` events mean this run's journal describes a DIFFERENT graph, so it funds no claim here.
500
+ * Folding it — or folding an empty array in its place — would state zero dispatches, reviews and
501
+ * parks for a run that recorded all three, which is unavailable evidence dressed as an answer. Same
502
+ * clause the verify claim uses (`verify unavailable`): no claim beats a false zero.
503
+ */
504
+ const effortPanel = (tasks, events, columns) => {
505
+ if (!events) {
506
+ return [
507
+ ` ${ok("▌")} ${title("WHERE THE EFFORT WENT")}`,
508
+ legend(` dispatch · review · park counts unavailable — ${NOT_COMPARABLE_CLAIM}`),
509
+ ];
510
+ }
511
+ const top = foldTaskEffort(tasks, events).slice(0, 4);
512
+ const maxTotal = Math.max(1, ...top.map((task) => task.total));
513
+ const idColumns = Math.max(2, Math.min(16, Math.max(...top.map((task) => cellWidth(task.taskId)))));
514
+ const rows = top.map((task) => {
515
+ const counts = `${task.dispatches} dispatch · ${task.reviews} review · ${task.parks} park`;
516
+ const prefix = ` ${fitCells(task.taskId, idColumns)} `;
517
+ const barColumns = Math.min(46, Math.max(3, columns - cellWidth(prefix) - 2 - cellWidth(counts)));
518
+ const dispatchEnd = Math.round((task.dispatches / maxTotal) * barColumns);
519
+ const reviewEnd = Math.round(((task.dispatches + task.reviews) / maxTotal) * barColumns);
520
+ const parkEnd = Math.round((task.total / maxTotal) * barColumns);
521
+ const stack = ok("█".repeat(dispatchEnd))
522
+ + warn("█".repeat(Math.max(0, reviewEnd - dispatchEnd)))
523
+ + fail("█".repeat(Math.max(0, parkEnd - reviewEnd)));
524
+ return `${prefix}${fitCells(stack, barColumns)} ${counts}`;
525
+ });
526
+ return [
527
+ ` ${ok("▌")} ${title("WHERE THE EFFORT WENT")}`,
528
+ legend(` dispatches · review rounds · human parks · top ${top.length} of ${tasks.length} tasks`),
529
+ "",
530
+ ...rows,
531
+ "",
532
+ ` ${ok("█")} ${dim("dispatch")} ${warn("█")} ${dim("review round")} ${fail("█")} ${dim("human park")}`,
533
+ ];
534
+ };
381
535
  // VIS-11 (v1.13): a liveness header for renderFrame — last journal event age + whether the recorded
382
536
  // daemon pid is still alive. Honest about unknowns: a pre-v1.13 journal with no pid renders "unknown",
383
537
  // never fabricated (garbage pid data fails toward unknown too). kill(pid,0) is a signal probe, not a
@@ -724,6 +878,17 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
724
878
  // (a recompiled graph's journal must not animate the wrong tasks); with no or stale journal the
725
879
  // dep-waiting cells still derive from the effective graph statuses.
726
880
  const activity = foldActivity(comparable ? events : [], effective.tasks);
881
+ // RULING-v189-scope §14c: a card answers BOTH supervisor questions. `foldActivity` names what a
882
+ // task waits FOR; this names what waits ON it. Read from `effective.tasks` — whose statuses are
883
+ // the journal's replay, never the compiled graph's — so a dependent the record says is done stops
884
+ // being named, and a task all of whose dependents are done acquires no entry at all.
885
+ const dependents = new Map();
886
+ for (const task of effective.tasks) {
887
+ if (task.status === "done")
888
+ continue;
889
+ for (const dep of task.deps)
890
+ dependents.set(dep, [...(dependents.get(dep) ?? []), task.id]);
891
+ }
727
892
  const unicode = visual();
728
893
  const divider = unicode ? " · " : " / ";
729
894
  // SUP-01: derived by stat() alone, from THIS frame's clock. status stays a reader — nothing here
@@ -783,7 +948,12 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
783
948
  const chain = gateChain(states, false);
784
949
  const prefix = livePhase ? ` ${ASCII_SPINNER[animationFrame % ASCII_SPINNER.length]} ${t.id} ` : ` ${surfaceTaskBox(st, merged)} ${t.id} `;
785
950
  const suffix = ` ${chain}${priorGraph ? ` ${PRIOR_GRAPH_MARKER}` : ""} ${livePhase ? "running" : surfaceStatusWord(st)}${label} ${assignCol}${pane ? ` pane ${pane}` : ""}`;
786
- return `${prefix}${shortGoal(t.title, Math.max(0, width - prefix.length - suffix.length))}${suffix}`;
951
+ // The machine row carries the cockpit's own law (see LAYOUT_PRIORITY and the two-line card):
952
+ // identity is never what a crowded row sheds. `dep-waiting on …` grows with fan-in and alone
953
+ // can exceed the terminal width, and the pre-floor math paid for it out of the TITLE — 20 of
954
+ // 41 rows in a live 41-task run named no task at all. These rows already overflow `width`
955
+ // (a pane name is 60 columns on its own), so the floor costs wrapping, never the graph.
956
+ return `${prefix}${shortGoal(t.title, Math.max(MACHINE_TITLE_FLOOR, width - prefix.length - suffix.length))}${suffix}`;
787
957
  });
788
958
  const zone = journalRowsOnly ? `${divider}zone ${localZoneLabel(zoneReference)}` : "";
789
959
  const header = runId
@@ -797,120 +967,148 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
797
967
  workerPhases: [...phases.values()].filter((phase) => phase.phase === "worker"),
798
968
  };
799
969
  }
800
- // TTY: cockpit frame composed through src/brand.ts (CLI-DESIGN.md) — dominant run title,
801
- // dim chrome, semantic color only on verdicts, completion gauge, and column-aligned status
802
- // rows (pad plain text FIRST, colorize after: ANSI has zero display width and would corrupt
803
- // padEnd math)
804
- // The cockpit owns this board's column contract: `COCKPIT_COLUMN_FLOOR` is the narrowest board it
805
- // plans, so below it the terminal — not a second floor stated here — decides what happens to the
806
- // drawn rows. Every column figure below is derived from this one.
970
+ // TTY: the operator-approved task table. The daemon gives this surface a ~110-column pane first;
971
+ // narrower terminals keep the same facts in stacked rows. Every cut/wrap goes through width.ts.
807
972
  const boardColumns = Math.max(COCKPIT_COLUMN_FLOOR, width);
808
973
  const dot = dim(" · ");
809
- const anyFailed = cells.some((c) => c.redTier);
810
- const gaugeCells = 10;
811
- const fill = total ? Math.round((done / total) * gaugeCells) : 0;
974
+ const separator = ` ${dim("│")} `;
812
975
  const tipFailed = tipPhase?.state === "failed";
976
+ const anyFailed = cells.some((cell) => cell.redTier);
813
977
  const progressTone = anyFailed || tipFailed ? fail : tipPhase ? warn : ok;
814
- const gauge = (fill ? progressTone("█".repeat(fill)) : "") + (fill < gaugeCells ? dim("░".repeat(gaugeCells - fill)) : "");
815
- const live = liveness(events, now)
816
- .replace(/\bdead\b/, fail("dead"))
817
- .replace(/\bfinished\b/, dim("finished"))
818
- .replace(/\balive\b/, ok("alive"))
819
- .replaceAll(" · ", dot);
820
978
  const tally = tipPhase
821
979
  ? `${progressTone(`${done}/${total} tasks done`)}${dot}${progressTone("run not verified")}`
822
- : `${done}/${total} done`;
823
- // The prototype's header order, ported: identity, then what the run is worth (verdict and
824
- // tally), then the instruments that decorate it. A board that must wrap keeps the decisive
825
- // segments on the row an operator reads first; nothing is dropped to get there.
826
- const header = ` ${[
827
- title(runId ? `run ${runId}` : "tickmarkr"),
828
- ...(runId ? [] : ["no runs yet"]),
980
+ : done === total && total > 0 ? ok(`${done}/${total} done`) : `${done}/${total} done`;
981
+ const live = liveness(events, now)
982
+ .replace(/\bdead\b/u, fail("dead"))
983
+ .replace(/\bfinished\b/u, dim("finished"))
984
+ .replace(/\balive\b/u, ok("alive"))
985
+ .replaceAll(" · ", dot);
986
+ const header = [
987
+ brandChip(" tickmarkr "),
988
+ title(runId ?? "no runs yet"),
989
+ tally,
990
+ ...(events.length ? [`${events.filter((event) => event.event === "run-resume").length} restarts`] : []),
991
+ ...(events.length ? [`${events.filter((event) => event.event === "escalation").length} escalations`] : []),
829
992
  ...(runId && supersededBy ? [warn(`superseded by ${supersededBy}`)] : []),
830
993
  ...(runId && !comparable ? [warn(NOT_COMPARABLE_NOTICE)] : []),
831
994
  ...(verify === undefined
832
995
  ? []
833
996
  : [verify === "verify passed" ? ok(verify) : tipFailed ? fail(verify) : warn(verify)]),
834
- `${gauge} ${tipPhase ? tally : done === total && total > 0 ? ok(tally) : tally}`,
835
997
  ...(runId ? [live] : []),
836
998
  ...(journalRowsOnly ? [`zone ${localZoneLabel(zoneReference)}`] : []),
837
- ].join(dot)}`;
838
- const hr = rule(Math.min(boardColumns, 100));
839
- // OBS-104 run-level now line: names the most recent journal event. TTY frame only — the non-TTY
840
- // machine surface is byte-pinned (status-brand golden) and must not drift. Rendered BELOW the task
841
- // rows: the line names task ids, and rows must stay the first id-bearing lines for grep consumers.
999
+ ].join(separator);
842
1000
  const nowLine = activity.now ? [legend(` now: ${activity.now}`)] : [];
843
- const gatesLegend = legend(` gates: ${GATE_NAMES.join(" · ")}`);
844
- // Every known tier, beaten or not: a tier missing from this line would read as a healthy one.
845
1001
  const supervisionLegend = legend(` ${supervisionText(supervision)}`);
846
- // Two-line card per task (operator request, v1.67): line 1 carries identity + verdict — glyph,
847
- // id, title at full width, and only SHORT status words. Line 2 carries the machinery, dim and
848
- // aligned under the title: gate chain, live activity phrase (or channel), ctx. Long channel names
849
- // and activity phrases live on line 2 only, so they can never squeeze the title or wrap line 1.
850
- const taskVerdict = (c) => c.merged ? "pass" : c.redTier ? "fail" : c.st === "failed" || c.st === "human" ? "warn" : "neutral";
851
- const statusWord = (c) => c.livePhase ? "running" : c.redTier ? "failed" : c.st === "failed" ? "warn" : surfaceStatusWord(c.st);
852
- // Every column figure is display cells through the cockpit's width authority — never String
853
- // length, which over-charges a wide cluster and bills a combining mark that draws nothing.
854
- const idW = Math.max(...cells.map((c) => cellWidth(c.t.id)), 2);
855
- // plain-text status suffixes for width math only — starved / failed gate names / approval hint
856
- const suffixPlain = (c) => (c.isStarved ? " · starved" : "") + failedSuffix(c.states)
857
- + humanGateSuffix(c.t, graphTaskStatus(c.st, c.t.status), c.states);
858
- const stW = Math.max(...cells.map((c) => cellWidth(statusWord(c) + suffixPlain(c))));
859
- const avail = Math.max(8, boardColumns - (5 + idW) - 2 - stW);
860
- const titles = cells.map((c) => shortGoal(c.t.title, avail));
861
- const titleW = Math.max(8, ...titles.map(cellWidth));
862
- // Line 2 starts under the title column — but never past what the board can pay for: an id near
863
- // the schema's 64-character maximum would otherwise indent the machinery clean off a narrow row.
864
- const chainCells = cellWidth(gateChain(GATE_NAMES.map(() => "open"), true));
865
- const indent = " ".repeat(Math.max(0, Math.min(idW + 5, boardColumns - chainCells - 2)));
866
- const rows = cells.map((c, i) => {
867
- const { t, st, merged, failureKind, redTier, states, priorGraph, isStarved, phrase, channel, ctx, livePhase, pane } = c;
868
- const word = statusWord(c);
869
- const staleWorker = livePhase?.phase === "worker"
870
- && workerOutputAge(livePhase, now, workerLiveness.get(t.id)) >= 60_000;
871
- const stWord = staleWorker ? warn(word) : merged ? ok(word) : redTier ? fail(word) : st === "failed" || st === "human" ? warn(word) : word;
872
- // a fail names its gate in words right here — the one moment gate identity is needed on a row
873
- const f = failedGates(states);
1002
+ const taskSectionSummary = `${done} of ${total} merged · rows in graph order · gates left→right in pipeline order`;
1003
+ const taskSection = ` ${ok("▌")} ${title("TASKS")} ${dim(taskSectionSummary)}`;
1004
+ const effortByTask = new Map(foldTaskEffort(g.tasks, record && !comparable ? [] : events)
1005
+ .map((task) => [task.taskId, task]));
1006
+ const gateMatrix = (states) => states.map((state) => {
1007
+ const glyph = gateBox(state, true);
1008
+ const tone = state === "pass" ? ok : state === "fail" ? fail : dim;
1009
+ return `${tone(glyph)} `;
1010
+ }).join("");
1011
+ const gateHeader = GATE_NAMES.map((gate) => fitCells(gate.slice(0, 2), 3)).join("");
1012
+ const noteFor = (cell) => {
1013
+ const { t, st, merged, failureKind, redTier, states, priorGraph, isStarved, phrase, ctx, livePhase, pane } = cell;
1014
+ const effort = effortByTask.get(t.id);
1015
+ const blocking = graphTaskStatus(st, t.status) === "done" ? [] : dependents.get(t.id) ?? [];
1016
+ const failed = failedGates(states);
874
1017
  const human = humanGateSuffix(t, graphTaskStatus(st, t.status), states);
875
- const statusCell = stWord +
876
- (isStarved ? dot + fail("starved") : "") +
877
- (f.length ? dot + fail(f.join(", ")) : "") +
878
- (human ? dot + warn("awaiting approval") : "");
879
- const taskTitle = titles[i];
880
- const taskLabel = `${t.id.padEnd(idW)} ${taskTitle}${" ".repeat(titleW - cellWidth(taskTitle))} ${statusCell}`;
881
- const line1 = livePhase
882
- ? ` ${(staleWorker ? warn : dim)(SPINNER[animationFrame % SPINNER.length])} ${taskLabel}`
883
- : ` ${statusRow(taskVerdict(c), taskLabel)}`;
884
- // activity already names its channel for in-flight attempts — never repeat it
885
- const chain = gateChain(states, true);
886
- const segments = [
887
- ...(priorGraph ? [{ element: "progressCaption", text: PRIOR_GRAPH_MARKER }] : []),
888
- ...(phrase
889
- ? [
890
- { element: "journal", text: phrase },
891
- ...(phrase.includes(channel) || channel === "-"
892
- ? []
893
- : [{ element: "secondaryHeader", text: channel }]),
894
- ]
895
- : [{ element: "secondaryHeader", text: channel }]),
896
- ...(failureKind && !phrase?.includes(failureKind)
897
- ? [{ element: "progressCaption", text: failureKind }]
898
- : []),
899
- // A pane an operator can still open is structure, not decoration: it wraps like the blockers
900
- // above it rather than being shed, because half an address is no address.
901
- ...(pane ? [{ element: "journal", text: `pane ${pane}` }] : []),
902
- ...(ctx !== undefined ? [{ element: "statTiles", text: `ctx ${ctx}` }] : []),
903
- ];
904
- const detail = detailText(segments, boardColumns - cellWidth(indent) - cellWidth(chain) - 2);
905
- // A board narrow enough to surrender every detail segment ends the row at the gate chain: the
906
- // separator is part of the segment it separates, never chrome left standing on its own.
907
- const line2 = detail ? `${indent}${chain} ${dim(detail)}` : `${indent}${chain}`;
908
- return [line1, line2, ""];
909
- }).flat();
910
- if (!nowLine.length)
911
- rows.pop(); // cards end blank-separated; drop the dangling one
1018
+ return [
1019
+ livePhase ? `${SPINNER[animationFrame % SPINNER.length]} ${phrase ?? "running"}` : "",
1020
+ !livePhase && isStarved ? "starved" : "",
1021
+ !livePhase && !isStarved && !merged ? redTier ? "failed" : phrase ?? (st === "failed" ? "warn" : surfaceStatusWord(st)) : "",
1022
+ redTier && phrase ? phrase : "",
1023
+ priorGraph ? PRIOR_GRAPH_MARKER : "",
1024
+ failed.length ? `failed · ${failed.join(", ")}` : "",
1025
+ human ? "awaiting approval" : "",
1026
+ !merged && failureKind && !phrase?.includes(failureKind) ? failureKind : "",
1027
+ (effort?.parks ?? 0) >= 3 ? `parked ×${effort.parks}` : "",
1028
+ (effort?.reviews ?? 0) > 2 ? `${effort.reviews} review rounds` : "",
1029
+ blocking.length ? `blocks ${blocking.join(", ")}` : "",
1030
+ pane ? `pane ${pane}` : "",
1031
+ ctx !== undefined ? `ctx ${ctx}` : "",
1032
+ ].filter(Boolean).join(" · ");
1033
+ };
1034
+ const staleWorker = (cell) => cell.livePhase?.phase === "worker"
1035
+ && workerSilenceMs(cell.livePhase, now, workerLiveness.get(cell.t.id)) >= 60_000;
1036
+ const tableRows = () => {
1037
+ const idWidth = Math.min(8, Math.max(5, ...cells.map((cell) => cellWidth(cell.t.id))));
1038
+ const areaWidth = 16;
1039
+ const depsWidth = 14;
1040
+ const gatesWidth = GATE_NAMES.length * 3;
1041
+ const channelWidth = boardColumns >= 140 ? 22 : 12;
1042
+ const attemptWidth = 5;
1043
+ const noteWidth = boardColumns >= 140 ? 24 : boardColumns >= 120 ? 20 : 14;
1044
+ const fixed = 4 + idWidth + areaWidth + depsWidth + gatesWidth + 2 + channelWidth + attemptWidth + noteWidth;
1045
+ const taskWidth = boardColumns - fixed;
1046
+ const fitColumn = (text, columns, gap = 2) => fitCells(text, Math.max(1, columns - gap)) + " ".repeat(Math.min(gap, columns - 1));
1047
+ if (taskWidth < 12) {
1048
+ return cells.flatMap((cell) => {
1049
+ const effort = effortByTask.get(cell.t.id);
1050
+ const deps = cell.t.deps.length ? cell.t.deps.join(", ") : "—";
1051
+ const tone = cell.redTier ? fail : cell.merged ? ok : staleWorker(cell) || cell.st === "failed" || cell.st === "human" ? warn : dim;
1052
+ const machinery = [
1053
+ `deps ${deps}`,
1054
+ gateMatrix(cell.states),
1055
+ cell.channel,
1056
+ `att ${effort?.dispatches ?? 0}`,
1057
+ ].join(" ");
1058
+ return [
1059
+ ` ${tone(cell.t.id)} ${dim(taskArea(cell.t))} ${sanitizeTaskText(cell.t.title)}`,
1060
+ ...wrapCells(` ${dim(machinery)}`, boardColumns, { continuationPrefix: " " }),
1061
+ ...wrapCells(` ${dim(noteFor(cell))}`, boardColumns, { continuationPrefix: " " }).filter((line) => line.trim()),
1062
+ "",
1063
+ ];
1064
+ });
1065
+ }
1066
+ const headerRow = ` ${fitColumn("", idWidth, 1)}${fitColumn("area", areaWidth)}${fitColumn("deps", depsWidth)}${fitColumn("task", taskWidth)}${gateHeader} ${fitColumn("channel", channelWidth)}${fitColumn("att", attemptWidth, 1)}note`;
1067
+ const ruleRow = ` ${"─".repeat(boardColumns - 4)}`;
1068
+ const rows = cells.flatMap((cell) => {
1069
+ const effort = effortByTask.get(cell.t.id);
1070
+ const columns = [
1071
+ wrapCells(cell.t.id, idWidth - 1),
1072
+ wrapCells(taskArea(cell.t), areaWidth - 2),
1073
+ wrapCells(cell.t.deps.length ? cell.t.deps.join(", ") : "—", depsWidth - 2),
1074
+ [sanitizeTaskText(cell.t.title)],
1075
+ [gateMatrix(cell.states)],
1076
+ [cell.channel],
1077
+ [String(effort?.dispatches ?? 0)],
1078
+ wrapCells(noteFor(cell), noteWidth),
1079
+ ];
1080
+ const height = Math.max(...columns.map((column) => column.length));
1081
+ const idTone = cell.redTier ? fail : cell.merged ? ok : staleWorker(cell) || cell.st === "failed" || cell.st === "human" ? warn : dim;
1082
+ const titleTone = cell.redTier || cell.livePhase || (effort?.parks ?? 0) >= 3 ? title : dim;
1083
+ return Array.from({ length: height }, (_, index) => ` ${idTone(fitColumn(columns[0][index] ?? "", idWidth, 1))}`
1084
+ + dim(fitColumn(columns[1][index] ?? "", areaWidth))
1085
+ + dim(fitColumn(columns[2][index] ?? "", depsWidth))
1086
+ + titleTone(fitColumn(columns[3][index] ?? "", taskWidth))
1087
+ + fitCells(columns[4][index] ?? "", gatesWidth)
1088
+ + " "
1089
+ + dim(fitColumn(columns[5][index] ?? "", channelWidth))
1090
+ + dim(fitColumn(columns[6][index] ?? "", attemptWidth, 1))
1091
+ + dim(fitCells(columns[7][index] ?? "", noteWidth)));
1092
+ });
1093
+ return [legend(headerRow), dim(ruleRow), ...rows];
1094
+ };
1095
+ const gatesLegend = legend(` gates ${GATE_NAMES.map((gate) => `${gate.slice(0, 2)} ${gate}`).join(" ")}`);
1096
+ const effort = effortPanel(g.tasks, record && !comparable ? undefined : events, boardColumns);
912
1097
  return {
913
- content: boardRows([header, hr, gatesLegend, supervisionLegend, "", ...rows, ...nowLine], boardColumns).join("\n"),
1098
+ content: boardRows([
1099
+ header,
1100
+ ...nowLine,
1101
+ supervisionLegend,
1102
+ "",
1103
+ taskSection,
1104
+ "",
1105
+ ...tableRows(),
1106
+ gatesLegend,
1107
+ "",
1108
+ rule(boardColumns),
1109
+ "",
1110
+ ...effort,
1111
+ ], boardColumns).join("\n"),
914
1112
  ...(hotPhase ? { hotPhase } : {}),
915
1113
  ...(runId ? { runId } : {}),
916
1114
  workerPhases: [...phases.values()].filter((phase) => phase.phase === "worker"),
@@ -944,10 +1142,10 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
944
1142
  const namedRunId = positionalRunId(argv);
945
1143
  if (argv.includes("--oneline"))
946
1144
  return oneLine(cwd, namedRunId);
947
- // cockpit surface: banner + frame on a TTY (doctor's pattern); pipes get the bare frame
1145
+ // The task-table frame owns its compact one-line brand chip; the four-row global banner would
1146
+ // create the second header the approved replacement removes.
948
1147
  if (!argv.includes("--watch")) {
949
- const { content } = renderFrame(cwd, opts.now?.() ?? Date.now(), 0, undefined, false, namedRunId);
950
- return visual() ? BANNER + content : content;
1148
+ return renderFrame(cwd, opts.now?.() ?? Date.now(), 0, undefined, false, namedRunId).content;
951
1149
  }
952
1150
  const eventStream = argv.some((arg) => arg === "--events" || arg === "--jsonl" || arg === "--decision-events");
953
1151
  const webhookUrl = opts.webhookUrl
@@ -1070,7 +1268,7 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1070
1268
  frame = renderFrame(cwd, nowMs, i, workerLiveness, true, namedRunId);
1071
1269
  if (tty) {
1072
1270
  updateTitle(frame.hotPhase, nowMs);
1073
- process.stdout.write(`\x1b[2J\x1b[H${BANNER}${frame.content}\n${legend(` watching · refresh ${REFRESH_MS / 1000}s · ^C to quit`)}`);
1271
+ process.stdout.write(`\x1b[2J\x1b[H${frame.content}\n${legend(` watching · refresh ${REFRESH_MS / 1000}s · ^C to quit`)}`);
1074
1272
  }
1075
1273
  else {
1076
1274
  process.stdout.write(frame.content + sep);