tickmarkr 1.87.0 → 1.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/dist/adapters/catalog.d.ts +18 -1
  2. package/dist/adapters/catalog.js +44 -1
  3. package/dist/adapters/fake.d.ts +2 -1
  4. package/dist/adapters/fake.js +7 -0
  5. package/dist/adapters/grok.js +11 -0
  6. package/dist/adapters/kimi.d.ts +2 -1
  7. package/dist/adapters/kimi.js +36 -0
  8. package/dist/adapters/opencode.js +17 -0
  9. package/dist/adapters/pi.js +11 -0
  10. package/dist/adapters/prompt.js +8 -1
  11. package/dist/adapters/registry.js +76 -57
  12. package/dist/adapters/types.d.ts +34 -3
  13. package/dist/adapters/types.js +99 -1
  14. package/dist/cli/commands/approve.d.ts +2 -0
  15. package/dist/cli/commands/approve.js +104 -84
  16. package/dist/cli/commands/compile.d.ts +1 -1
  17. package/dist/cli/commands/compile.js +29 -12
  18. package/dist/cli/commands/doctor.d.ts +16 -0
  19. package/dist/cli/commands/doctor.js +52 -0
  20. package/dist/cli/commands/init.js +2 -1
  21. package/dist/cli/commands/plan.d.ts +1 -1
  22. package/dist/cli/commands/plan.js +10 -1
  23. package/dist/cli/commands/report.js +49 -0
  24. package/dist/cli/commands/status.js +298 -96
  25. package/dist/cli/commands/verify.d.ts +9 -0
  26. package/dist/cli/commands/verify.js +177 -0
  27. package/dist/cli/harness.d.ts +13 -0
  28. package/dist/cli/harness.js +50 -0
  29. package/dist/cli/index.d.ts +1 -1
  30. package/dist/cli/index.js +3 -1
  31. package/dist/compile/collateral.js +11 -11
  32. package/dist/compile/common.js +2 -2
  33. package/dist/compile/index.d.ts +14 -3
  34. package/dist/compile/index.js +36 -10
  35. package/dist/compile/native.d.ts +15 -1
  36. package/dist/compile/native.js +310 -28
  37. package/dist/config/config.js +2 -2
  38. package/dist/drivers/subprocess.d.ts +6 -1
  39. package/dist/drivers/subprocess.js +9 -4
  40. package/dist/gates/acceptance.d.ts +21 -1
  41. package/dist/gates/acceptance.js +67 -22
  42. package/dist/gates/artifact-manifest.d.ts +119 -0
  43. package/dist/gates/artifact-manifest.js +357 -0
  44. package/dist/gates/baseline.d.ts +45 -5
  45. package/dist/gates/baseline.js +119 -15
  46. package/dist/gates/llm.js +37 -26
  47. package/dist/gates/review.d.ts +16 -11
  48. package/dist/gates/review.js +44 -150
  49. package/dist/gates/run-gates.js +124 -7
  50. package/dist/gates/scope.js +3 -3
  51. package/dist/graph/files-glob.d.ts +18 -0
  52. package/dist/graph/files-glob.js +22 -0
  53. package/dist/graph/schema.d.ts +3 -1
  54. package/dist/graph/schema.js +4 -1
  55. package/dist/run/daemon.d.ts +44 -0
  56. package/dist/run/daemon.js +2334 -1973
  57. package/dist/run/git.d.ts +53 -0
  58. package/dist/run/git.js +119 -5
  59. package/dist/run/interactive-seed.d.ts +6 -2
  60. package/dist/run/interactive-seed.js +72 -5
  61. package/dist/run/journal.d.ts +9 -1
  62. package/dist/run/journal.js +99 -9
  63. package/dist/run/lock.d.ts +11 -0
  64. package/dist/run/lock.js +97 -6
  65. package/dist/run/merge.d.ts +4 -1
  66. package/dist/run/merge.js +26 -7
  67. package/dist/run/outcome.d.ts +50 -0
  68. package/dist/run/outcome.js +152 -0
  69. package/dist/run/protocol.d.ts +460 -0
  70. package/dist/run/protocol.js +433 -0
  71. package/dist/run/supervision.d.ts +29 -0
  72. package/dist/run/supervision.js +189 -0
  73. package/fixtures/authoring-lints/01-awk-range-self-pass.spec.md +12 -0
  74. package/fixtures/authoring-lints/02-judge-text-key-miss.spec.md +7 -0
  75. package/fixtures/authoring-lints/03-c1-t41-rendered-observable.spec.md +8 -0
  76. package/fixtures/authoring-lints/04-c1-t24-named-file.spec.md +8 -0
  77. package/fixtures/authoring-lints/05-c2-t24-t28-dep-inversion.spec.md +7 -0
  78. package/fixtures/authoring-lints/06-c2-denumbered-coupling.spec.md +7 -0
  79. package/fixtures/authoring-lints/07-c3a-t41-line-count-proxy.spec.md +7 -0
  80. package/fixtures/authoring-lints/08-c3b-t41-governance-referent.spec.md +7 -0
  81. package/fixtures/authoring-lints/09-c4-universals-without-pointer.spec.md +7 -0
  82. package/fixtures/authoring-lints/10-c5-t34-conjunct-flood.spec.md +7 -0
  83. package/fixtures/authoring-lints/11-c6-t34-q3-q9-q20-bundle.spec.md +7 -0
  84. package/fixtures/authoring-lints/12-c7-t24-prose-seam.spec.md +8 -0
  85. package/fixtures/wrapped-acceptance.native.md +29 -0
  86. package/package.json +1 -1
  87. package/schema/rungraph.schema.json +21 -2
  88. package/skills/tickmarkr-overseer/SKILL.md +262 -5
  89. package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +79 -8
  90. package/skills/tickmarkr-overseer/scripts/watch-contamination.sh +80 -0
  91. package/skills/tickmarkr-overseer/scripts/watch-context.sh +86 -0
  92. package/skills/tickmarkr-overseer/scripts/watch-parks.sh +96 -0
  93. package/skills/tickmarkr-overseer/scripts/watch-pending-input.sh +201 -0
