tickmarkr 1.96.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.
package/dist/brand.d.ts CHANGED
@@ -26,6 +26,8 @@ export declare function paneDispatchCommand(scriptPath: string): string;
26
26
  export declare const BRAND_RAMP: readonly [84, 78, 41, 35];
27
27
  /** Brand green (ramp anchor 41) — the tickmark hue; also the ok/pass/authed verdict color. */
28
28
  export declare const brand: (s: string) => string;
29
+ /** Compact product chip — black ink on the terminal theme's ANSI green. */
30
+ export declare const brandChip: (s: string) => string;
29
31
  /** Ok verdicts (pass/authed/green) render in the brand green ramp — same hue as the tickmark. */
30
32
  export declare const ok: (s: string) => string;
31
33
  /** Fail verdicts (unauthed/red) — red, always paired with the ✗ shape. */
@@ -39,6 +41,7 @@ export declare const bold: (s: string) => string;
39
41
  /** Every semantic color token, for sweeps: each is TTY-gated and NO_COLOR-aware. */
40
42
  export declare const TOKENS: {
41
43
  readonly brand: (s: string) => string;
44
+ readonly brandChip: (s: string) => string;
42
45
  readonly ok: (s: string) => string;
43
46
  readonly fail: (s: string) => string;
44
47
  readonly warn: (s: string) => string;
package/dist/brand.js CHANGED
@@ -108,6 +108,8 @@ const visual = () => process.stdout.isTTY === true && process.env.NO_COLOR === u
108
108
  const sgr = (code) => (s) => visual() ? `\x1b[${code}m${s}${R}` : s;
109
109
  /** Brand green (ramp anchor 41) — the tickmark hue; also the ok/pass/authed verdict color. */
110
110
  export const brand = sgr(`38;5;${BRAND_RAMP[2]}`);
111
+ /** Compact product chip — black ink on the terminal theme's ANSI green. */
112
+ export const brandChip = sgr("30;42");
111
113
  /** Ok verdicts (pass/authed/green) render in the brand green ramp — same hue as the tickmark. */
112
114
  export const ok = brand;
113
115
  /** Fail verdicts (unauthed/red) — red, always paired with the ✗ shape. */
@@ -119,7 +121,7 @@ export const dim = sgr("2");
119
121
  /** Emphasis (titles, selection, the product name) — bold. */
120
122
  export const bold = sgr("1");
121
123
  /** Every semantic color token, for sweeps: each is TTY-gated and NO_COLOR-aware. */
122
- export const TOKENS = { brand, ok, fail, warn, dim, bold };
124
+ export const TOKENS = { brand, brandChip, ok, fail, warn, dim, bold };
123
125
  /**
124
126
  * The glyph vocabulary — plain characters only; color layers on via tokens so
125
127
  * shape survives NO_COLOR. Bracket toggles ([x]/[ ]) are forbidden on every surface.
@@ -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";
@@ -13,7 +13,7 @@ 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
18
  // ponytail: fixed 2s refresh; promote to config.visibility.* only when an operator asks.
19
19
  const REFRESH_MS = 2000;
@@ -376,11 +376,6 @@ export const gateChain = (states, unicode) => GATE_NAMES.map((gate, i) => unicod
376
376
  ? GATE_STATE_TOKEN[states[i]](gateBox(states[i], true))
377
377
  : `${GATE_KEYS[gate]}${gateBox(states[i], false)}`).join(" ");
378
378
  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
379
  // a designed human gate parks the task BEFORE any gate result exists (daemon.ts execTask), so the
385
380
  // awaited approval is named from task state alone — never from gate results. Failed gates win the
386
381
  // cell when present: a post-approval park is not awaiting the designed gate. Plain text for column
@@ -401,19 +396,46 @@ export const shortGoal = (goal, max) => {
401
396
  };
402
397
  /** Columns the machine row always spends on the task title, however long its machinery segment runs. */
403
398
  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);
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
+ }
415
424
  }
