tickmarkr 1.96.0 → 2.0.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 { GLYPHS, LIVE } 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";
@@ -13,10 +13,44 @@ import { desiredPanes } from "../../run/reconcile.js";
13
13
  import { normalizeStallSnapshot } from "../../run/stall.js";
14
14
  import { readSupervision, supervisionText } from "../../run/supervision.js";
15
15
  import { deriveRunCockpitData, } from "../../tui/cockpit/derive.js";
16
- import { COCKPIT_COLUMN_FLOOR, LAYOUT_PRIORITY } from "../../tui/cockpit/layout.js";
16
+ import { COCKPIT_COLUMN_FLOOR } from "../../tui/cockpit/layout.js";
17
17
  import { cellWidth, fitCells, wrapCells } from "../../tui/cockpit/width.js";
18
- // ponytail: fixed 2s refresh; promote to config.visibility.* only when an operator asks.
19
- const REFRESH_MS = 2000;
18
+ import { version } from "./version.js";
19
+ // The board is a LIVE surface, so it draws through brand.ts's operator role map and never the vivid
20
+ // one-shot tokens. Semantic aliases can share a colour, but every board cell, gate mark, effort bar,
21
+ // header and footer styles through one of the five exported authorities.
22
+ const ok = LIVE.pass;
23
+ const running = LIVE.running;
24
+ const information = LIVE.information;
25
+ const fail = LIVE.failure;
26
+ const warn = LIVE.attention;
27
+ const dim = LIVE.secondaryText;
28
+ const title = LIVE.primaryText;
29
+ const legend = LIVE.secondaryText;
30
+ const rule = (columns) => LIVE.chrome("─".repeat(columns));
31
+ // Two clocks, deliberately unequal. The journal frame is cheap — a single file read and a fold — so
32
+ // it redraws on a 500ms cadence and the board feels live. Scraping a worker's pane is neither cheap
33
+ // nor bounded: it crosses the herdr socket and can hang on a wedged pane, so it keeps its own 2s
34
+ // budget and runs OFF the redraw path (see scheduleWorkerScrape). A slow scrape updates liveness
35
+ // one frame late; it never delays a frame, and it never overlaps itself.
36
+ // ponytail: fixed cadences; promote to config.visibility.* only when an operator asks.
37
+ const FRAME_MS = 500;
38
+ const SCRAPE_MS = 2000;
39
+ // The binary's own version, read ONCE when status starts (see `status`), then carried into every
40
+ // frame: a board redrawing twice a second must not pay a manifest read per frame, and the version
41
+ // it names must not change under the operator mid-run. It reads through the SAME manifest reader
42
+ // `tickmarkr version` prints from — one version answer for the whole CLI — and a manifest that
43
+ // cannot be read, cannot be parsed, or names no version PROPAGATES rather than resolving to a
44
+ // placeholder: a board that cannot name its own binary is a broken installation, and the board's
45
+ // whole job is to state what is true. A rendered `vunknown` would be a claim about the binary
46
+ // nobody could check.
47
+ const readBinaryVersion = async () => {
48
+ const semver = await version();
49
+ if (typeof semver !== "string" || semver === "") {
50
+ throw new Error("package.json names no version — reinstall tickmarkr");
51
+ }
52
+ return semver;
53
+ };
20
54
  // The claim leads, the guidance follows: an incomparable journal is named in three words a wrapped
21
55
  // board keeps on one row, and the remedy trails it. Both surfaces state the claim; nothing derived
22
56
  // from such a journal — task states, gate outcomes, tip verification — may be presented as this
@@ -368,7 +402,7 @@ const gateSnapshot = (task, events, rehashAt) => {
368
402
  };
369
403
  };
370
404
  export const gateStates = (task, events) => gateSnapshot(task, events).states;
371
- // verdict semantics only: pass brand green, fail red, skip/open dim chrome — everything else stays quiet
405
+ // Verdict semantics only: pass uses brand teal, failure uses amethyst, and skip/open use chrome.
372
406
  const GATE_STATE_TOKEN = { pass: ok, fail, skip: dim, open: dim };
373
407
  // TTY cells are bare glyphs in fixed GATE_NAMES order — gate identity lives once in the frame
374
408
  // legend, and in words on a failing row; non-TTY keeps the letter+box chips (byte-pinned surface)
@@ -376,11 +410,6 @@ export const gateChain = (states, unicode) => GATE_NAMES.map((gate, i) => unicod
376
410
  ? GATE_STATE_TOKEN[states[i]](gateBox(states[i], true))
377
411
  : `${GATE_KEYS[gate]}${gateBox(states[i], false)}`).join(" ");
378
412
  export const failedGates = (states) => GATE_NAMES.filter((_, i) => states[i] === "fail");
379
- // plain-text form of the failed-gate words for column math; rendered with a dim dot + red names
380
- const failedSuffix = (states) => {
381
- const f = failedGates(states);
382
- return f.length ? ` · ${f.join(", ")}` : "";
383
- };
384
413
  // a designed human gate parks the task BEFORE any gate result exists (daemon.ts execTask), so the
385
414
  // awaited approval is named from task state alone — never from gate results. Failed gates win the
386
415
  // cell when present: a post-approval park is not awaiting the designed gate. Plain text for column
@@ -401,19 +430,46 @@ export const shortGoal = (goal, max) => {
401
430
  };