@@ -1,18 +1,27 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { BANNER, GLYPHS, dim, fail, legend, ok, rule, statusRow, title, warn } from "../../brand.js";
4
+ import { DEFAULT_CONFIG, loadConfig } from "../../config/config.js";
4
5
  import { HerdrDriver } from "../../drivers/herdr.js";
5
- import { formatOwnedName, } from "../../drivers/types.js";
6
+ import { formatOwnedName, parseOwnedName, } from "../../drivers/types.js";
6
7
  import { blockedTasks, graphDefinitionHash, loadGraph, stateDirName } from "../../graph/graph.js";
7
8
  import { GATE_NAMES } from "../../graph/schema.js";
8
9
  import { foldActivity } from "../../run/activity.js";
9
10
  import { Journal, engagementComparable, isQualityFailureParkKind, recordedTaskFailureKind, runHasEnded, } from "../../run/journal.js";
11
+ import { desiredPanes } from "../../run/reconcile.js";
10
12
  import { normalizeStallSnapshot } from "../../run/stall.js";
13
+ import { readSupervision, supervisionText } from "../../run/supervision.js";
11
14
  import { deriveRunCockpitData, } from "../../tui/cockpit/derive.js";
12
- import { cellWidth, fitCells } from "../../tui/cockpit/width.js";
15
+ import { COCKPIT_COLUMN_FLOOR, LAYOUT_PRIORITY } from "../../tui/cockpit/layout.js";
16
+ import { cellWidth, fitCells, wrapCells } from "../../tui/cockpit/width.js";
13
17
  // ponytail: fixed 2s refresh; promote to config.visibility.* only when an operator asks.
14
18
  const REFRESH_MS = 2000;
15
- const NOT_COMPARABLE_NOTICE = "graph recompiled since this run — task states not comparable; resume with `--graph-changed` to audit this recompile";
19
+ // The claim leads, the guidance follows: an incomparable journal is named in three words a wrapped
20
+ // board keeps on one row, and the remedy trails it. Both surfaces state the claim; nothing derived
21
+ // from such a journal — task states, gate outcomes, tip verification — may be presented as this
22
+ // run's, so the verify claim beside it reads `unavailable` rather than the recorded verdict.
23
+ const NOT_COMPARABLE_CLAIM = "graph not comparable";
24
+ const NOT_COMPARABLE_NOTICE = `${NOT_COMPARABLE_CLAIM} — recompiled since this run; resume with \`--graph-changed\` to audit this recompile`;
16
25
  const PRIOR_GRAPH_MARKER = "prior graph";
17
26
  // One compact host-zone label per screen. Each event below is still converted from its own
18
27
  // complete UTC instant, so the zone's historical rules — including a transition within one frame
@@ -160,6 +169,11 @@ const postWebhookFireAndForget = (post, url, event) => {
160
169
  // Webhooks are observational. A synchronous bridge failure is as inert as a rejected request.
161
170
  }
162
171
  };