416
- 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("+");
417
439
  };
418
440
  /**
419
441
  * Every board line leaves through the cockpit's own wrap, measured in display cells: no drawn line
@@ -482,7 +504,7 @@ const foldTaskEffort = (tasks, events) => {
482
504
  const effortPanel = (tasks, events, columns) => {
483
505
  if (!events) {
484
506
  return [
485
- ` ${title("WHERE THE EFFORT WENT")}`,
507
+ ` ${ok("▌")} ${title("WHERE THE EFFORT WENT")}`,
486
508
  legend(` dispatch · review · park counts unavailable — ${NOT_COMPARABLE_CLAIM}`),
487
509
  ];
488
510
  }
@@ -502,7 +524,7 @@ const effortPanel = (tasks, events, columns) => {
502
524
  return `${prefix}${fitCells(stack, barColumns)} ${counts}`;
503
525
  });
504
526
  return [
505
- ` ${title("WHERE THE EFFORT WENT")}`,
527
+ ` ${ok("▌")} ${title("WHERE THE EFFORT WENT")}`,
506
528
  legend(` dispatches · review rounds · human parks · top ${top.length} of ${tasks.length} tasks`),
507
529
  "",
508
530
  ...rows,
@@ -945,129 +967,148 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
945
967
  workerPhases: [...phases.values()].filter((phase) => phase.phase === "worker"),
946
968
  };
947
969
  }
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.
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.
955
972
  const boardColumns = Math.max(COCKPIT_COLUMN_FLOOR, width);
956
973
  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;
974
+ const separator = ` ${dim("│")} `;
960
975
  const tipFailed = tipPhase?.state === "failed";
976
+ const anyFailed = cells.some((cell) => cell.redTier);
961
977
  const progressTone = anyFailed || tipFailed ? fail : tipPhase ? warn : ok;
962
- const gauge = (fill ? progressTone("█".repeat(fill)) : "") + (fill < gaugeCells ? dim("░".repeat(gaugeCells - fill)) : "");
963
- const live = liveness(events, now)
964
- .replace(/\bdead\b/, fail("dead"))
965
- .replace(/\bfinished\b/, dim("finished"))
966
- .replace(/\balive\b/, ok("alive"))
967
- .replaceAll(" · ", dot);
968
978
  const tally = tipPhase
969
979
  ? `${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"]),
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`] : []),
977
992
  ...(runId && supersededBy ? [warn(`superseded by ${supersededBy}`)] : []),
978
993
  ...(runId && !comparable ? [warn(NOT_COMPARABLE_NOTICE)] : []),
979
994
  ...(verify === undefined
980
995
  ? []
981
996
  : [verify === "verify passed" ? ok(verify) : tipFailed ? fail(verify) : warn(verify)]),
982
- `${gauge} ${tipPhase ? tally : done === total && total > 0 ? ok(tally) : tally}`,
983
997
  ...(runId ? [live] : []),
984
998
  ...(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.
999
+ ].join(separator);
990
1000
  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
1001
  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.
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);
1037
1015
  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
1016
+ const failed = failedGates(states);
1017
+ const human = humanGateSuffix(t, graphTaskStatus(st, t.status), states);
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(" ")}`);
1068
1096
  const effort = effortPanel(g.tasks, record && !comparable ? undefined : events, boardColumns);
1069
1097
  return {
1070
- content: boardRows([header, hr, gatesLegend, supervisionLegend, "", ...rows, ...nowLine, "", hr, "", ...effort], 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"),
1071
1112
  ...(hotPhase ? { hotPhase } : {}),
1072
1113
  ...(runId ? { runId } : {}),
1073
1114
  workerPhases: [...phases.values()].filter((phase) => phase.phase === "worker"),
@@ -1101,10 +1142,10 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1101
1142
  const namedRunId = positionalRunId(argv);
1102
1143
  if (argv.includes("--oneline"))
1103
1144
  return oneLine(cwd, namedRunId);
1104
- // 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.
1105
1147
  if (!argv.includes("--watch")) {
1106
- const { content } = renderFrame(cwd, opts.now?.() ?? Date.now(), 0, undefined, false, namedRunId);
1107
- return visual() ? BANNER + content : content;
1148
+ return renderFrame(cwd, opts.now?.() ?? Date.now(), 0, undefined, false, namedRunId).content;
1108
1149
  }
1109
1150
  const eventStream = argv.some((arg) => arg === "--events" || arg === "--jsonl" || arg === "--decision-events");
1110
1151
  const webhookUrl = opts.webhookUrl
@@ -1227,7 +1268,7 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1227
1268
  frame = renderFrame(cwd, nowMs, i, workerLiveness, true, namedRunId);
1228
1269
  if (tty) {
1229
1270
  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`)}`);
1271
+ process.stdout.write(`\x1b[2J\x1b[H${frame.content}\n${legend(` watching · refresh ${REFRESH_MS / 1000}s · ^C to quit`)}`);
1231
1272
  }
1232
1273
  else {
1233
1274
  process.stdout.write(frame.content + sep);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.96.0",
3
+ "version": "1.97.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,7 +25,10 @@
25
25
  #
26
26
  # First output line is the machine-greppable verdict:
27
27
  # DELIVERED_SUBMITTED | QUEUED_BEHIND_TURN | SEND_UNSUBMITTED | INTERRUPT_FAILED | TARGET_GONE
28
- # | REFUSED_SIZE | REFUSED_BOX_OCCUPIED
28
+ # | REFUSED_SIZE | REFUSED_BOX_OCCUPIED | SEND_UNVERIFIED | SEND_REFUSED | SEND_FAILED
29
+ #
30
+ # A success verdict names the surface that delivered when it was not the default one, so a fallback
31
+ # is visible in the transcript rather than inferred (OBS-552).
29
32
  #
30
33
  # This verifies SUBMISSION — the text left the input box — never that the seat understood or acted.
31
34
  # Read the seat's ARTIFACT for that; a send receipt is not a response.
@@ -46,6 +49,14 @@ status_of() {
46
49
  | sed -n 's/.*"agent_status":"\([a-z_]*\)".*/\1/p' | head -1
