tickmarkr 1.84.0 → 1.86.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 (87) hide show
  1. package/README.md +4 -2
  2. package/dist/adapters/catalog-remote.d.ts +64 -0
  3. package/dist/adapters/catalog-remote.js +287 -0
  4. package/dist/adapters/catalog.d.ts +96 -0
  5. package/dist/adapters/catalog.js +176 -0
  6. package/dist/adapters/claude-code.d.ts +1 -0
  7. package/dist/adapters/claude-code.js +59 -1
  8. package/dist/adapters/fake.js +42 -4
  9. package/dist/adapters/model-lints.d.ts +25 -5
  10. package/dist/adapters/model-lints.js +184 -50
  11. package/dist/adapters/model-windows.d.ts +31 -0
  12. package/dist/adapters/model-windows.js +69 -0
  13. package/dist/adapters/prompt.d.ts +5 -1
  14. package/dist/adapters/prompt.js +13 -4
  15. package/dist/adapters/registry.d.ts +25 -26
  16. package/dist/adapters/registry.js +173 -110
  17. package/dist/adapters/types.d.ts +3 -0
  18. package/dist/adapters/types.js +36 -3
  19. package/dist/brand.d.ts +5 -1
  20. package/dist/brand.js +18 -2
  21. package/dist/cli/commands/doctor.d.ts +3 -0
  22. package/dist/cli/commands/doctor.js +43 -21
  23. package/dist/cli/commands/fleet.d.ts +7 -0
  24. package/dist/cli/commands/fleet.js +94 -74
  25. package/dist/cli/commands/init.js +118 -5
  26. package/dist/cli/commands/status.js +202 -46
  27. package/dist/compile/collateral.d.ts +86 -2
  28. package/dist/compile/collateral.js +294 -3
  29. package/dist/compile/gsd.d.ts +2 -1
  30. package/dist/compile/gsd.js +68 -2
  31. package/dist/compile/native.d.ts +14 -0
  32. package/dist/compile/native.js +161 -12
  33. package/dist/config/config.d.ts +82 -5
  34. package/dist/config/config.js +253 -66
  35. package/dist/config/fleet-overlay.d.ts +25 -20
  36. package/dist/config/fleet-overlay.js +195 -77
  37. package/dist/config/fleet-why.d.ts +23 -0
  38. package/dist/config/fleet-why.js +42 -0
  39. package/dist/drivers/herdr.d.ts +21 -3
  40. package/dist/drivers/herdr.js +344 -110
  41. package/dist/gates/acceptance.js +7 -2
  42. package/dist/gates/baseline.d.ts +1 -0
  43. package/dist/gates/baseline.js +91 -13
  44. package/dist/gates/llm.d.ts +0 -1
  45. package/dist/gates/llm.js +5 -30
  46. package/dist/gates/review.d.ts +9 -1
  47. package/dist/gates/review.js +105 -10
  48. package/dist/gates/run-gates.d.ts +9 -0
  49. package/dist/gates/run-gates.js +285 -41
  50. package/dist/gates/verdict-cause.d.ts +4 -0
  51. package/dist/gates/verdict-cause.js +63 -0
  52. package/dist/graph/schema.d.ts +6 -0
  53. package/dist/graph/schema.js +8 -5
  54. package/dist/route/router.d.ts +0 -5
  55. package/dist/route/router.js +16 -20
  56. package/dist/run/consult.d.ts +6 -0
  57. package/dist/run/consult.js +35 -25
  58. package/dist/run/daemon.d.ts +48 -2
  59. package/dist/run/daemon.js +1488 -330
  60. package/dist/run/journal.d.ts +56 -3
  61. package/dist/run/journal.js +358 -4
  62. package/dist/run/stall.d.ts +35 -1
  63. package/dist/run/stall.js +118 -8
  64. package/dist/tui/cockpit/capture.d.ts +12 -0
  65. package/dist/tui/cockpit/capture.js +37 -1
  66. package/dist/tui/cockpit/components.d.ts +2 -0
  67. package/dist/tui/cockpit/components.js +8 -8
  68. package/dist/tui/cockpit/derive.d.ts +29 -2
  69. package/dist/tui/cockpit/derive.js +219 -23
  70. package/dist/tui/cockpit/run-cockpit.js +128 -27
  71. package/dist/tui/cockpit/theme.d.ts +32 -26
  72. package/dist/tui/cockpit/theme.js +11 -5
  73. package/dist/tui/ink/components.d.ts +0 -15
  74. package/dist/tui/ink/components.js +0 -17
  75. package/dist/tui/ink/fleet-app.d.ts +4 -1
  76. package/dist/tui/ink/fleet-app.js +134 -13
  77. package/fixtures/sample.native.md +1 -1
  78. package/package.json +1 -1
  79. package/skills/tickmarkr-overseer/SKILL.md +354 -34
  80. package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +70 -0
  81. package/skills/tickmarkr-overseer/scripts/watch-panes.sh +1 -1
  82. package/dist/tui/ink/studio-app.d.ts +0 -59
  83. package/dist/tui/ink/studio-app.js +0 -320
  84. package/dist/tui/save.d.ts +0 -38
  85. package/dist/tui/save.js +0 -96
  86. package/dist/tui/staging.d.ts +0 -29
  87. package/dist/tui/staging.js +0 -78