172
+ // `phase` is a coarse word — the daemon writes "judge" for the acceptance gate and, for a review
173
+ // round, whatever phaseForGate() maps that gate to. The gate a row must NAME is the event's own
174
+ // `gate` field; a value that is not one of the seven declared gates is not a gate name and is
175
+ // ignored rather than printed.
176
+ const recordedGate = (value) => typeof value === "string" && GATE_NAMES.includes(value) ? value : undefined;
163
177
  const taskPhase = (value) => {
164
178
  if (value === "worker" || value === "gates" || value === "judge" || value === "review" || value === "merge")
165
179
  return value;
@@ -191,8 +205,10 @@ const livePhases = (events) => {
191
205
  const attempt = typeof event.data.attempt === "number" && Number.isInteger(event.data.attempt) && event.data.attempt >= 0
192
206
  ? event.data.attempt
193
207
  : 0;
194
- if (phase && Number.isFinite(startedAt))
195
- live.set(event.taskId, { taskId: event.taskId, phase, startedAt, order, attempt });
208
+ const gate = recordedGate(event.data.gate);
209
+ if (phase && Number.isFinite(startedAt)) {
210
+ live.set(event.taskId, { taskId: event.taskId, phase, startedAt, order, attempt, ...(gate ? { gate } : {}) });
211
+ }
196
212
  continue;
197
213
  }
198
214
  if (!event.taskId)
@@ -200,7 +216,7 @@ const livePhases = (events) => {
200
216
  const active = live.get(event.taskId);
201
217
  if (!active)
202
218
  continue;
203
- const gate = phaseGate(active.phase);
219
+ const gate = active.gate ?? phaseGate(active.phase);
204
220
  const matched = (active.phase === "worker" && event.event === "worker-result")
205
221
  || (active.phase === "gates" && event.event === "gate-result")
206
222
  || (gate !== undefined && event.event === "gate-result" && event.data.gate === gate)
@@ -215,7 +231,14 @@ const livePhases = (events) => {
215
231
  }
216
232
  return live;
217
233
  };
218
- const phaseLabel = (phase) => phase.startsWith("gate:") ? `gate ${phase.slice("gate:".length)}` : phase;
234
+ // A gate the journal named is rendered by that name. Only a phase the journal left ungated falls
235
+ // back to the phase word — the word alone cannot tell an acceptance round from a review one, since
236
+ // the daemon records both under a phase of its own choosing (phaseForGate).
237
+ const phaseLabel = (phase) => {
238
+ if (typeof phase !== "string")
239
+ return phase.gate ? `gate ${phase.gate}` : phaseLabel(phase.phase);
240
+ return phase.startsWith("gate:") ? `gate ${phase.slice("gate:".length)}` : phase;
241
+ };
219
242
  const fmtElapsed = (ms) => {
220
243
  const seconds = Math.max(0, Math.floor(ms / 1000));
221
244
  if (seconds < 60)
@@ -230,7 +253,7 @@ const workerOutputAge = (phase, now, worker) => now - (worker?.phaseStartedAt ==
230
253
  const phaseDetail = (phase, now, worker) => {
231
254
  const elapsed = fmtElapsed(now - phase.startedAt);
232
255
  if (phase.phase !== "worker")
233
- return `${phaseLabel(phase.phase)} · ${elapsed} elapsed`;
256
+ return `${phaseLabel(phase)} · ${elapsed} elapsed`;
234
257
  const age = fmtElapsed(workerOutputAge(phase, now, worker));
235
258
  const output = worker?.phaseStartedAt === phase.startedAt && worker.hasOutput
236
259
  ? `last output ${age} ago`
@@ -335,6 +358,26 @@ export const shortGoal = (goal, max) => {
335
358
  return fitCells(clause, cells).trimEnd();
336
359
  return `${fitCells(clause, cells - 3).trimEnd()}...`;
337
360
  };
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);
372
+ }
373
+ return compose();
374
+ };
375
+ /**
376
+ * Every board line leaves through the cockpit's own wrap, measured in display cells: no drawn line
377
+ * exceeds the board's columns, and nothing is shortened into a count to get there. Blank separators
378
+ * stay blank — `wrapCells` always answers at least one row.
379
+ */
380
+ const boardRows = (lines, columns) => lines.flatMap((line) => wrapCells(line, columns, { continuationPrefix: " " }));
338
381
  // VIS-11 (v1.13): a liveness header for renderFrame — last journal event age + whether the recorded
339
382
  // daemon pid is still alive. Honest about unknowns: a pre-v1.13 journal with no pid renders "unknown",
340
383
  // never fabricated (garbage pid data fails toward unknown too). kill(pid,0) is a signal probe, not a
@@ -446,15 +489,93 @@ const tipVerifyPhase = (events) => {
446
489
  };
447
490
  const tipVerifyText = (phase) => {
448
491
  if (phase.state === "pending")
449
- return "tip-verify: pending";
492
+ return "verify pending";
450
493
  const gates = phase.gates.length ? phase.gates.join(", ") : "gate unknown";
451
- const failed = `tip-verify: FAILED (${gates})`;
494
+ const failed = `verify FAILED (${gates})`;
452
495
  const reverify = phase.state === "re-verifying" ? ` → re-verifying (run attempt ${phase.attempt})` : "";
453
496
  const fingerprints = phase.fingerprints === undefined
454
497
  ? ""
455
498
  : ` · ${phase.fingerprints} fingerprint${phase.fingerprints === 1 ? "" : "s"}`;
456
499
  return `${failed}${reverify}${fingerprints}`;
457
500
  };
501
+ // The only tip-verification verdicts a run-end can RECORD. Anything else in that field — absent, a
502
+ // value carrying a newline or terminal controls, a word from some other vocabulary — records no
503
+ // verdict, and the surfaces say so instead of echoing it or counting `tip-verify` events to guess
504
+ // (an operator-local reimplementation counted them and scored two failed runs as verified).
505
+ const RECORDED_VERIFY = new Set(["passed", "failed"]);
506
+ const recordedVerify = (events) => {
507
+ for (let i = events.length - 1; i >= 0; i--) {
508
+ const event = events[i];
509
+ if (event.event !== "run-end")
510
+ continue;
511
+ const recorded = event.data.tipVerify;
512
+ return typeof recorded === "string" && RECORDED_VERIFY.has(recorded)
513
+ ? recorded
514
+ : "unrecorded";
515
+ }
516
+ return "unrecorded"; // no run-end: this run's record names no verdict yet
517
+ };
518
+ /**
519
+ * What the board may CLAIM about tip verification, in one place for both surfaces.
520
+ *
521
+ * An incomparable journal yields no claim about this graph at all, so it reads `unavailable` rather
522
+ * than the recorded verdict. A live phase (failed, re-verifying, pending) keeps its own richer
523
+ * phrase. Otherwise the run-end field is READ: `passed` is stated positively — silence used to be
524
+ * the only sign of a passed tip, which reads identically to a surface that simply hides it — and a
525
+ * record that ends without naming a verdict is `unrecorded`. A run still in flight claims nothing.
526
+ */
527
+ const verifyClaim = (events, comparable, tipPhase) => {
528
+ if (!comparable)
529
+ return "verify unavailable";
530
+ if (tipPhase)
531
+ return tipVerifyText(tipPhase);
532
+ if (!runHasEnded(events))
533
+ return undefined;
534
+ return `verify ${recordedVerify(events)}`;
535
+ };
536
+ /**
537
+ * Where an operator can still look, per task — folded from the daemon's OWN pane authority
538
+ * (`desiredPanes`, the reconcile fold that decides which owned panes should exist right now) and
539
+ * the visibility setting that governs when a worker pane closes. A locator built from dispatch
540
+ * history alone survives the events that retire a pane — `task-done` closes a merged task's worker
541
+ * pane, `run-end` retires every pane of the run — and sends the operator to a pane that is gone.
542
+ *
543
+ * Under `keepPanes: "attempt"` the daemon closes a worker pane as soon as it harvests that attempt,
544
+ * before the gates run and before `worker-result` is journaled (daemon.ts), so a harvested attempt
545
+ * has no pane left however desirable the fold finds it.
546
+ */
547
+ const workerPaneLocators = (events, runId, keepPanes) => {
548
+ const locators = new Map();
549
+ for (const name of desiredPanes(events, runId)) {
550
+ const owned = parseOwnedName(name);
551
+ if (owned?.role === "worker")
552
+ locators.set(owned.taskId, name);
553
+ }
554
+ if (keepPanes !== "attempt")
555
+ return locators;
556
+ const harvested = new Set();
557
+ for (const event of events) {
558
+ if (!event.taskId)
559
+ continue;
560
+ if (event.event === "task-dispatch")
561
+ harvested.delete(event.taskId);
562
+ else if (event.event === "worker-result")
563
+ harvested.add(event.taskId);
564
+ }
565
+ for (const taskId of harvested)
566
+ locators.delete(taskId);
567
+ return locators;
568
+ };
569
+ // status is a reader of the repo it reports on: the same resolved config every other command loads.
570
+ // A config that will not load is not a reason to fail a frame — the shipped default keeps the fold.
571
+ const keepPanesSetting = (cwd) => {
572
+ try {
573
+ return loadConfig(cwd).visibility.keepPanes;
574
+ }
575
+ catch {
576
+ return DEFAULT_CONFIG.visibility.keepPanes;
577
+ }
578
+ };
458
579
  const liveness = (events, now = Date.now()) => {
459
580
  const last = events.at(-1);
460
581
  if (!last)
@@ -507,58 +628,82 @@ const parseJournalSnapshot = (raw) => raw.split("\n").flatMap((line) => {
507
628
  return [];
508
629
  }
509
630
  });
631
+ /**
632
+ * ONE journal read per answer, and every claim about the run folded from THOSE bytes — the board's
633
+ * task rows, activity, phases, gates, liveness and tip verification, and the compact one-line form
634
+ * alike. A second read is what lets two snapshots be presented as one state: a line the daemon
635
+ * appends between them pairs a task count from one instant with a verdict from another.
636
+ *
637
+ * An explicit <runId> is a resolution, not a hint: `Journal.open` refuses an id without a readable
638
+ * journal, so status fails loudly naming that id instead of rendering any other run.
639
+ */
640
+ const readRunRecord = (cwd, graph, namedRunId) => {
641
+ const runId = namedRunId ?? Journal.latestRunId(cwd, { withJournal: true });
642
+ if (!runId)
643
+ return undefined;
644
+ const raw = readFileSync(join(Journal.open(cwd, runId).dir, "journal.jsonl"), "utf8");
645
+ const events = parseJournalSnapshot(raw);
646
+ // The resume comparator is the fail-closed baseline; a matching graph-rehash is the daemon's
647
+ // append-only audit that authorizes this status replay after stop-amend-resume.
648
+ const engagement = statusEngagement(events, graphDefinitionHash(graph));
649
+ return {
650
+ runId,
651
+ raw,
652
+ events,
653
+ comparable: engagement.comparable,
654
+ ...(engagement.rehashAt === undefined ? {} : { rehashAt: engagement.rehashAt }),
655
+ };
656
+ };
657
+ /**
658
+ * The cockpit fold over those same bytes — the journal-backed task authority both forms read.
659
+ *
660
+ * The daemon creates journal.jsonl with lock-reclaimed before run-start, and a reader may also
661
+ * catch an empty or torn first write. Those snapshots own no task fold yet; once run-start exists,
662
+ * however, a fold failure is unexpected and must surface instead of becoming a false empty run.
663
+ */
664
+ const recordTaskRows = (record, graph) => record.comparable && record.events.some((event) => event.event === "run-start")
665
+ ? new Map(deriveRunCockpitData({ fileName: `${record.runId}.journal.jsonl`, raw: record.raw }, "status", { graph }).taskRows.map((row) => [row.taskId, row]))
666
+ : new Map();
667
+ /** The graph tasks this run's record actually speaks about — a row with no recorded fact is silence. */
668
+ const recordedTasks = (graph, rows) => graph.tasks.filter((task) => {
669
+ const row = rows.get(task.id);
670
+ return row !== undefined && (row.state !== undefined
671
+ || row.lastEventTime !== undefined
672
+ || row.attempts !== undefined
673
+ || row.actor !== undefined
674
+ || row.merged === true
675
+ || row.parkKind !== undefined);
676
+ });
677
+ const recordedDone = (tasks, rows) => tasks.filter((task) => graphTaskStatus(rows.get(task.id)?.state, task.status) === "done").length;
510
678
  const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness = new Map(), journalRowsOnly = false, namedRunId) => {
511
679
  const g = loadGraph(cwd);
512
- // An explicit <runId> is a resolution, not a hint: Journal.open below refuses an id without a
513
- // readable journal, so status fails loudly naming that id instead of rendering any other run.
514
- const runId = namedRunId ?? Journal.latestRunId(cwd, { withJournal: true });
680
+ const record = readRunRecord(cwd, g, namedRunId);
681
+ const runId = record?.runId;
682
+ const events = record?.events ?? [];
683
+ const comparable = record?.comparable ?? false;
684
+ const rehashAt = record?.rehashAt;
515
685
  const assignments = new Map();
516
686
  let replayed = null;
517
- let cockpit;
518
- let events = [];
519
687
  const contexts = new Map();
520
- let comparable = false;
521
- let rehashAt;
522
- let supersededBy; // v1.53 T5: this run is dead — a newer run replaced it
523
- if (runId) {
524
- const j = Journal.open(cwd, runId);
525
- // ONE byte snapshot per frame. The cockpit fold and every decoration below — foldActivity,
526
- // livePhases, tipVerifyPhase, gateSnapshot, the failure and liveness readings — fold THESE
527
- // bytes and nothing else, so a line the daemon appends mid-frame can never pair newer task
528
- // rows against older activity, phases, gates or tip state. `Journal.open` refuses a run
529
- // without a journal, so the file is there to read.
530
- const raw = readFileSync(join(j.dir, "journal.jsonl"), "utf8");
531
- events = parseJournalSnapshot(raw);
532
- // The daemon creates journal.jsonl with lock-reclaimed before run-start and a reader may also
533
- // catch an empty/torn first write. Those snapshots own no task fold yet; once run-start exists,
534
- // however, a fold failure is unexpected and must surface instead of becoming a false empty run.
535
- if (journalRowsOnly && events.some((event) => event.event === "run-start")) {
536
- cockpit = deriveRunCockpitData({ fileName: `${runId}.journal.jsonl`, raw }, "status", { graph: g });
537
- }
538
- const sup = [...events].reverse().find((e) => e.event === "superseded" && typeof e.data.by === "string");
539
- supersededBy = sup?.data.by;
540
- // The resume comparator is the fail-closed baseline; a matching graph-rehash is the daemon's
541
- // append-only audit that authorizes this status replay after stop-amend-resume.
542
- const engagement = statusEngagement(events, graphDefinitionHash(g));
543
- comparable = engagement.comparable;
544
- rehashAt = engagement.rehashAt;
545
- if (comparable) {
546
- if (!journalRowsOnly)
547
- replayed = j.replayStatuses();
548
- for (const e of events) {
549
- if (!journalRowsOnly && e.event === "task-dispatch" && e.taskId) {
550
- const a = e.data.assignment;
551
- if (typeof a.adapter === "string" && typeof a.model === "string") {
552
- assignments.set(e.taskId, `${a.adapter}:${a.model}`);
553
- }
554
- }
555
- if (e.event === "context-sample" && e.taskId && typeof e.data.tokens === "number" && Number.isFinite(e.data.tokens)) {
556
- contexts.set(e.taskId, e.data.tokens); // last write wins
688
+ // v1.53 T5: this run is dead — a newer run replaced it
689
+ const supersededBy = [...events].reverse()
690
+ .find((e) => e.event === "superseded" && typeof e.data.by === "string")?.data.by;
691
+ if (record && comparable) {
692
+ if (!journalRowsOnly)
693
+ replayed = Journal.open(cwd, record.runId).replayStatuses();
694
+ for (const e of events) {
695
+ if (!journalRowsOnly && e.event === "task-dispatch" && e.taskId) {
696
+ const a = e.data.assignment;
697
+ if (typeof a.adapter === "string" && typeof a.model === "string") {
698
+ assignments.set(e.taskId, `${a.adapter}:${a.model}`);
557
699
  }
558
700
  }
701
+ if (e.event === "context-sample" && e.taskId && typeof e.data.tokens === "number" && Number.isFinite(e.data.tokens)) {
702
+ contexts.set(e.taskId, e.data.tokens); // last write wins
703
+ }
559
704
  }
560
705
  }
561
- const taskRows = new Map(cockpit?.taskRows.map((row) => [row.taskId, row]) ?? []);
706
+ const taskRows = record && journalRowsOnly ? recordTaskRows(record, g) : new Map();
562
707
  const clockFallback = new Date(now);
563
708
  const recordedClock = new Date(events.at(-1)?.ts ?? now);
564
709
  const zoneReference = Number.isFinite(recordedClock.getTime()) ? recordedClock : clockFallback;
@@ -573,19 +718,7 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
573
718
  : task.status,
574
719
  })),
575
720
  };
576
- const renderedTasks = journalRowsOnly
577
- ? cockpit !== undefined && comparable
578
- ? g.tasks.filter((task) => {
579
- const row = taskRows.get(task.id);
580
- return row !== undefined && (row.state !== undefined
581
- || row.lastEventTime !== undefined
582
- || row.attempts !== undefined
583
- || row.actor !== undefined
584
- || row.merged === true
585
- || row.parkKind !== undefined);
586
- })
587
- : []
588
- : g.tasks;
721
+ const renderedTasks = journalRowsOnly ? recordedTasks(g, taskRows) : g.tasks;
589
722
  const starved = new Set(blockedTasks(effective).map((t) => t.id));
590
723
  // OBS-104: ONE activity fold feeds both surfaces — never re-derived here. Comparable events only
591
724
  // (a recompiled graph's journal must not animate the wrong tasks); with no or stale journal the
@@ -593,13 +726,21 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
593
726
  const activity = foldActivity(comparable ? events : [], effective.tasks);
594
727
  const unicode = visual();
595
728
  const divider = unicode ? " · " : " / ";
729
+ // SUP-01: derived by stat() alone, from THIS frame's clock. status stays a reader — nothing here
730
+ // creates, touches or reaps a beat file, so an absent supervision dir stays absent.
731
+ const supervision = readSupervision(cwd, now);
596
732
  const width = process.stdout.columns ?? 120;
597
733
  const done = journalRowsOnly
598
- ? renderedTasks.filter((task) => graphTaskStatus(taskRows.get(task.id)?.state, task.status) === "done").length
734
+ ? recordedDone(renderedTasks, taskRows)
599
735
  : effective.tasks.filter((task) => task.status === "done").length;
600
736
  const total = g.tasks.length;
601
737
  const ended = comparable && runHasEnded(events);
602
738
  const tipPhase = comparable ? tipVerifyPhase(events) : undefined;
739
+ const verify = runId ? verifyClaim(events, comparable, tipPhase) : undefined;
740
+ // Where an operator can still look: only panes this run's record leaves open (see workerPaneLocators).
741
+ const panes = runId && comparable
742
+ ? workerPaneLocators(events, runId, keepPanesSetting(cwd))
743
+ : new Map();
603
744
  const taskIds = new Set(g.tasks.map((task) => task.id));
604
745
  const phases = comparable ? livePhases(events) : new Map();
605
746
  for (const taskId of phases.keys())
@@ -632,24 +773,25 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
632
773
  const gates = comparable
633
774
  ? gateSnapshot(t, events, rehashAt)
634
775
  : { states: defaultGateStates(t), priorGraph: false };
635
- return { t, st, merged, failureKind, redTier, label, assignCol, isStarved, phrase, channel, ctx, livePhase, ...gates };
776
+ const pane = panes.get(t.id);
777
+ return { t, st, merged, failureKind, redTier, label, assignCol, isStarved, phrase, channel, ctx, livePhase, pane, ...gates };
636
778
  });