47
50
  }
48
51
 
52
+ # The submit-fallback needs a PANE id, and $TARGET may be an agent NAME. `agent get` answers for a
53
+ # detected-but-undrivable seat — it is only `agent prompt` that refuses one (OBS-552) — so this
54
+ # resolves either target form. The first `pane_id` in the response is the agent's own.
55
+ pane_of() {
56
+ herdr agent get "$TARGET" 2>/dev/null \
57
+ | sed -n 's/.*"pane_id":"\([^"]*\)".*/\1/p' | head -1
58
+ }
59
+
49
60
  # The rendered `❯` line — the only state that discriminates submitted from sitting. head -1 returns
50
61
  # the first rendered line, i.e. a PREFIX of a wrapped draft, so callers compare prefixes, never
51
62
  # whole sentences (OBS-396: a full-sentence match returns false on a message that arrived intact).
@@ -54,6 +65,24 @@ prompt_line() {
54
65
  | sed -n 's/^[[:space:]]*❯[[:space:]]*//p' | head -1 | sed 's/[[:space:]]*$//'
55
66
  }
56
67
 
68
+ # STAGED-QUEUE DISCRIMINATOR (OBS-552 addendum, captured 2026-08-19 on a live kimi seat). After its
69
+ # first turn ends, kimi stages every later message into a SECOND `❯` region —
70
+ # `❯ <text> ↑ to edit · ctrl-s to steer immediately` — and EMPTIES the ordinary box when it does. So
71
+ # `prompt_line`'s emptiness is not submission on a kimi seat: it is the queue swallowing the
72
+ # directive. Six consecutive paths were tried against that state and the pane revision never moved
73
+ # off 3; `seat-send.sh` called the first one DELIVERED_SUBMITTED. This reads the staging AFFORDANCE
74
+ # rather than the vendor name, so any TUI that grows the same queue is covered without a matcher list.
75
+ # Returns the staged TEXT only: the `❯` marker and the trailing affordance are chrome, and leaving the
76
+ # affordance on would break `is_ours`, whose contract is that the rendered text is a PREFIX of $MSG
77
+ # (OBS-396). A wrapped staged entry still yields a prefix, which is all the comparison needs.
78
+ staged_line() {
79
+ herdr agent read "$TARGET" --source visible --lines 14 2>/dev/null \
80
+ | sed -n '/↑ to edit/{/steer/p;}' | head -1 \
81
+ | sed 's/^[[:space:]]*❯[[:space:]]*//' \
82
+ | sed 's/[[:space:]]*↑ to edit.*$//' \
83
+ | sed 's/[[:space:]]*$//'
84
+ }
85
+
57
86
  # Is $1 a rendered prefix of our message? (first line of a wrapped draft = prefix of MSG)