@@ -1,3 +1,5 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
1
3
  import { BANNER, GLYPHS, dim, fail, legend, ok, rule, statusRow, title, warn } from "../../brand.js";
2
4
  import { HerdrDriver } from "../../drivers/herdr.js";
3
5
  import { formatOwnedName, } from "../../drivers/types.js";
@@ -6,10 +8,53 @@ import { GATE_NAMES } from "../../graph/schema.js";
6
8
  import { foldActivity } from "../../run/activity.js";
7
9
  import { Journal, engagementComparable, isQualityFailureParkKind, recordedTaskFailureKind, runHasEnded, } from "../../run/journal.js";
8
10
  import { normalizeStallSnapshot } from "../../run/stall.js";
11
+ import { deriveRunCockpitData, } from "../../tui/cockpit/derive.js";
12
+ import { cellWidth, fitCells } from "../../tui/cockpit/width.js";
9
13
  // ponytail: fixed 2s refresh; promote to config.visibility.* only when an operator asks.
10
14
  const REFRESH_MS = 2000;
11
15
  const NOT_COMPARABLE_NOTICE = "graph recompiled since this run — task states not comparable; resume with `--graph-changed` to audit this recompile";
12
16
  const PRIOR_GRAPH_MARKER = "prior graph";
17
+ // One compact host-zone label per screen. Each event below is still converted from its own
18
+ // complete UTC instant, so the zone's historical rules — including a transition within one frame
19
+ // — stay intact rather than being rendered through this label's single sampled offset.
20
+ const localZoneLabel = (reference = new Date()) => {
21
+ const offset = -reference.getTimezoneOffset();
22
+ if (offset === 0)
23
+ return "UTC";
24
+ const sign = offset < 0 ? "-" : "+";
25
+ const hours = String(Math.floor(Math.abs(offset) / 60)).padStart(2, "0");
26
+ const minutes = Math.abs(offset) % 60;
27
+ return minutes === 0
28
+ ? `${sign}${hours}`
29
+ : `${sign}${hours}:${String(minutes).padStart(2, "0")}`;
30
+ };
31
+ const localClockText = (content, reference = new Date()) => {
32
+ return content.replace(/\b([01]\d|2[0-3]):([0-5]\d):([0-5]\d)\b/gu, (_whole, h, m, s) => {
33
+ const instant = new Date(Date.UTC(reference.getUTCFullYear(), reference.getUTCMonth(), reference.getUTCDate(), Number(h), Number(m), Number(s)));
34
+ return [instant.getHours(), instant.getMinutes(), instant.getSeconds()]
35
+ .map((part) => String(part).padStart(2, "0"))
36
+ .join(":");
37
+ });
38
+ };
39
+ /** The complete journal instant behind a folded clock phrase, not a screen-wide offset sample. */
40
+ const taskClockReference = (events, taskId, content, fallback) => {
41
+ const utcClock = content.match(/\b(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d\b/u)?.[0];
42
+ if (utcClock === undefined)
43
+ return fallback;
44
+ for (let index = events.length - 1; index >= 0; index -= 1) {
45
+ const event = events[index];
46
+ if (event.taskId !== taskId || event.ts.slice(11, 19) !== utcClock)
47
+ continue;
48
+ const instant = new Date(event.ts);
49
+ if (Number.isFinite(instant.getTime()))
50
+ return instant;
51
+ }
52
+ return fallback;
53
+ };
54
+ // Every attempt number a cell phrase carries is the engagement's own ladder (foldActivity counts
55
+ // dispatch labels from 1). Name that ruler wherever a bare one appears, not only in the one phrase
56
+ // shipping today — a phrase added upstream must not reach the operator unruled.
57
+ const labelAttemptRuler = (text) => text.replace(/(?<!engagement |run )\battempt (?=\d)/gu, "engagement attempt ");
13
58
  // The timer must keep the process ALIVE: an unref'd timer here let the event loop drain after the
14
59
  // first frame, so a live `--watch` printed once and exited 0 (OBS-11). Never unref this.
15
60
  const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -88,6 +133,20 @@ const optionValue = (argv, name) => {
88
133
  const value = index >= 0 ? argv[index + 1] : undefined;
89
134
  return value && !value.startsWith("-") ? value : undefined;
90
135
  };
136
+ // The one positional this command takes: an explicit run id. Everything else is a flag or a flag's
137
+ // value (`--webhook <url>`), so the first bare token that is not a --webhook value is the run id.
138
+ const positionalRunId = (argv) => {
139
+ for (let i = 0; i < argv.length; i++) {
140
+ const arg = argv[i];
141
+ if (arg === "--webhook") {
142
+ i += 1;
143
+ continue;
144
+ }
145
+ if (!arg.startsWith("-"))
146
+ return arg;
147
+ }
148
+ return undefined;
149
+ };
91
150
  const defaultPostWebhook = (url, event) => fetch(url, {
92
151
  method: "POST",
93
152
  headers: { "content-type": "application/json" },
@@ -196,6 +255,25 @@ export const taskBox = (status) => {
196
255
  return "[!]";
197
256
  return "[ ]";
198
257
  };
258
+ const graphTaskStatus = (state, fallback) => {
259
+ if (state === "completed" || state === "done")
260
+ return "done";
261
+ if (state === "interrupted")
262
+ return "failed";
263
+ return state ?? fallback;
264
+ };
265
+ const surfaceStatusWord = (state) => {
266
+ if (state === "human")
267
+ return "parked";
268
+ return state;
269
+ };
270
+ const surfaceTaskBox = (state, merged) => {
271
+ if (merged)
272
+ return "[x]";
273
+ if (state === "failed" || state === "human")
274
+ return "[!]";
275
+ return "[ ]";
276
+ };
199
277
  export const gateBox = (state, unicode) => {
200
278
  if (unicode) {
201
279
  // shared glyph vocabulary: pass/fail verdicts, dash for skip, dim circle for not-yet-run
@@ -244,13 +322,18 @@ const failedSuffix = (states) => {
244
322
  // cell when present: a post-approval park is not awaiting the designed gate. Plain text for column
245
323
  // math; rendered with a dim dot + warn words. TTY-only — the non-TTY surface stays byte-pinned.
246
324
  export const humanGateSuffix = (t, st, states) => st === "human" && t.humanGate && failedGates(states).length === 0 ? " · awaiting approval" : "";
325
+ // Task titles are graph-authored data. Remove terminal controls and row-breaking whitespace before
326
+ // either width measurement or rendering so zero-cell ECMA-48 bytes cannot escape the task column.
327
+ const ECMA_48_CONTROL = /(?:\u001B\][\s\S]*?(?:\u0007|\u001B\\)|\u009D[\s\S]*?(?:\u0007|\u009C)|\u001B[P^_X][\s\S]*?\u001B\\|[\u0090\u0098\u009E\u009F][\s\S]*?\u009C|(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]|\u001B[@-_])/gu;
328
+ const sanitizeTaskText = (text) => text.replace(ECMA_48_CONTROL, "").replace(/\p{Cc}/gu, " ").replace(/\s+/gu, " ").trim();
247
329
  export const shortGoal = (goal, max) => {
248
- const clause = goal.split(/[,;.?!]/, 1)[0].trim();
249
- if (clause.length <= max)
330
+ const clause = sanitizeTaskText(goal).split(/[,;.?!]/, 1)[0].trim();
331
+ const cells = Math.max(0, Math.floor(max));
332
+ if (cellWidth(clause) <= cells)
250
333
  return clause;
251
- if (max <= 3)
252
- return clause.slice(0, Math.max(0, max));
253
- return `${clause.slice(0, max - 3).trimEnd()}...`;
334
+ if (cells <= 3)
335
+ return fitCells(clause, cells).trimEnd();
336
+ return `${fitCells(clause, cells - 3).trimEnd()}...`;
254
337
  };
255
338
  // VIS-11 (v1.13): a liveness header for renderFrame — last journal event age + whether the recorded
256
339
  // daemon pid is still alive. Honest about unknowns: a pre-v1.13 journal with no pid renders "unknown",
@@ -366,7 +449,7 @@ const tipVerifyText = (phase) => {
366
449
  return "tip-verify: pending";
367
450
  const gates = phase.gates.length ? phase.gates.join(", ") : "gate unknown";
368
451
  const failed = `tip-verify: FAILED (${gates})`;
369
- const reverify = phase.state === "re-verifying" ? ` → re-verifying (attempt ${phase.attempt})` : "";
452
+ const reverify = phase.state === "re-verifying" ? ` → re-verifying (run attempt ${phase.attempt})` : "";
370
453
  const fingerprints = phase.fingerprints === undefined
371
454
  ? ""
372
455
  : ` · ${phase.fingerprints} fingerprint${phase.fingerprints === 1 ? "" : "s"}`;
@@ -412,11 +495,26 @@ const statusEngagement = (events, loadedHash) => {
412
495
  }
413
496
  return { comparable: baseline.comparable };
414
497
  };
415
- const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness = new Map()) => {
498
+ // The journal's own reader rule (src/run/journal.ts readJsonl), applied to bytes already in hand:
499
+ // skip blanks, drop a line that will not parse (a torn trailing write after a crash), keep the rest.
500
+ const parseJournalSnapshot = (raw) => raw.split("\n").flatMap((line) => {
501
+ if (!line.trim())
502
+ return [];
503
+ try {
504
+ return [JSON.parse(line)];
505
+ }
506
+ catch {
507
+ return [];
508
+ }
509
+ });
510
+ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness = new Map(), journalRowsOnly = false, namedRunId) => {
416
511
  const g = loadGraph(cwd);
417
- const runId = Journal.latestRunId(cwd, { withJournal: true });
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 });
418
515
  const assignments = new Map();
419
516
  let replayed = null;
517
+ let cockpit;
420
518
  let events = [];
421
519
  const contexts = new Map();
422
520
  let comparable = false;
@@ -424,7 +522,19 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
424
522
  let supersededBy; // v1.53 T5: this run is dead — a newer run replaced it
425
523
  if (runId) {
426
524
  const j = Journal.open(cwd, runId);
427
- events = j.read();
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
+ }
428
538
  const sup = [...events].reverse().find((e) => e.event === "superseded" && typeof e.data.by === "string");
429
539
  supersededBy = sup?.data.by;
430
540
  // The resume comparator is the fail-closed baseline; a matching graph-rehash is the daemon's
@@ -433,12 +543,14 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
433
543
  comparable = engagement.comparable;
434
544
  rehashAt = engagement.rehashAt;
435
545
  if (comparable) {
436
- replayed = j.replayStatuses();
546
+ if (!journalRowsOnly)
547
+ replayed = j.replayStatuses();
437
548
  for (const e of events) {
438
- if (e.event === "task-dispatch" && e.taskId) {
549
+ if (!journalRowsOnly && e.event === "task-dispatch" && e.taskId) {
439
550
  const a = e.data.assignment;
440
- if (typeof a.adapter === "string" && typeof a.model === "string")
551
+ if (typeof a.adapter === "string" && typeof a.model === "string") {
441
552
  assignments.set(e.taskId, `${a.adapter}:${a.model}`);
553
+ }
442
554
  }
443
555
  if (e.event === "context-sample" && e.taskId && typeof e.data.tokens === "number" && Number.isFinite(e.data.tokens)) {
444
556
  contexts.set(e.taskId, e.data.tokens); // last write wins
@@ -446,7 +558,34 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
446
558
  }
447
559
  }
448
560
  }
449
- const effective = { ...g, tasks: g.tasks.map((t) => ({ ...t, status: replayed?.get(t.id) ?? t.status })) };
561
+ const taskRows = new Map(cockpit?.taskRows.map((row) => [row.taskId, row]) ?? []);
562
+ const clockFallback = new Date(now);
563
+ const recordedClock = new Date(events.at(-1)?.ts ?? now);
564
+ const zoneReference = Number.isFinite(recordedClock.getTime()) ? recordedClock : clockFallback;
565
+ const effective = {
566
+ ...g,
567
+ tasks: g.tasks.map((task) => ({
568
+ ...task,
569
+ status: comparable
570
+ ? journalRowsOnly
571
+ ? graphTaskStatus(taskRows.get(task.id)?.state, task.status)
572
+ : replayed?.get(task.id) ?? task.status
573
+ : task.status,
574
+ })),
575
+ };
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;
450
589
  const starved = new Set(blockedTasks(effective).map((t) => t.id));
451
590
  // OBS-104: ONE activity fold feeds both surfaces — never re-derived here. Comparable events only
452
591
  // (a recompiled graph's journal must not animate the wrong tasks); with no or stale journal the
@@ -455,7 +594,10 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
455
594
  const unicode = visual();
456
595
  const divider = unicode ? " · " : " / ";
457
596
  const width = process.stdout.columns ?? 120;
458
- const done = effective.tasks.filter((t) => t.status === "done").length;
597
+ const done = journalRowsOnly
598
+ ? renderedTasks.filter((task) => graphTaskStatus(taskRows.get(task.id)?.state, task.status) === "done").length
599
+ : effective.tasks.filter((task) => task.status === "done").length;
600
+ const total = g.tasks.length;
459
601
  const ended = comparable && runHasEnded(events);
460
602
  const tipPhase = comparable ? tipVerifyPhase(events) : undefined;
461
603
  const taskIds = new Set(g.tasks.map((task) => task.id));
@@ -464,8 +606,10 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
464
606
  if (!taskIds.has(taskId))
465
607
  phases.delete(taskId);
466
608
  const hotPhase = [...phases.values()].sort((a, b) => a.order - b.order).at(-1);
467
- const cells = g.tasks.map((t) => {
468
- const st = replayed?.get(t.id) ?? t.status;
609
+ const cells = renderedTasks.map((t) => {
610
+ const folded = journalRowsOnly && comparable ? taskRows.get(t.id) : undefined;
611
+ const st = folded?.state ?? replayed?.get(t.id) ?? t.status;
612
+ const merged = journalRowsOnly ? folded?.merged === true : st === "done" || st === "completed";
469
613
  const failureKind = comparable ? recordedTaskFailureKind(events, t.id) : undefined;
470
614
  const terminal = st === "failed" || st === "human";
471
615
  // Unknown legacy task-failed events stay red. Typed availability noise is warn-tier only while
@@ -475,28 +619,34 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
475
619
  || (st === "failed" && failureKind === undefined));
476
620
  const livePhase = phases.get(t.id);
477
621
  const isStarved = !livePhase && starved.has(t.id);
478
- const phrase = livePhase ? phaseDetail(livePhase, now, workerLiveness.get(t.id)) : isStarved ? undefined : activity.cells.get(t.id);
622
+ const rawPhrase = livePhase ? phaseDetail(livePhase, now, workerLiveness.get(t.id)) : isStarved ? undefined : activity.cells.get(t.id);
623
+ const phrase = rawPhrase === undefined
624
+ ? undefined
625
+ : journalRowsOnly
626
+ ? labelAttemptRuler(localClockText(rawPhrase, taskClockReference(events, t.id, rawPhrase, clockFallback)))
627
+ : rawPhrase;
479
628
  const label = isStarved ? " starved" : phrase ? ` ${phrase}` : "";
480
- const channel = assignments.get(t.id) ?? "-";
629
+ const channel = journalRowsOnly ? folded?.actor ?? "-" : assignments.get(t.id) ?? "-";
481
630
  const ctx = contexts.get(t.id);
482
631
  const assignCol = ctx !== undefined ? `${channel}${divider}ctx ${ctx}` : channel;
483
632
  const gates = comparable
484
633
  ? gateSnapshot(t, events, rehashAt)
485
634
  : { states: defaultGateStates(t), priorGraph: false };
486
- return { t, st, failureKind, redTier, label, assignCol, isStarved, phrase, channel, ctx, livePhase, ...gates };
635
+ return { t, st, merged, failureKind, redTier, label, assignCol, isStarved, phrase, channel, ctx, livePhase, ...gates };
487
636
  });
488
637
  if (!unicode) {
489
638
  // machine/CI surface — journals without phase-start stay byte-identical; new phase-aware frames
490
639
  // use an ASCII spinner so pipes never receive terminal-only braille/ANSI.
491
- const rows = cells.map(({ t, st, label, assignCol, livePhase, states, priorGraph }) => {
640
+ const rows = cells.map(({ t, st, merged, label, assignCol, livePhase, states, priorGraph }) => {
492
641
  const chain = gateChain(states, false);
493
- const prefix = livePhase ? ` ${ASCII_SPINNER[animationFrame % ASCII_SPINNER.length]} ${t.id} ` : ` ${taskBox(st)} ${t.id} `;
494
- const suffix = ` ${chain}${priorGraph ? ` ${PRIOR_GRAPH_MARKER}` : ""} ${livePhase ? "running" : String(st)}${label} ${assignCol}`;
495
- return `${prefix}${shortGoal(t.goal, Math.max(0, width - prefix.length - suffix.length))}${suffix}`;
642
+ 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}`;
644
+ return `${prefix}${shortGoal(t.title, Math.max(0, width - prefix.length - suffix.length))}${suffix}`;
496
645
  });
646
+ const zone = journalRowsOnly ? `${divider}zone ${localZoneLabel(zoneReference)}` : "";
497
647
  const header = runId
498
- ? `tickmarkr status${divider}run ${runId}${supersededBy ? `${divider}superseded by ${supersededBy}` : ""}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events, now).replaceAll(" · ", divider)}${tipPhase ? `${divider}${tipVerifyText(tipPhase)}` : ""}${divider}${tipPhase ? `${done}/${g.tasks.length} tasks done${divider}run not verified` : `${done}/${g.tasks.length} done`}`
499
- : `tickmarkr status${divider}no runs yet${divider}${done}/${g.tasks.length} done`;
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`}`
649
+ : `tickmarkr status${zone}${divider}no runs yet${divider}${done}/${total} done`;
500
650
  const legendLine = ` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(divider)}`;
501
651
  return {
502
652
  content: [header, legendLine, ...rows].join("\n"),
@@ -512,7 +662,7 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
512
662
  const dot = dim(" · ");
513
663
  const anyFailed = cells.some((c) => c.redTier);
514
664
  const gaugeCells = 10;
515
- const fill = g.tasks.length ? Math.round((done / g.tasks.length) * gaugeCells) : 0;
665
+ const fill = total ? Math.round((done / total) * gaugeCells) : 0;
516
666
  const tipFailed = tipPhase?.state === "failed";
517
667
  const progressTone = anyFailed || tipFailed ? fail : tipPhase ? warn : ok;
518
668
  const gauge = (fill ? progressTone("█".repeat(fill)) : "") + (fill < gaugeCells ? dim("░".repeat(gaugeCells - fill)) : "");
@@ -522,16 +672,17 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
522
672
  .replace(/\balive\b/, ok("alive"))
523
673
  .replaceAll(" · ", dot);
524
674
  const tally = tipPhase
525
- ? `${progressTone(`${done}/${g.tasks.length} tasks done`)}${dot}${progressTone("run not verified")}`
526
- : `${done}/${g.tasks.length} done`;
675
+ ? `${progressTone(`${done}/${total} tasks done`)}${dot}${progressTone("run not verified")}`
676
+ : `${done}/${total} done`;
527
677
  const tipStatus = tipPhase
528
678
  ? `${tipFailed ? fail(tipVerifyText(tipPhase)) : warn(tipVerifyText(tipPhase))}${dot}`
529
679
  : "";
530
- const header = ` ${title(runId ? `run ${runId}` : "tickmarkr")}${dot}` +
680
+ const zone = journalRowsOnly ? `zone ${localZoneLabel(zoneReference)}${dot}` : "";
681
+ const header = ` ${title(runId ? `run ${runId}` : "tickmarkr")}${dot}${zone}` +
531
682
  (runId
532
683
  ? `${supersededBy ? `${warn(`superseded by ${supersededBy}`)}${dot}` : ""}${!comparable ? `${warn(NOT_COMPARABLE_NOTICE)}${dot}` : ""}${live}${dot}${tipStatus}`
533
684
  : `no runs yet${dot}`) +
534
- `${gauge} ${tipPhase ? tally : done === g.tasks.length && g.tasks.length > 0 ? ok(tally) : tally}`;
685
+ `${gauge} ${tipPhase ? tally : done === total && total > 0 ? ok(tally) : tally}`;
535
686
  const hr = rule(Math.min(width, 100));
536
687
  // OBS-104 run-level now line: names the most recent journal event. TTY frame only — the non-TTY
537
688
  // machine surface is byte-pinned (status-brand golden) and must not drift. Rendered BELOW the task
@@ -539,33 +690,35 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
539
690
  const nowLine = activity.now ? [legend(` now: ${activity.now}`)] : [];
540
691
  const gatesLegend = legend(` gates: ${GATE_NAMES.join(" · ")}`);
541
692
  // Two-line card per task (operator request, v1.67): line 1 carries identity + verdict — glyph,
542
- // id, goal at full width, and only SHORT status words. Line 2 carries the machinery, dim and
543
- // aligned under the goal: gate chain, live activity phrase (or channel), ctx. Long channel names
544
- // and activity phrases live on line 2 only, so they can never squeeze the goal or wrap line 1.
545
- const taskVerdict = (c) => c.st === "done" ? "pass" : c.redTier ? "fail" : c.st === "failed" || c.st === "human" ? "warn" : "neutral";
546
- const statusWord = (c) => c.livePhase ? "running" : c.redTier ? "failed" : c.st === "failed" ? "warn" : String(c.st);
693
+ // id, title at full width, and only SHORT status words. Line 2 carries the machinery, dim and
694
+ // aligned under the title: gate chain, live activity phrase (or channel), ctx. Long channel names
695
+ // and activity phrases live on line 2 only, so they can never squeeze the title or wrap line 1.
696
+ const taskVerdict = (c) => c.merged ? "pass" : c.redTier ? "fail" : c.st === "failed" || c.st === "human" ? "warn" : "neutral";
697
+ const statusWord = (c) => c.livePhase ? "running" : c.redTier ? "failed" : c.st === "failed" ? "warn" : surfaceStatusWord(c.st);
547
698
  const idW = Math.max(...cells.map((c) => c.t.id.length), 2);
548
699
  // plain-text status suffixes for width math only — starved / failed gate names / approval hint
549
- const suffixPlain = (c) => (c.isStarved ? " · starved" : "") + failedSuffix(c.states) + humanGateSuffix(c.t, c.st, c.states);
700
+ const suffixPlain = (c) => (c.isStarved ? " · starved" : "") + failedSuffix(c.states)
701
+ + humanGateSuffix(c.t, graphTaskStatus(c.st, c.t.status), c.states);
550
702
  const stW = Math.max(...cells.map((c) => statusWord(c).length + suffixPlain(c).length));
551
703
  const avail = Math.max(8, width - (5 + idW) - 2 - stW);
552
- const goals = cells.map((c) => shortGoal(c.t.goal, avail));
553
- const goalW = Math.max(8, ...goals.map((s) => s.length));
554
- const indent = " ".repeat(idW + 5); // line 2 starts under the goal column
704
+ const titles = cells.map((c) => shortGoal(c.t.title, avail));
705
+ const titleW = Math.max(8, ...titles.map(cellWidth));
706
+ const indent = " ".repeat(idW + 5); // line 2 starts under the title column
555
707
  const rows = cells.map((c, i) => {
556
- const { t, st, failureKind, redTier, states, priorGraph, isStarved, phrase, channel, ctx, livePhase } = c;
708
+ const { t, st, merged, failureKind, redTier, states, priorGraph, isStarved, phrase, channel, ctx, livePhase } = c;
557
709
  const word = statusWord(c);
558
710
  const staleWorker = livePhase?.phase === "worker"
559
711
  && workerOutputAge(livePhase, now, workerLiveness.get(t.id)) >= 60_000;
560
- const stWord = staleWorker ? warn(word) : st === "done" ? ok(word) : redTier ? fail(word) : st === "failed" || st === "human" ? warn(word) : word;
712
+ const stWord = staleWorker ? warn(word) : merged ? ok(word) : redTier ? fail(word) : st === "failed" || st === "human" ? warn(word) : word;
561
713
  // a fail names its gate in words right here — the one moment gate identity is needed on a row
562
714
  const f = failedGates(states);
563
- const human = humanGateSuffix(t, st, states);
715
+ const human = humanGateSuffix(t, graphTaskStatus(st, t.status), states);
564
716
  const statusCell = stWord +
565
717
  (isStarved ? dot + fail("starved") : "") +
566
718
  (f.length ? dot + fail(f.join(", ")) : "") +
567
719
  (human ? dot + warn("awaiting approval") : "");
568
- const taskLabel = `${t.id.padEnd(idW)} ${goals[i].padEnd(goalW)} ${statusCell}`;
720
+ const taskTitle = titles[i];
721
+ const taskLabel = `${t.id.padEnd(idW)} ${taskTitle}${" ".repeat(titleW - cellWidth(taskTitle))} ${statusCell}`;
569
722
  const line1 = livePhase
570
723
  ? ` ${(staleWorker ? warn : dim)(SPINNER[animationFrame % SPINNER.length])} ${taskLabel}`
571
724
  : ` ${statusRow(taskVerdict(c), taskLabel)}`;
@@ -588,9 +741,10 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
588
741
  };
589
742
  };
590
743
  export async function status(argv, cwd = process.cwd(), opts = {}) {
744
+ const namedRunId = positionalRunId(argv);
591
745
  // cockpit surface: banner + frame on a TTY (doctor's pattern); pipes get the bare frame
592
746
  if (!argv.includes("--watch")) {
593
- const { content } = renderFrame(cwd, opts.now?.() ?? Date.now());
747
+ const { content } = renderFrame(cwd, opts.now?.() ?? Date.now(), 0, undefined, false, namedRunId);
594
748
  return visual() ? BANNER + content : content;
595
749
  }
596
750
  const eventStream = argv.some((arg) => arg === "--events" || arg === "--jsonl" || arg === "--decision-events");
@@ -652,7 +806,9 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
652
806
  let decisionRunId;
653
807
  let journalCursor = 0;
654
808
  const consumeDecisionEvents = () => {
655
- const runId = Journal.latestRunId(cwd, { withJournal: true });
809
+ // A named run is followed, never re-resolved: --watch <runId> keeps reporting that run even as
810
+ // newer runs start. Only the no-argument form tracks latest, as before.
811
+ const runId = namedRunId ?? Journal.latestRunId(cwd, { withJournal: true });
656
812
  if (!runId)
657
813
  return [];
658
814
  if (decisionRunId !== runId) {
@@ -709,7 +865,7 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
709
865
  }
710
866
  }
711
867
  else {
712
- frame = renderFrame(cwd, nowMs, i, workerLiveness);
868
+ frame = renderFrame(cwd, nowMs, i, workerLiveness, true, namedRunId);
713
869
  if (tty) {
714
870
  updateTitle(frame.hotPhase, nowMs);
715
871
  process.stdout.write(`\x1b[2J\x1b[H${BANNER}${frame.content}\n${legend(` watching · refresh ${REFRESH_MS / 1000}s · ^C to quit`)}`);
@@ -1,3 +1,4 @@
1
+ import { type TickmarkrConfig } from "../config/config.js";
1
2
  import { type Task } from "../graph/schema.js";
2
3
  /**
3
4
  * Return human-readable scope-lint lines for plan output (no `!` prefix — plan owns that).
@@ -28,5 +29,88 @@ export declare function separabilityErrors(tasks: ReadonlyArray<Pick<Task, "id"
28
29
  * diff at the 130k cap; it took 28 dispatches and never passed review, while T3 (4 items) took 5.
29
30
  */
30
31
  export declare function taskBudgetErrors(tasks: ReadonlyArray<Pick<Task, "id" | "files" | "acceptance">>): string[];
31
- /** Every Task Unit Contract violation in one pass, ready to throw. */
32
- export declare function taskUnitContractErrors(tasks: ReadonlyArray<Pick<Task, "id" | "files" | "deps" | "acceptance">>): string[];
32
+ /**
33
+ * Max acceptance × files[] surface per task (OVERSEER-RULING-20260731-velocity, R2:
34
+ * `surface = acceptance.length × files.length > 24` fails compile). EVERY criterion counts —
35
+ * a judge item is still a thing one worker must satisfy and one reviewer must verify in the
36
+ * same pass, and counting only test oracles let judge-heavy tasks bypass the bound (the first
37
+ * T6 attempt was rejected for exactly that narrowing). The calibration corpus pins the bound:
38
+ * v1.84's original T1 measures 6×5=30 and fails; v1.79's tasks pass — its widest, T5 at
39
+ * 4×7=28, rides the recorded exception below rather than redefining the metric.
40
+ */
41
+ export declare const MAX_TASK_SURFACE = 24;
42
+ /**
43
+ * Max goal words per acceptance criterion (v1.84's T1 measured 75). Gameable only by adding
44
+ * criteria — which is the point: every law migrated from goal prose to a criterion moves its
45
+ * enforcement from the 40-minute review round to the 2-minute judge.
46
+ */
47
+ export declare const MAX_GOAL_WORDS_PER_CRITERION = 60;
48
+ /**
49
+ * A recorded surface exception — an explicit contract amendment, never a silent bypass. Each
50
+ * entry matches exactly ONE historical task shape: task id, acceptance count, and the full
51
+ * files[] list must all be identical, so nothing an author writes later can ride one (matching
52
+ * would require copying a recorded files[] verbatim — an audible spec edit any reviewer sees).
53
+ */
54
+ export interface SurfaceException {
55
+ /** Task id as authored in the spec the exception was recorded for. */
56
+ id: string;
57
+ /** Exact acceptance-criterion count of the excepted task. */
58
+ acceptance: number;
59
+ /** Exact files[] of the excepted task (order-insensitive; sorted at recording time). */
60
+ files: string[];
61
+ /** Where the amendment is recorded and why it exists. */
62
+ reason: string;
63
+ }
64
+ export declare const SURFACE_CONTRACT_EXCEPTIONS: ReadonlyArray<SurfaceException>;
65
+ /**
66
+ * R2 surface bound: acceptance criteria × files[] patterns, one error per offending task.
67
+ * `exceptions` defaults to the recorded contract amendments; tests pass [] to prove an
68
+ * exception is load-bearing.
69
+ */
70
+ export declare function surfaceErrors(tasks: ReadonlyArray<Pick<Task, "id" | "files" | "acceptance">>, exceptions?: ReadonlyArray<SurfaceException>): string[];
71
+ /** R2 goal-density bound: goal words per acceptance criterion, one error per offending task. */
72
+ export declare function goalDensityErrors(tasks: ReadonlyArray<Pick<Task, "id" | "goal" | "acceptance">>): string[];
73
+ /**
74
+ * R2 symbol ownership: a code identifier named in a judge or test criterion that resolves uniquely
75
+ * to a definition OUTSIDE the task's files[] is unsatisfiable by construction — the v1.84 T1
76
+ * planFrame defect, which burned two review rounds because the enabling symbol lived in another
77
+ * task's file. Zero definition sites (plain-language false positive, external symbol, or a file
78
+ * the task itself creates) and ambiguous tokens (multiple definition sites) stay silent. The index
79
+ * must be COMPLETE: any code path that cannot be read is its own error — the rule fails closed
80
+ * rather than trust a partial scan.
81
+ */
82
+ export declare function symbolOwnershipErrors(tasks: ReadonlyArray<Pick<Task, "id" | "files" | "acceptance">>, repoRoot: string): string[];
83
+ /**
84
+ * The participation half of the config — everything the review-policy rules read. `byShape` rides
85
+ * along because `gates.byShape.<shape>.review: false` is a second, NON-monotone participation switch:
86
+ * review.policy can only raise a task's policy, but a shape override can omit the review gate outright
87
+ * (src/gates/run-gates.ts `enabled`). Path-keyed participation is only true if both are read together.
88
+ */
89
+ export type ReviewParticipation = TickmarkrConfig["review"] & {
90
+ byShape?: TickmarkrConfig["gates"]["byShape"];
91
+ };
92
+ /**
93
+ * R3 review participation (OVERSEER-RULING-20260731-velocity; OBS-186). The compiler assigns
94
+ * `reviewPolicy` from the DECLARED files[] — `judge-only` only when every declared path is provably
95
+ * docs/CHANGELOG/RELEASING/version-mirror leaf work — and the operator's floor may raise it. A task
96
+ * that would SKIP review while touching `review.criticalPaths` is the exact shape OBS-186 caught in
97
+ * the field: the riskiest task in the bundle silently declining the one gate that exists for it. That
98
+ * cannot be a warning, because nothing downstream would stop it — the gate would honour the policy.
99
+ */
100
+ export declare function reviewParticipationErrors(tasks: ReadonlyArray<Pick<Task, "id" | "files" | "shape">>, review: ReviewParticipation): string[];
101
+ /**
102
+ * Every Task Unit Contract violation in one pass, ready to throw. `repoRoot` backs the
103
+ * symbol-ownership lint and the participation config, and defaults to the invocation directory —
104
+ * correct for the CLI/daemon, which compile from inside the target repo.
105
+ *
106
+ * KNOWN GAP (R2 review, T6/T7 scope): the compile seam in src/compile/index.ts
107
+ * (`enforceTaskUnitContract`) does not thread `compileSource`'s `root` argument into this call, so a
108
+ * PROGRAMMATIC compile whose target repo differs from process.cwd() resolves the wrong root and can
109
+ * miss that repo's own `review.criticalPaths`. Threading it is one line in src/compile/index.ts,
110
+ * outside this task's file scope. Two things bound the exposure meanwhile, both live: the critical
111
+ * set here is the UNION with the shipped defaults, so a wrong root can never lower enforcement below
112
+ * the shipped floor; and the review GATE — which is handed the run's real config — refuses to skip a
113
+ * critical path itself (src/gates/review.ts), so a lint this seam misses costs a late verdict rather
114
+ * than an unreviewed one. The compile lint is the early warning; the gate is the fail-closed backstop.
115
+ */
116
+ export declare function taskUnitContractErrors(tasks: ReadonlyArray<Pick<Task, "id" | "goal" | "files" | "deps" | "acceptance" | "shape">>, repoRoot?: string, review?: ReviewParticipation): string[];