637
779
  if (!unicode) {
638
780
  // machine/CI surface — journals without phase-start stay byte-identical; new phase-aware frames
639
781
  // use an ASCII spinner so pipes never receive terminal-only braille/ANSI.
640
- const rows = cells.map(({ t, st, merged, label, assignCol, livePhase, states, priorGraph }) => {
782
+ const rows = cells.map(({ t, st, merged, label, assignCol, livePhase, states, priorGraph, pane }) => {
641
783
  const chain = gateChain(states, false);
642
784
  const prefix = livePhase ? ` ${ASCII_SPINNER[animationFrame % ASCII_SPINNER.length]} ${t.id} ` : ` ${surfaceTaskBox(st, merged)} ${t.id} `;
643
- const suffix = ` ${chain}${priorGraph ? ` ${PRIOR_GRAPH_MARKER}` : ""} ${livePhase ? "running" : surfaceStatusWord(st)}${label} ${assignCol}`;
785
+ const suffix = ` ${chain}${priorGraph ? ` ${PRIOR_GRAPH_MARKER}` : ""} ${livePhase ? "running" : surfaceStatusWord(st)}${label} ${assignCol}${pane ? ` pane ${pane}` : ""}`;
644
786
  return `${prefix}${shortGoal(t.title, Math.max(0, width - prefix.length - suffix.length))}${suffix}`;
645
787
  });
646
788
  const zone = journalRowsOnly ? `${divider}zone ${localZoneLabel(zoneReference)}` : "";
647
789
  const header = runId
648
- ? `tickmarkr status${divider}run ${runId}${zone}${supersededBy ? `${divider}superseded by ${supersededBy}` : ""}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events, now).replaceAll(" · ", divider)}${tipPhase ? `${divider}${tipVerifyText(tipPhase)}` : ""}${divider}${tipPhase ? `${done}/${total} tasks done${divider}run not verified` : `${done}/${total} done`}`
790
+ ? `tickmarkr status${divider}run ${runId}${zone}${supersededBy ? `${divider}superseded by ${supersededBy}` : ""}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events, now).replaceAll(" · ", divider)}${verify ? `${divider}${verify}` : ""}${divider}${tipPhase ? `${done}/${total} tasks done${divider}run not verified` : `${done}/${total} done`}`
649
791
  : `tickmarkr status${zone}${divider}no runs yet${divider}${done}/${total} done`;
650
792
  const legendLine = ` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(divider)}`;
651
793
  return {
652
- content: [header, legendLine, ...rows].join("\n"),
794
+ content: [header, legendLine, ` ${supervisionText(supervision, divider)}`, ...rows].join("\n"),
653
795
  ...(hotPhase ? { hotPhase } : {}),
654
796
  ...(runId ? { runId } : {}),
655
797
  workerPhases: [...phases.values()].filter((phase) => phase.phase === "worker"),
@@ -659,6 +801,10 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
659
801
  // dim chrome, semantic color only on verdicts, completion gauge, and column-aligned status
660
802
  // rows (pad plain text FIRST, colorize after: ANSI has zero display width and would corrupt
661
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.
807
+ const boardColumns = Math.max(COCKPIT_COLUMN_FLOOR, width);
662
808
  const dot = dim(" · ");
663
809
  const anyFailed = cells.some((c) => c.redTier);
664
810
  const gaugeCells = 10;
@@ -674,38 +820,51 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
674
820
  const tally = tipPhase
675
821
  ? `${progressTone(`${done}/${total} tasks done`)}${dot}${progressTone("run not verified")}`
676
822
  : `${done}/${total} done`;
677
- const tipStatus = tipPhase
678
- ? `${tipFailed ? fail(tipVerifyText(tipPhase)) : warn(tipVerifyText(tipPhase))}${dot}`
679
- : "";
680
- const zone = journalRowsOnly ? `zone ${localZoneLabel(zoneReference)}${dot}` : "";
681
- const header = ` ${title(runId ? `run ${runId}` : "tickmarkr")}${dot}${zone}` +
682
- (runId
683
- ? `${supersededBy ? `${warn(`superseded by ${supersededBy}`)}${dot}` : ""}${!comparable ? `${warn(NOT_COMPARABLE_NOTICE)}${dot}` : ""}${live}${dot}${tipStatus}`
684
- : `no runs yet${dot}`) +
685
- `${gauge} ${tipPhase ? tally : done === total && total > 0 ? ok(tally) : tally}`;
686
- const hr = rule(Math.min(width, 100));
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"]),
829
+ ...(runId && supersededBy ? [warn(`superseded by ${supersededBy}`)] : []),
830
+ ...(runId && !comparable ? [warn(NOT_COMPARABLE_NOTICE)] : []),
831
+ ...(verify === undefined
832
+ ? []
833
+ : [verify === "verify passed" ? ok(verify) : tipFailed ? fail(verify) : warn(verify)]),
834
+ `${gauge} ${tipPhase ? tally : done === total && total > 0 ? ok(tally) : tally}`,
835
+ ...(runId ? [live] : []),
836
+ ...(journalRowsOnly ? [`zone ${localZoneLabel(zoneReference)}`] : []),
837
+ ].join(dot)}`;
838
+ const hr = rule(Math.min(boardColumns, 100));
687
839
  // OBS-104 run-level now line: names the most recent journal event. TTY frame only — the non-TTY
688
840
  // machine surface is byte-pinned (status-brand golden) and must not drift. Rendered BELOW the task
689
841
  // rows: the line names task ids, and rows must stay the first id-bearing lines for grep consumers.
690
842
  const nowLine = activity.now ? [legend(` now: ${activity.now}`)] : [];
691
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
+ const supervisionLegend = legend(` ${supervisionText(supervision)}`);
692
846
  // Two-line card per task (operator request, v1.67): line 1 carries identity + verdict — glyph,
693
847
  // id, title at full width, and only SHORT status words. Line 2 carries the machinery, dim and
694
848
  // aligned under the title: gate chain, live activity phrase (or channel), ctx. Long channel names
695
849
  // and activity phrases live on line 2 only, so they can never squeeze the title or wrap line 1.
696
850
  const taskVerdict = (c) => c.merged ? "pass" : c.redTier ? "fail" : c.st === "failed" || c.st === "human" ? "warn" : "neutral";
697
851
  const statusWord = (c) => c.livePhase ? "running" : c.redTier ? "failed" : c.st === "failed" ? "warn" : surfaceStatusWord(c.st);
698
- const idW = Math.max(...cells.map((c) => c.t.id.length), 2);
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);
699
855
  // plain-text status suffixes for width math only — starved / failed gate names / approval hint
700
856
  const suffixPlain = (c) => (c.isStarved ? " · starved" : "") + failedSuffix(c.states)
701
857
  + humanGateSuffix(c.t, graphTaskStatus(c.st, c.t.status), c.states);
702
- const stW = Math.max(...cells.map((c) => statusWord(c).length + suffixPlain(c).length));
703
- const avail = Math.max(8, width - (5 + idW) - 2 - stW);
858
+ const stW = Math.max(...cells.map((c) => cellWidth(statusWord(c) + suffixPlain(c))));
859
+ const avail = Math.max(8, boardColumns - (5 + idW) - 2 - stW);
704
860
  const titles = cells.map((c) => shortGoal(c.t.title, avail));
705
861
  const titleW = Math.max(8, ...titles.map(cellWidth));
706
- const indent = " ".repeat(idW + 5); // line 2 starts under the title column
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)));
707
866
  const rows = cells.map((c, i) => {
708
- const { t, st, merged, failureKind, redTier, states, priorGraph, isStarved, phrase, channel, ctx, livePhase } = c;
867
+ const { t, st, merged, failureKind, redTier, states, priorGraph, isStarved, phrase, channel, ctx, livePhase, pane } = c;
709
868
  const word = statusWord(c);
710
869
  const staleWorker = livePhase?.phase === "worker"
711
870
  && workerOutputAge(livePhase, now, workerLiveness.get(t.id)) >= 60_000;
@@ -723,25 +882,68 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
723
882
  ? ` ${(staleWorker ? warn : dim)(SPINNER[animationFrame % SPINNER.length])} ${taskLabel}`
724
883
  : ` ${statusRow(taskVerdict(c), taskLabel)}`;
725
884
  // activity already names its channel for in-flight attempts — never repeat it
726
- const detail = [
727
- ...(priorGraph ? [PRIOR_GRAPH_MARKER] : []),
728
- ...(phrase ? [phrase, ...(phrase.includes(channel) || channel === "-" ? [] : [channel])] : [channel]),
729
- ...(failureKind && !phrase?.includes(failureKind) ? [failureKind] : []),
730
- ...(ctx !== undefined ? [`ctx ${ctx}`] : []),
731
- ].join(" · ");
732
- return [line1, `${indent}${gateChain(states, true)} ${dim(detail)}`, ""];
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, ""];
733
909
  }).flat();
734
910
  if (!nowLine.length)
735
911
  rows.pop(); // cards end blank-separated; drop the dangling one
736
912
  return {
737
- content: [header, hr, gatesLegend, "", ...rows, ...nowLine].join("\n"),
913
+ content: boardRows([header, hr, gatesLegend, supervisionLegend, "", ...rows, ...nowLine], boardColumns).join("\n"),
738
914
  ...(hotPhase ? { hotPhase } : {}),
739
915
  ...(runId ? { runId } : {}),
740
916
  workerPhases: [...phases.values()].filter((phase) => phase.phase === "worker"),
741
917
  };
742
918
  };
919
+ /**
920
+ * The compact form a statusline calls, so journal interpretation happens ONCE, inside the product.
921
+ * (An operator-local bash reimplementation counted `tip-verify` events instead of reading the
922
+ * run-end field and scored two runs whose tip verification had FAILED as verified.)
923
+ *
924
+ * One line, one journal read, and every word from the run's own record: the tally and the verdict
925
+ * are folded from the same bytes, so a completion appended between two reads can never be paired
926
+ * with a verdict the other read never saw. Plain text through the task-text sanitizer — a statusline
927
+ * embeds this, so no recorded value may carry a control byte or a row break into its host.
928
+ */
929
+ const oneLine = (cwd, namedRunId) => {
930
+ const graph = loadGraph(cwd);
931
+ const record = readRunRecord(cwd, graph, namedRunId);
932
+ if (!record)
933
+ return sanitizeTaskText(`tickmarkr · no runs yet · 0/${graph.tasks.length} done`);
934
+ const rows = recordTaskRows(record, graph);
935
+ const claims = record.comparable
936
+ ? [
937
+ `${recordedDone(recordedTasks(graph, rows), rows)}/${graph.tasks.length} done`,
938
+ `verify ${recordedVerify(record.events)}`,
939
+ ]
940
+ : [NOT_COMPARABLE_CLAIM, "verify unavailable"];
941
+ return sanitizeTaskText([record.runId, ...claims].join(" · "));
942
+ };
743
943
  export async function status(argv, cwd = process.cwd(), opts = {}) {
744
944
  const namedRunId = positionalRunId(argv);
945
+ if (argv.includes("--oneline"))
946
+ return oneLine(cwd, namedRunId);
745
947
  // cockpit surface: banner + frame on a TTY (doctor's pattern); pipes get the bare frame
746
948
  if (!argv.includes("--watch")) {
747
949
  const { content } = renderFrame(cwd, opts.now?.() ?? Date.now(), 0, undefined, false, namedRunId);
@@ -849,7 +1051,7 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
849
1051
  titleSaved = true;
850
1052
  process.once("exit", restoreTitle);
851
1053
  }
852
- process.stdout.write(`\x1b]0;⏳ ${hotPhase.taskId} ${phaseLabel(hotPhase.phase)} ${fmtElapsed(nowMs - hotPhase.startedAt)}\x07`);
1054
+ process.stdout.write(`\x1b]0;⏳ ${hotPhase.taskId} ${phaseLabel(hotPhase)} ${fmtElapsed(nowMs - hotPhase.startedAt)}\x07`);
853
1055
  };
854
1056
  try {
855
1057
  for (let i = 0; i < iterations; i++) {
@@ -0,0 +1,9 @@
1
+ import { type Assignment, type BillingChannel } from "../../adapters/types.js";
2
+ import { type AcceptanceItem } from "../../graph/schema.js";
3
+ export declare function parseCriteria(text: string): AcceptanceItem[];
4
+ export declare const HUMAN_CHANNEL: BillingChannel;
5
+ export declare const HUMAN_AUTHOR: Assignment;
6
+ export declare function verify(argv: string[], cwd?: string): Promise<{
7
+ out: string;
8
+ code: number;
9
+ }>;