58
87
  is_ours() {
59
88
  case "$MSG" in "$1"*) return 0 ;; *) return 1 ;; esac
@@ -107,9 +136,73 @@ fi
107
136
 
108
137
  # Atomic text+Enter honouring the pane's live bracketed-paste mode — never send-text + separate
109
138
  # Enter, which is the "sitting unsubmitted" failure by construction.
110
- herdr agent prompt "$TARGET" "$MSG" >/dev/null 2>&1
139
+ #
140
+ # OBS-552: the outcome of this verb was thrown away — `>/dev/null 2>&1` discarded BOTH the exit code
141
+ # and the reason. A refusal that wrote NOTHING then fell through to the prompt-line checks below with
142
+ # an empty line, which is byte-identical to a clean submit, and this script exited 0 announcing
143
+ # DELIVERED_SUBMITTED — or QUEUED_BEHIND_TURN when the seat read `working`, which also EXPLAINS AWAY
144
+ # the silence that follows. Measured 2026-08-19 against a live seat: `herdr agent prompt` refuses a
145
+ # detected-but-undrivable occupant with `agent_not_ready` and never touches the input box, while
146
+ # `pane run` delivers to that same pane. A send receipt that cannot fail is not a receipt.
147
+ PROMPT_ERR=$(herdr agent prompt "$TARGET" "$MSG" 2>&1 >/dev/null)
148
+ PROMPT_RC=$?
149
+ VIA=""
150
+ if [ "$PROMPT_RC" -ne 0 ]; then
151
+ case "$PROMPT_ERR" in
152
+ *agent_not_ready*)
153
+ # UNEQUIVOCALLY PRE-WRITE: herdr rejected the target before typing anything, so a second
154
+ # delivery cannot duplicate. This is the ONLY class that earns a fallback.
155
+ PANE=$(pane_of)
156
+ if [ -z "$PANE" ]; then
157
+ echo "SEND_REFUSED $TARGET — agent prompt refused pre-write and no pane id resolved: $PROMPT_ERR"
158
+ exit 1
159
+ fi
160
+ PANE_ERR=$(herdr pane run "$PANE" "$MSG" 2>&1 >/dev/null)
161
+ PANE_RC=$?
162
+ if [ "$PANE_RC" -ne 0 ]; then
163
+ echo "SEND_FAILED $TARGET — both delivery surfaces refused; nothing was typed."
164
+ echo " agent prompt: $PROMPT_ERR"
165
+ echo " pane run ($PANE): $PANE_ERR"
166
+ exit 1
167
+ fi
168
+ VIA=" (via pane run $PANE — agent prompt refused pre-write: $PROMPT_ERR)"
169
+ ;;
170
+ *)
171
+ # AMBIGUOUS: this class cannot prove the write did not land, and a second delivery APPENDS to
172
+ # whatever did. So it never retries — it surfaces the cause and stops, which is the one outcome
173
+ # the discarded stderr made impossible.
174
+ echo "SEND_UNVERIFIED $TARGET — agent prompt exited $PROMPT_RC with an ambiguous outcome: $PROMPT_ERR"
175
+ echo " NOT retrying (a second send appends to anything that landed). Read the pane, then decide."
176
+ exit 1
177
+ ;;
178
+ esac
179
+ fi
111
180
 