402
431
  /** Columns the machine row always spends on the task title, however long its machinery segment runs. */
403
432
  const MACHINE_TITLE_FLOOR = 24;
404
- const detailText = (segments, budget) => {
405
- const shedOrder = LAYOUT_PRIORITY.slice(LAYOUT_PRIORITY.indexOf("journal") + 1).reverse();
406
- const shed = new Set();
407
- const compose = () => segments
408
- .filter((segment) => segment.text.length > 0 && !shed.has(segment.element))
409
- .map((segment) => segment.text)
410
- .join(" · ");
411
- for (const element of shedOrder) {
412
- if (cellWidth(compose()) <= budget)
413
- break;
414
- shed.add(element);
433
+ // The approved task board derives an area from files[] rather than adding another graph field.
434
+ // Known tickmarkr domains keep their reviewed names; other repositories fall back to their first
435
+ // meaningful path segment so the shipped surface never turns a foreign project into UNMAPPED rows.
436
+ const TASK_AREA_RULES = [
437
+ ["SPEC", [/^src\/(compile|graph|plan)\//u]],
438
+ ["FLEET", [/^src\/(route|config|adapters)\//u]],
439
+ ["RUN", [/^src\/(run|drivers|eval)\//u]],
440
+ ["GATE", [/^src\/gates\//u]],
441
+ ["UI", [/^src\/(cli|tui|report)\//u, /^src\/brand\.ts/u]],
442
+ ["DOCS", [/^(docs|skills)\//u]],
443
+ ["REPO", [/^(scripts|schema)\//u, /^\.github\//u, /^tests\/(lint|repo|helpers)\//u, /^(vitest\.config|package\.json|tsconfig)/u]],
444
+ ];
445
+ const expandAreaBraces = (path) => {
446
+ const match = /^(.*?)\{([^}]*)\}(.*)$/u.exec(path);
447
+ return match
448
+ ? match[2].split(",").flatMap((part) => expandAreaBraces(`${match[1]}${part.trim()}${match[3]}`))
449
+ : [path];
450
+ };
451
+ const areaForPath = (path) => {
452
+ const candidates = [path, path.replace(/^tests\//u, "src/")];
453
+ for (const candidate of candidates) {
454
+ for (const [name, patterns] of TASK_AREA_RULES) {
455
+ if (patterns.some((pattern) => pattern.test(candidate)))
456
+ return name;
457
+ }
415
458
  }
416
- return compose();
459
+ const parts = path.replace(/^[!./]+/u, "").split("/").filter((part) => part && !/[*?[\]]/u.test(part));
460
+ const root = parts[0];
461
+ if (!root)
462
+ return "UNMAPPED";
463
+ const domain = ["src", "app", "packages", "tests"].includes(root) && parts[1] ? parts[1] : root;
464
+ return domain.replace(/\.[^.]+$/u, "").toUpperCase();
465
+ };
466
+ const taskArea = (task) => {
467
+ const paths = task.files.flatMap(expandAreaBraces);
468
+ if (paths.length === 0)
469
+ return "—";
470
+ const primary = paths.filter((path) => !path.startsWith("tests/") && !path.startsWith("src/cli/"));
471
+ const source = primary.length ? primary : paths;
472
+ return [...new Set(source.map(areaForPath))].join("+");
417
473
  };
418
474
  /**
419
475
  * Every board line leaves through the cockpit's own wrap, measured in display cells: no drawn line
@@ -471,6 +527,10 @@ const foldTaskEffort = (tasks, events) => {
471
527
  return [...effort.values()].sort((left, right) => right.total - left.total
472
528
  || order.get(left.taskId) - order.get(right.taskId));
473
529
  };
530
+ // Attention (review) and failure (park) wear the ONE amethyst, so the bar segments and their legend
531
+ // markers carry the distinction in SHAPE: a full block for dispatch, light shade for a review round,
532
+ // dark shade for a human park. Same display width, so the fitted bar keeps its measured columns.
533
+ const EFFORT_GLYPH = { dispatch: "\u2588", review: "\u2592", park: "\u2593" };
474
534
  /**
475
535
  * Prototype panel, fitted by the cockpit's display-cell authority before board-wide wrapping.
476
536
  *
@@ -482,7 +542,7 @@ const foldTaskEffort = (tasks, events) => {
482
542
  const effortPanel = (tasks, events, columns) => {
483
543
  if (!events) {
484
544
  return [
485
- ` ${title("WHERE THE EFFORT WENT")}`,
545
+ ` ${ok("▌")} ${title("WHERE THE EFFORT WENT")}`,
486
546
  legend(` dispatch · review · park counts unavailable — ${NOT_COMPARABLE_CLAIM}`),
487
547
  ];
488
548
  }
@@ -496,18 +556,18 @@ const effortPanel = (tasks, events, columns) => {
496
556
  const dispatchEnd = Math.round((task.dispatches / maxTotal) * barColumns);
497
557
  const reviewEnd = Math.round(((task.dispatches + task.reviews) / maxTotal) * barColumns);
498
558
  const parkEnd = Math.round((task.total / maxTotal) * barColumns);
499
- const stack = ok("█".repeat(dispatchEnd))
500
- + warn("█".repeat(Math.max(0, reviewEnd - dispatchEnd)))
501
- + fail("█".repeat(Math.max(0, parkEnd - reviewEnd)));
559
+ const stack = ok(EFFORT_GLYPH.dispatch.repeat(dispatchEnd))
560
+ + warn(EFFORT_GLYPH.review.repeat(Math.max(0, reviewEnd - dispatchEnd)))
561
+ + fail(EFFORT_GLYPH.park.repeat(Math.max(0, parkEnd - reviewEnd)));
502
562
  return `${prefix}${fitCells(stack, barColumns)} ${counts}`;
503
563
  });
504
564
  return [
505
- ` ${title("WHERE THE EFFORT WENT")}`,
565
+ ` ${ok("▌")} ${title("WHERE THE EFFORT WENT")}`,
506
566
  legend(` dispatches · review rounds · human parks · top ${top.length} of ${tasks.length} tasks`),
507
567
  "",
508
568
  ...rows,
509
569
  "",
510
- ` ${ok("█")} ${dim("dispatch")} ${warn("█")} ${dim("review round")} ${fail("█")} ${dim("human park")}`,
570
+ ` ${ok(EFFORT_GLYPH.dispatch)} ${dim("dispatch")} ${warn(EFFORT_GLYPH.review)} ${dim("review round")} ${fail(EFFORT_GLYPH.park)} ${dim("human park")}`,
511
571
  ];
512
572
  };
513
573
  // VIS-11 (v1.13): a liveness header for renderFrame — last journal event age + whether the recorded
@@ -807,7 +867,10 @@ const recordedTasks = (graph, rows) => graph.tasks.filter((task) => {
807
867
  || row.parkKind !== undefined);
808
868
  });
809
869
  const recordedDone = (tasks, rows) => tasks.filter((task) => graphTaskStatus(rows.get(task.id)?.state, task.status) === "done").length;
810
- const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness = new Map(), journalRowsOnly = false, namedRunId) => {
870
+ const renderFrame = (cwd,
871
+ // No default: the version is the binary's, resolved once by its caller, and a frame that had to
872
+ // invent one would be naming a version nobody can check.
873
+ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(), journalRowsOnly = false, namedRunId) => {
811
874
  const g = loadGraph(cwd);
812
875
  const record = readRunRecord(cwd, g, namedRunId);
813
876
  const runId = record?.runId;
@@ -945,129 +1008,198 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
945
1008
  workerPhases: [...phases.values()].filter((phase) => phase.phase === "worker"),
946
1009
  };
947
1010
  }
948
- // TTY: cockpit frame composed through src/brand.ts (CLI-DESIGN.md) — dominant run title,
949
- // dim chrome, semantic color only on verdicts, completion gauge, and column-aligned status
950
- // rows (pad plain text FIRST, colorize after: ANSI has zero display width and would corrupt
951
- // padEnd math)
952
- // The cockpit owns this board's column contract: `COCKPIT_COLUMN_FLOOR` is the narrowest board it
953
- // plans, so below it the terminal — not a second floor stated here — decides what happens to the
954
- // drawn rows. Every column figure below is derived from this one.
1011
+ // TTY: the operator-approved task table. The daemon gives this surface a ~110-column pane first;
1012
+ // narrower terminals keep the same facts in stacked rows. Every cut/wrap goes through width.ts.
955
1013
  const boardColumns = Math.max(COCKPIT_COLUMN_FLOOR, width);
956
1014
  const dot = dim(" · ");
957
- const anyFailed = cells.some((c) => c.redTier);
958
- const gaugeCells = 10;
959
- const fill = total ? Math.round((done / total) * gaugeCells) : 0;
1015
+ const separator = ` ${dim("│")} `;
960
1016
  const tipFailed = tipPhase?.state === "failed";
1017
+ const anyFailed = cells.some((cell) => cell.redTier);
961
1018
  const progressTone = anyFailed || tipFailed ? fail : tipPhase ? warn : ok;
962
- const gauge = (fill ? progressTone("█".repeat(fill)) : "") + (fill < gaugeCells ? dim("░".repeat(gaugeCells - fill)) : "");
1019
+ // Attention and failure wear the ONE amethyst here too, so the header leads with its own glyph:
1020
+ // ✗ once a verdict is in, ! while the tip is still unverified. Colour cannot carry this.
1021
+ const progressGlyph = anyFailed || tipFailed ? GLYPHS.fail : GLYPHS.attention;
1022
+ const tally = tipPhase
1023
+ ? `${progressTone(`${progressGlyph} ${done}/${total} tasks done`)}${dot}${progressTone("run not verified")}`
1024
+ : done === total && total > 0 ? ok(`${done}/${total} done`) : information(`${done}/${total} done`);
963
1025
  const live = liveness(events, now)
964
- .replace(/\bdead\b/, fail("dead"))
965
- .replace(/\bfinished\b/, dim("finished"))
966
- .replace(/\balive\b/, ok("alive"))
1026
+ .replace(/\bdead\b/u, fail("dead"))
1027
+ .replace(/\bfinished\b/u, dim("finished"))
1028
+ .replace(/\balive\b/u, running("alive"))
967
1029
  .replaceAll(" · ", dot);
968
- const tally = tipPhase
969
- ? `${progressTone(`${done}/${total} tasks done`)}${dot}${progressTone("run not verified")}`
970
- : `${done}/${total} done`;
971
- // The prototype's header order, ported: identity, then what the run is worth (verdict and
972
- // tally), then the instruments that decorate it. A board that must wrap keeps the decisive
973
- // segments on the row an operator reads first; nothing is dropped to get there.
974
- const header = ` ${[
975
- title(runId ? `run ${runId}` : "tickmarkr"),
976
- ...(runId ? [] : ["no runs yet"]),
1030
+ const facts = [
1031
+ title(runId ?? "no runs yet"),
1032
+ tally,
1033
+ ...(events.length ? [`${events.filter((event) => event.event === "run-resume").length} restarts`] : []),
1034
+ ...(events.length ? [`${events.filter((event) => event.event === "escalation").length} escalations`] : []),
977
1035
  ...(runId && supersededBy ? [warn(`superseded by ${supersededBy}`)] : []),
978
1036
  ...(runId && !comparable ? [warn(NOT_COMPARABLE_NOTICE)] : []),
979
1037
  ...(verify === undefined
980
1038
  ? []
981
- : [verify === "verify passed" ? ok(verify) : tipFailed ? fail(verify) : warn(verify)]),
982
- `${gauge} ${tipPhase ? tally : done === total && total > 0 ? ok(tally) : tally}`,
1039
+ : [verify === "verify passed"
1040
+ ? ok(verify)
1041
+ : tipFailed ? fail(`${GLYPHS.fail} ${verify}`) : warn(`${GLYPHS.attention} ${verify}`)]),
983
1042
  ...(runId ? [live] : []),
984
1043
  ...(journalRowsOnly ? [`zone ${localZoneLabel(zoneReference)}`] : []),
985
- ].join(dot)}`;
986
- const hr = rule(Math.min(boardColumns, 100));
987
- // OBS-104 run-level now line: names the most recent journal event. TTY frame only — the non-TTY
988
- // machine surface is byte-pinned (status-brand golden) and must not drift. Rendered BELOW the task
989
- // rows: the line names task ids, and rows must stay the first id-bearing lines for grep consumers.
1044
+ ].join(separator);
1045
+ // The brand lockup is two rows and stays two rows at every width: the muted chip, and the running
1046
+ // binary's version directly beneath it. The run's own facts continue to the right of both rows and
1047
+ // wrap under themselves — the version can never be pushed off its row by a fact that grew, which
1048
+ // is the whole point of wrapping the facts against their own column rather than the board's.
1049
+ const chipText = " tickmarkr ";
1050
+ const versionText = ` v${binaryVersion}`;
1051
+ const chipCells = cellWidth(chipText);
1052
+ const versionCells = cellWidth(versionText);
1053
+ const lockupCells = Math.max(chipCells, versionCells);
1054
+ const lockupGap = " ";
1055
+ const lockupGapCells = cellWidth(lockupGap);
1056
+ const factColumns = Math.max(1, boardColumns - lockupCells - lockupGapCells);
1057
+ // No continuation prefix: the lockup gutter below already indents every fact row, so the fact
1058
+ // column starts at the same cell on all of them.
1059
+ const factLines = wrapCells(facts, factColumns);
1060
+ const besideLockup = (lead, factLine) => factLine === undefined ? lead : `${lead}${lockupGap}${factLine}`;
1061
+ const fillLockup = (lead, cells) => `${lead}${" ".repeat(lockupCells - cells)}`;
1062
+ const headerRows = [
1063
+ besideLockup(fillLockup(LIVE.chip(chipText), chipCells), factLines[0]),
1064
+ besideLockup(fillLockup(dim(versionText), versionCells), factLines[1]),
1065
+ ...factLines.slice(2).map((line) => `${" ".repeat(lockupCells + lockupGapCells)}${line}`),
1066
+ ];
990
1067
  const nowLine = activity.now ? [legend(` now: ${activity.now}`)] : [];
991
- const gatesLegend = legend(` gates: ${GATE_NAMES.join(" · ")}`);
992
- // Every known tier, beaten or not: a tier missing from this line would read as a healthy one.
993
1068
  const supervisionLegend = legend(` ${supervisionText(supervision)}`);
994
- // Two-line card per task (operator request, v1.67): line 1 carries identity + verdict — glyph,
995
- // id, title at full width, and only SHORT status words. Line 2 carries the machinery, dim and
996
- // aligned under the title: gate chain, live activity phrase (or channel), ctx. Long channel names
997
- // and activity phrases live on line 2 only, so they can never squeeze the title or wrap line 1.
998
- const taskVerdict = (c) => c.merged ? "pass" : c.redTier ? "fail" : c.st === "failed" || c.st === "human" ? "warn" : "neutral";
999
- const statusWord = (c) => c.livePhase ? "running" : c.redTier ? "failed" : c.st === "failed" ? "warn" : surfaceStatusWord(c.st);
1000
- // Every column figure is display cells through the cockpit's width authority — never String
1001
- // length, which over-charges a wide cluster and bills a combining mark that draws nothing.
1002
- const idW = Math.max(...cells.map((c) => cellWidth(c.t.id)), 2);
1003
- // plain-text status suffixes for width math only — starved / failed gate names / approval hint
1004
- const suffixPlain = (c) => (c.isStarved ? " · starved" : "") + failedSuffix(c.states)
1005
- + humanGateSuffix(c.t, graphTaskStatus(c.st, c.t.status), c.states);
1006
- const stW = Math.max(...cells.map((c) => cellWidth(statusWord(c) + suffixPlain(c))));
1007
- const avail = Math.max(8, boardColumns - (5 + idW) - 2 - stW);
1008
- const titles = cells.map((c) => shortGoal(c.t.title, avail));
1009
- const titleW = Math.max(8, ...titles.map(cellWidth));
1010
- // Line 2 starts under the title column — but never past what the board can pay for: an id near
1011
- // the schema's 64-character maximum would otherwise indent the machinery clean off a narrow row.
1012
- const chainCells = cellWidth(gateChain(GATE_NAMES.map(() => "open"), true));
1013
- const indent = " ".repeat(Math.max(0, Math.min(idW + 5, boardColumns - chainCells - 2)));
1014
- const rows = cells.map((c, i) => {
1015
- const { t, st, merged, failureKind, redTier, states, priorGraph, isStarved, phrase, channel, ctx, livePhase, pane } = c;
1016
- const word = statusWord(c);
1017
- // The alarm reads the SAME evidence the detail phrase names — a worktree delta the daemon proved
1018
- // counts, or the card would flag a worker it just called alive one line below (OBS-538 review).
1019
- const staleWorker = livePhase?.phase === "worker"
1020
- && workerSilenceMs(livePhase, now, workerLiveness.get(t.id)) >= 60_000;
1021
- const stWord = staleWorker ? warn(word) : merged ? ok(word) : redTier ? fail(word) : st === "failed" || st === "human" ? warn(word) : word;
1022
- // a fail names its gate in words right here — the one moment gate identity is needed on a row
1023
- const f = failedGates(states);
1024
- const human = humanGateSuffix(t, graphTaskStatus(st, t.status), states);
1025
- const statusCell = stWord +
1026
- (isStarved ? dot + fail("starved") : "") +
1027
- (f.length ? dot + fail(f.join(", ")) : "") +
1028
- (human ? dot + warn("awaiting approval") : "");
1029
- const taskTitle = titles[i];
1030
- const taskLabel = `${t.id.padEnd(idW)} ${taskTitle}${" ".repeat(titleW - cellWidth(taskTitle))} ${statusCell}`;
1031
- const line1 = livePhase
1032
- ? ` ${(staleWorker ? warn : dim)(SPINNER[animationFrame % SPINNER.length])} ${taskLabel}`
1033
- : ` ${statusRow(taskVerdict(c), taskLabel)}`;
1034
- // activity already names its channel for in-flight attempts — never repeat it
1035
- const chain = gateChain(states, true);
1036
- // A finished task blocks nobody — the same journal-folded done-ness the map was built on.
1069
+ const taskSectionSummary = `${done} of ${total} merged · rows in graph order · gates left→right in pipeline order`;
1070
+ const taskSection = ` ${ok("▌")} ${title("TASKS")} ${dim(taskSectionSummary)}`;
1071
+ const effortByTask = new Map(foldTaskEffort(g.tasks, record && !comparable ? [] : events)
1072
+ .map((task) => [task.taskId, task]));
1073
+ const gateMatrix = (states) => states.map((state) => {
1074
+ const glyph = gateBox(state, true);
1075
+ const tone = state === "pass" ? ok : state === "fail" ? fail : dim;
1076
+ return `${tone(glyph)} `;
1077
+ }).join("");
1078
+ const gateHeader = GATE_NAMES.map((gate) => fitCells(gate.slice(0, 2), 3)).join("");
1079
+ const noteFor = (cell) => {
1080
+ const { t, st, merged, failureKind, redTier, states, priorGraph, isStarved, phrase, ctx, livePhase, pane } = cell;
1081
+ const effort = effortByTask.get(t.id);
1037
1082
  const blocking = graphTaskStatus(st, t.status) === "done" ? [] : dependents.get(t.id) ?? [];
1038
- const segments = [
1039
- ...(priorGraph ? [{ element: "progressCaption", text: PRIOR_GRAPH_MARKER }] : []),
1040
- ...(phrase
1041
- ? [
1042
- { element: "journal", text: phrase },
1043
- ...(phrase.includes(channel) || channel === "-"
1044
- ? []
1045
- : [{ element: "secondaryHeader", text: channel }]),
1046
- ]
1047
- : [{ element: "secondaryHeader", text: channel }]),
1048
- // The waiting phrase's reverse direction, and structure for the same reason: a supervisor
1049
- // deciding what to unblock needs the names, so it wraps beside the blockers rather than being
1050
- // shed or folded into a count. A finished task blocks nobody.
1051
- ...(blocking.length ? [{ element: "journal", text: `blocks ${blocking.join(", ")}` }] : []),
1052
- ...(failureKind && !phrase?.includes(failureKind)
1053
- ? [{ element: "progressCaption", text: failureKind }]
1054
- : []),
1055
- // A pane an operator can still open is structure, not decoration: it wraps like the blockers
1056
- // above it rather than being shed, because half an address is no address.
1057
- ...(pane ? [{ element: "journal", text: `pane ${pane}` }] : []),
1058
- ...(ctx !== undefined ? [{ element: "statTiles", text: `ctx ${ctx}` }] : []),
1059
- ];
1060
- const detail = detailText(segments, boardColumns - cellWidth(indent) - cellWidth(chain) - 2);
1061
- // A board narrow enough to surrender every detail segment ends the row at the gate chain: the
1062
- // separator is part of the segment it separates, never chrome left standing on its own.
1063
- const line2 = detail ? `${indent}${chain} ${dim(detail)}` : `${indent}${chain}`;
1064
- return [line1, line2, ""];
1065
- }).flat();
1066
- if (!nowLine.length)
1067
- rows.pop(); // cards end blank-separated; drop the dangling one
1083
+ const failed = failedGates(states);
1084
+ const human = humanGateSuffix(t, graphTaskStatus(st, t.status), states);
1085
+ return [
1086
+ livePhase ? `${SPINNER[animationFrame % SPINNER.length]} ${phrase ?? "running"}` : "",
1087
+ !livePhase && isStarved ? "starved" : "",
1088
+ // Attention and failure wear the ONE amethyst, so the tier word leads with its own glyph —
1089
+ // ! for warn, ✗ for failed. That is the distinction that survives NO_COLOR and a pipe.
1090
+ !livePhase && !isStarved && !merged
1091
+ ? redTier
1092
+ ? `${GLYPHS.fail} failed`
1093
+ : st === "failed" || st === "human"
1094
+ ? `${GLYPHS.attention} ${phrase ?? (st === "failed" ? "warn" : surfaceStatusWord(st))}`
1095
+ : phrase ?? surfaceStatusWord(st)
1096
+ : "",
1097
+ redTier && phrase ? phrase : "",
1098
+ priorGraph ? PRIOR_GRAPH_MARKER : "",
1099
+ failed.length ? `failed · ${failed.join(", ")}` : "",
1100
+ human ? "awaiting approval" : "",
1101
+ !merged && failureKind && !phrase?.includes(failureKind) ? failureKind : "",
1102
+ (effort?.parks ?? 0) >= 3 ? `parked ×${effort.parks}` : "",
1103
+ (effort?.reviews ?? 0) > 2 ? `${effort.reviews} review rounds` : "",
1104
+ blocking.length ? `blocks ${blocking.join(", ")}` : "",
1105
+ pane ? `pane ${pane}` : "",
1106
+ ctx !== undefined ? `ctx ${ctx}` : "",
1107
+ ].filter(Boolean).join(" · ");
1108
+ };
1109
+ const staleWorker = (cell) => cell.livePhase?.phase === "worker"
1110
+ && workerSilenceMs(cell.livePhase, now, workerLiveness.get(cell.t.id)) >= 60_000;
1111
+ const tableRows = () => {
1112
+ const idWidth = Math.min(8, Math.max(5, ...cells.map((cell) => cellWidth(cell.t.id))));
1113
+ const areaWidth = 16;
1114
+ const depsWidth = 14;
1115
+ const gatesWidth = GATE_NAMES.length * 3;
1116
+ const channelWidth = boardColumns >= 140 ? 22 : 12;
1117
+ const attemptWidth = 5;
1118
+ const noteWidth = boardColumns >= 140 ? 24 : boardColumns >= 120 ? 20 : 14;
1119
+ const fixed = 4 + idWidth + areaWidth + depsWidth + gatesWidth + 2 + channelWidth + attemptWidth + noteWidth;
1120
+ const taskWidth = boardColumns - fixed;
1121
+ const fitColumn = (text, columns, gap = 2) => fitCells(text, Math.max(1, columns - gap)) + " ".repeat(Math.min(gap, columns - 1));
1122
+ if (taskWidth < 12) {
1123
+ return cells.flatMap((cell) => {
1124
+ const effort = effortByTask.get(cell.t.id);
1125
+ const deps = cell.t.deps.length ? cell.t.deps.join(", ") : "—";
1126
+ const tone = cell.redTier
1127
+ ? fail
1128
+ : cell.merged
1129
+ ? ok
1130
+ : staleWorker(cell) || cell.st === "failed" || cell.st === "human"
1131
+ ? warn
1132
+ : cell.livePhase || cell.st === "running"
1133
+ ? running
1134
+ : dim;
1135
+ const machinery = [
1136
+ `deps ${deps}`,
1137
+ gateMatrix(cell.states),
1138
+ cell.channel,
1139
+ `att ${effort?.dispatches ?? 0}`,
1140
+ ].join(" ");
1141
+ return [
1142
+ ` ${tone(cell.t.id)} ${dim(taskArea(cell.t))} ${sanitizeTaskText(cell.t.title)}`,
1143
+ ...wrapCells(` ${dim(machinery)}`, boardColumns, { continuationPrefix: " " }),
1144
+ ...wrapCells(` ${dim(noteFor(cell))}`, boardColumns, { continuationPrefix: " " }).filter((line) => line.trim()),
1145
+ "",
1146
+ ];
1147
+ });
1148
+ }
1149
+ const headerRow = ` ${fitColumn("", idWidth, 1)}${fitColumn("area", areaWidth)}${fitColumn("deps", depsWidth)}${fitColumn("task", taskWidth)}${gateHeader} ${fitColumn("channel", channelWidth)}${fitColumn("att", attemptWidth, 1)}note`;
1150
+ const ruleRow = ` ${"─".repeat(boardColumns - 4)}`;
1151
+ const rows = cells.flatMap((cell) => {
1152
+ const effort = effortByTask.get(cell.t.id);
1153
+ const columns = [
1154
+ wrapCells(cell.t.id, idWidth - 1),
1155
+ wrapCells(taskArea(cell.t), areaWidth - 2),
1156
+ wrapCells(cell.t.deps.length ? cell.t.deps.join(", ") : "—", depsWidth - 2),
1157
+ [sanitizeTaskText(cell.t.title)],
1158
+ [gateMatrix(cell.states)],
1159
+ [cell.channel],
1160
+ [String(effort?.dispatches ?? 0)],
1161
+ wrapCells(noteFor(cell), noteWidth),
1162
+ ];
1163
+ const height = Math.max(...columns.map((column) => column.length));
1164
+ const idTone = cell.redTier
1165
+ ? fail
1166
+ : cell.merged
1167
+ ? ok
1168
+ : staleWorker(cell) || cell.st === "failed" || cell.st === "human"
1169
+ ? warn
1170
+ : cell.livePhase || cell.st === "running"
1171
+ ? running
1172
+ : dim;
1173
+ const titleTone = cell.redTier || cell.livePhase || (effort?.parks ?? 0) >= 3 ? title : dim;
1174
+ return Array.from({ length: height }, (_, index) => ` ${idTone(fitColumn(columns[0][index] ?? "", idWidth, 1))}`
1175
+ + dim(fitColumn(columns[1][index] ?? "", areaWidth))
1176
+ + dim(fitColumn(columns[2][index] ?? "", depsWidth))
1177
+ + titleTone(fitColumn(columns[3][index] ?? "", taskWidth))
1178
+ + fitCells(columns[4][index] ?? "", gatesWidth)
1179
+ + " "
1180
+ + dim(fitColumn(columns[5][index] ?? "", channelWidth))
1181
+ + dim(fitColumn(columns[6][index] ?? "", attemptWidth, 1))
1182
+ + dim(fitCells(columns[7][index] ?? "", noteWidth)));
1183
+ });
1184
+ return [legend(headerRow), dim(ruleRow), ...rows];
1185
+ };
1186
+ const gatesLegend = legend(` gates ${GATE_NAMES.map((gate) => `${gate.slice(0, 2)} ${gate}`).join(" ")}`);
1068
1187
  const effort = effortPanel(g.tasks, record && !comparable ? undefined : events, boardColumns);
1069
1188
  return {
1070
- content: boardRows([header, hr, gatesLegend, supervisionLegend, "", ...rows, ...nowLine, "", hr, "", ...effort], boardColumns).join("\n"),
1189
+ content: boardRows([
1190
+ ...headerRows,
1191
+ ...nowLine,
1192
+ supervisionLegend,
1193
+ "",
1194
+ taskSection,
1195
+ "",
1196
+ ...tableRows(),
1197
+ gatesLegend,
1198
+ "",
1199
+ rule(boardColumns),
1200
+ "",
1201
+ ...effort,
1202
+ ], boardColumns).join("\n"),
1071
1203
  ...(hotPhase ? { hotPhase } : {}),
1072
1204
  ...(runId ? { runId } : {}),
1073
1205
  workerPhases: [...phases.values()].filter((phase) => phase.phase === "worker"),
@@ -1101,10 +1233,12 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1101
1233
  const namedRunId = positionalRunId(argv);
1102
1234
  if (argv.includes("--oneline"))
1103
1235
  return oneLine(cwd, namedRunId);
1104
- // cockpit surface: banner + frame on a TTY (doctor's pattern); pipes get the bare frame
1236
+ // ONE manifest read per invocation, here — every frame below names this same version.
1237
+ const binaryVersion = await readBinaryVersion();
1238
+ // The task-table frame owns its own two-row brand lockup; the four-row global banner would
1239
+ // create the second header the approved replacement removes.
1105
1240
  if (!argv.includes("--watch")) {
1106
- const { content } = renderFrame(cwd, opts.now?.() ?? Date.now(), 0, undefined, false, namedRunId);
1107
- return visual() ? BANNER + content : content;
1241
+ return renderFrame(cwd, binaryVersion, opts.now?.() ?? Date.now(), 0, undefined, false, namedRunId).content;
1108
1242
  }
1109
1243
  const eventStream = argv.some((arg) => arg === "--events" || arg === "--jsonl" || arg === "--decision-events");
1110
1244
  const webhookUrl = opts.webhookUrl
@@ -1132,14 +1266,21 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1132
1266
  }
1133
1267
  }
1134
1268
  : undefined);
1135
- const observeWorkerOutput = async (active, runId, observedAt) => {
1269
+ // Retiring a task that is no longer running is bookkeeping over data already in hand, so it stays
1270
+ // on the frame path where the board can rely on it every redraw.
1271
+ const retireInactiveWorkers = (active) => {
1136
1272
  const activeIds = new Set(active.map((phase) => phase.taskId));
1137
1273
  for (const taskId of workerLiveness.keys())
1138
1274
  if (!activeIds.has(taskId))
1139
1275
  workerLiveness.delete(taskId);
1276
+ };
1277
+ const scrapeWorkerOutput = async (active, runId, observedAt) => {
1140
1278
  if (!readWorkerOutput || !runId)
1141
1279
  return;
1142
- await Promise.all(active.map(async (phase) => {
1280
+ // allSettled, never all: Promise.all resolves the moment ONE pane read rejects, which would
1281
+ // clear the single-flight guard while a sibling read is still hung on its socket and let the
1282
+ // next budget start a second, overlapping scrape. Every read must settle before the guard drops.
1283
+ await Promise.allSettled(active.map(async (phase) => {
1143
1284
  const output = await readWorkerOutput(phase.taskId, phase.attempt, runId);
1144
1285
  if (output === undefined)
1145
1286
  return;
@@ -1162,6 +1303,21 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1162
1303
  prior.snapshot = snapshot;
1163
1304
  }));
1164
1305
  };
1306
+ // The scrape's own clock, and the reason a hung pane cannot freeze the board: it is STARTED here
1307
+ // and never awaited, so the frame loop walks straight past it. `scrapeInFlight` is what keeps a
1308
+ // scrape that outlives its budget from being joined by a second one — a wedged pane read would
1309
+ // otherwise accumulate one outstanding socket read per elapsed budget for as long as it hangs.
1310
+ let scrapeInFlight = false;
1311
+ let lastScrapeAt = Number.NEGATIVE_INFINITY;
1312
+ const scheduleWorkerScrape = (frame, observedAt) => {
1313
+ if (scrapeInFlight || observedAt - lastScrapeAt < SCRAPE_MS)
1314
+ return;
1315
+ scrapeInFlight = true;
1316
+ lastScrapeAt = observedAt;
1317
+ void scrapeWorkerOutput(frame.workerPhases, frame.runId, observedAt)
1318
+ .catch(() => undefined)
1319
+ .finally(() => { scrapeInFlight = false; });
1320
+ };
1165
1321
  let decisionRunId;
1166
1322
  let journalCursor = 0;
1167
1323
  const consumeDecisionEvents = () => {
@@ -1224,10 +1380,10 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1224
1380
  }
1225
1381
  }
1226
1382
  else {
1227
- frame = renderFrame(cwd, nowMs, i, workerLiveness, true, namedRunId);
1383
+ frame = renderFrame(cwd, binaryVersion, nowMs, i, workerLiveness, true, namedRunId);
1228
1384
  if (tty) {
1229
1385
  updateTitle(frame.hotPhase, nowMs);
1230
- process.stdout.write(`\x1b[2J\x1b[H${BANNER}${frame.content}\n${legend(` watching · refresh ${REFRESH_MS / 1000}s · ^C to quit`)}`);
1386
+ process.stdout.write(`\x1b[2J\x1b[H${frame.content}\n${legend(` watching · refresh ${FRAME_MS / 1000}s · ^C to quit`)}`);
1231
1387
  }
1232
1388
  else {
1233
1389
  process.stdout.write(frame.content + sep);
@@ -1236,9 +1392,11 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1236
1392
  frames.push(frame.content);
1237
1393
  }
1238
1394
  if (i + 1 < iterations) {
1239
- if (frame)
1240
- await observeWorkerOutput(frame.workerPhases, frame.runId, nowMs);
1241
- await sleep(REFRESH_MS);
1395
+ if (frame) {
1396
+ retireInactiveWorkers(frame.workerPhases);
1397
+ scheduleWorkerScrape(frame, nowMs);
1398
+ }
1399
+ await sleep(FRAME_MS);
1242
1400
  }
1243
1401
  }
1244
1402
  }
@@ -1,5 +1,30 @@
1
1
  import { type TickmarkrConfig } from "../config/config.js";
2
2
  import { type Task } from "../graph/schema.js";
3
+ /**
4
+ * OBS-547: the FULL per-task collateral prediction, uncapped. ONE map is computed per run (daemon.ts,
5
+ * at run start) and handed whole to the scope gate, so a hit the plan display hides is still there
6
+ * when the gate asks about it. The lint lines below are a capped VIEW of this same map — never a
7
+ * second scan. Tasks with no hits are absent.
8
+ */
9
+ export declare function collateralHits(tasks: ReadonlyArray<Pick<Task, "id" | "files">>, repoRoot: string): Map<string, string[]>;
10
+ /** The verbatim repair an authoring defect owes: the files[] lines the spec is missing. */
11
+ export declare function filesRepair(taskId: string, paths: ReadonlyArray<string>): string;
12
+ export interface ScopeCollateralVerdict {
13
+ /** every hard offender was predicted ⇒ authoring defect, repair pre-written, no attempt charged */
14
+ authoring: boolean;
15
+ /** the offenders the map had already named */
16
+ predicted: string[];
17
+ /** the offenders it had not — the lint's blind spots, recorded as evidence rather than folklore */
18
+ missed: string[];
19
+ repair: string;
20
+ }
21
+ /**
22
+ * OBS-547: cross-reference at the RED. The gate supplies specificity (the paths actually touched),
23
+ * the prediction supplies classification — so there are no false positives to fear and no refusal to
24
+ * author. Feed this the FULL map for the task: a classifier bounded by the display cap calls a
25
+ * predicted 21st hit a quality failure.
26
+ */
27
+ export declare function classifyScopeOffenders(taskId: string, hard: ReadonlyArray<string>, predicted: ReadonlyArray<string>): ScopeCollateralVerdict;
3
28
  /**
4
29
  * Return human-readable scope-lint lines for plan output (no `!` prefix — plan owns that).
5
30
  * Each line names the task id and at least one missing collateral test path.