112
181
  sleep 2
182
+
183
+ # BEFORE any emptiness verdict: a non-empty staged queue means this seat has accepted text and is not
184
+ # running it, so an empty ordinary box proves nothing. Fail closed — there is no delivery remedy to
185
+ # offer, and every one was tried against the captured instance: bare `enter` on the empty box no-ops,
186
+ # `up`+`enter` moves the entry to the box and puts it straight BACK in the queue, `herdr agent
187
+ # send-keys` cannot type kimi's own `ctrl-s` affordance (no ctrl chords), and `pane run` is a
188
+ # shell-pane path with no effect on a TUI. Recovery is the operator's: capture provenance
189
+ # (pid/ppid/pgid/lstart), TERM the CLI, then `herdr agent start … -- --auto -c` in the SAME pane —
190
+ # session continuation preserved 136k of context and the queue drained on restart. NEVER retry here:
191
+ # the entry may drain at any moment and a second send would then run twice.
192
+ STAGED=$(staged_line)
193
+ if [ -n "$STAGED" ]; then
194
+ if is_ours "$STAGED"; then
195
+ echo "SEND_UNVERIFIED $TARGET — the directive is STAGED in the seat's queue, not running: $STAGED"
196
+ else
197
+ echo "SEND_UNVERIFIED $TARGET — this seat's queue holds an entry that is not draining, so nothing sent now is running: $STAGED"
198
+ fi
199
+ echo " An empty prompt line does NOT mean submitted on a staging TUI — the box is empty BECAUSE the"
200
+ echo " text went to the queue. Do NOT re-send: the queue may drain at any moment and run it twice."
201
+ echo " No input path drains it (enter no-ops, up+enter re-queues, send-keys has no ctrl chords,"
202
+ echo " pane run is shell-only). Capture provenance, TERM the CLI, restart it in the SAME pane with"
203
+ echo " session continuation, then verify the pane REVISION moves before trusting any receipt."
204
+ exit 1
205
+ fi
113
206
  PL=$(prompt_line)
114
207
  if [ -n "$PL" ] && is_ours "$PL"; then
115
208
  # The text is SITTING, not sent — the swallowed-Enter shape. Submitting the existing draft is not a
@@ -127,14 +220,14 @@ fi
127
220
 
128
221
  if [ -n "$PL" ]; then
129
222
  # Not ours: either autosuggest ghost (dim SGR — benign) or a draft that appeared under us.
130
- echo "DELIVERED_SUBMITTED $TARGET — prompt line clear of our text; NOTE it now holds: $PL"
223
+ echo "DELIVERED_SUBMITTED $TARGET$VIA — prompt line clear of our text; NOTE it now holds: $PL"
131
224
  echo " ANSI-verify (dim = ghost). If typed, treat per D-206: discriminate before anyone clears it."
132
225
  exit 0
133
226
  fi
134
227
 
135
228
  if [ -n "$QUEUED" ]; then
136
- echo "QUEUED_BEHIND_TURN $TARGET — submitted into a working seat's queue; it is READ at turn end. A freeze-class or superseding directive belongs behind TKR_INTERRUPT=1."
229
+ echo "QUEUED_BEHIND_TURN $TARGET$VIA — submitted into a working seat's queue; it is READ at turn end. A freeze-class or superseding directive belongs behind TKR_INTERRUPT=1."
137
230
  else
138
- echo "DELIVERED_SUBMITTED $TARGET"
231
+ echo "DELIVERED_SUBMITTED $TARGET$VIA"
139
232
  fi
140
233
  exit 0