tickmarkr 1.83.0 → 1.85.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 (37) hide show
  1. package/dist/adapters/claude-code.d.ts +1 -0
  2. package/dist/adapters/claude-code.js +57 -1
  3. package/dist/adapters/fake.js +9 -0
  4. package/dist/adapters/types.d.ts +3 -0
  5. package/dist/adapters/types.js +21 -0
  6. package/dist/cli/commands/status.js +160 -30
  7. package/dist/compile/collateral.d.ts +86 -2
  8. package/dist/compile/collateral.js +294 -3
  9. package/dist/config/config.d.ts +62 -0
  10. package/dist/config/config.js +157 -2
  11. package/dist/drivers/herdr.d.ts +20 -3
  12. package/dist/drivers/herdr.js +288 -105
  13. package/dist/gates/baseline.d.ts +1 -0
  14. package/dist/gates/baseline.js +91 -13
  15. package/dist/gates/review.d.ts +7 -0
  16. package/dist/gates/review.js +99 -6
  17. package/dist/gates/run-gates.d.ts +9 -0
  18. package/dist/gates/run-gates.js +285 -41
  19. package/dist/run/daemon.d.ts +48 -2
  20. package/dist/run/daemon.js +1417 -315
  21. package/dist/run/journal.d.ts +56 -3
  22. package/dist/run/journal.js +275 -1
  23. package/dist/run/stall.d.ts +35 -1
  24. package/dist/run/stall.js +118 -8
  25. package/dist/tui/cockpit/components.d.ts +30 -1
  26. package/dist/tui/cockpit/components.js +19 -3
  27. package/dist/tui/cockpit/derive.d.ts +29 -2
  28. package/dist/tui/cockpit/derive.js +219 -23
  29. package/dist/tui/cockpit/layout.d.ts +75 -6
  30. package/dist/tui/cockpit/layout.js +97 -19
  31. package/dist/tui/cockpit/live.d.ts +14 -1
  32. package/dist/tui/cockpit/live.js +223 -29
  33. package/dist/tui/cockpit/pointer.d.ts +261 -0
  34. package/dist/tui/cockpit/pointer.js +610 -0
  35. package/dist/tui/cockpit/run-cockpit.d.ts +36 -5
  36. package/dist/tui/cockpit/run-cockpit.js +270 -51
  37. package/package.json +1 -1
@@ -8,6 +8,7 @@ export declare const CLAUDE_ALIAS_IDENTITY_STAMPS: {
8
8
  export type ClaudeAlias = keyof typeof CLAUDE_ALIAS_IDENTITY_STAMPS;
9
9
  export type ClaudeAliasIdentityProbe = (cwd: string, alias: ClaudeAlias) => string | undefined;
10
10
  export declare const CLAUDE_TRUST_DIALOG: TrustDialog;
11
+ export declare const CLAUDE_INPUT_BOX: import("./types.js").InputBox;
11
12
  export declare function claudeSlug(real: string): string;
12
13
  export declare function readClaudeAliasIdentity(cwd: string, alias: ClaudeAlias): string | undefined;
13
14
  export declare function probeClaudeAliasIdentity(cwd: string, alias: ClaudeAlias): string | undefined;
@@ -3,7 +3,7 @@ import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { parseWorkerResult } from "./prompt.js";
6
- import { channelsFromConfig, MODEL_ID_RE, shq, TokenUsageSchema } from "./types.js";
6
+ import { channelsFromConfig, declareInputBox, MODEL_ID_RE, shq, TokenUsageSchema } from "./types.js";
7
7
  // SPEND-01/SPEND-11: claude writes a per-session JSONL to ~/.claude/projects/<slug>/ where slug is the
8
8
  // realpath'd cwd with every non-alphanumeric char replaced by "-" (verified 114/114 — 36-DIAGNOSIS.md).
9
9
  // The old `/`-only formula missed the "." in `.tickmarkr/worktrees/…` — ENOENT on every worktree dispatch.
@@ -40,6 +40,61 @@ export const CLAUDE_TRUST_DIALOG = {
40
40
  fingerprint: "Quick safety check: Is this a project you created or one you trust?",
41
41
  key: "Enter",
42
42
  };
43
+ // OBS-201/262 nudge deliverability: claude-code is the ONLY member of NUDGEABLE_ADAPTERS, and the
44
+ // driver's deliverTyped pincer (readiness stable-frame → type → read-back → verified submit) needs a
45
+ // declared input box for any delivery whose command is not the adapter's own launch line — which a
46
+ // nudge never is. Without this declaration `requireInputBox` was false, readiness fell back to
47
+ // "the pane no longer echoes the command", and submission fell through to the positional fallback:
48
+ // the rescue that OBS-262 widened the gate for could not be verifiably delivered to the one adapter
49
+ // allowed to receive it.
50
+ //
51
+ // CAPTURED, not guessed (claude 2.1.220, tmux 120x30, 2026-08-03 — the state-to-PIXELS law):
52
+ // ──────────────────────────────────────────── ← full-width rule
53
+ // ❯ You are still assigned this task… ← caret, NBSP, then the typed turn
54
+ // ──────────────────────────────────────────── ← full-width rule
55
+ // Two findings the kimi-shaped guess would have got wrong, and both are fatal on their own:
56
+ // 1. claude's editor is NOT a bordered ╭─╮ box — it is one row FENCED BY TWO RULES, so a
57
+ // `│ > ` fingerprint or a border-adjacency walk never fires.
58
+ // 2. the caret is padded with U+00A0, not a space. A `"❯ "` fingerprint with an ASCII space
59
+ // matches nothing, ever — the OBS-152 failure mode exactly (six versions of "kimi is broken"
60
+ // were all anchored matchers meeting a TUI that renders differently than assumed).
61
+ // `match` means THE EDITOR IS PAINTED and is deliberately true for an empty editor (readiness);
62
+ // `emptyMatch` is the stricter "painted AND carrying nothing", which is what proves a submit
63
+ // registered. Callers deciding submission must test emptyMatch first — see submissionRegistered.
64
+ const CLAUDE_ANSI_SGR_RE = /\u001B\[[0-9;]*m/g;
65
+ const CLAUDE_RULE_RE = /^─{8,}$/;
66
+ const CLAUDE_CARET_RE = /^❯\u00A0/;
67
+ const CLAUDE_CARET_EMPTY_RE = /^❯\u00A0\s*$/;
68
+ // A wrapped or multi-line turn grows the editor downward before the closing rule.
69
+ // ponytail: a fixed window, not a parser — raise it if a real capture ever shows a taller editor.
70
+ const CLAUDE_MAX_EDITOR_ROWS = 8;
71
+ function matchesClaudeEditor(paneText, empty) {
72
+ // Trim ASCII margins ONLY: String.trim() eats U+00A0, which would erase the very byte that
73
+ // distinguishes claude's caret padding from an ordinary prompt line.
74
+ const lines = paneText.replace(CLAUDE_ANSI_SGR_RE, "").split("\n").map((l) => l.replace(/^[ \t]+|[ \t]+$/g, ""));
75
+ const caret = empty ? CLAUDE_CARET_EMPTY_RE : CLAUDE_CARET_RE;
76
+ return lines.some((line, i) => {
77
+ if (!caret.test(line))
78
+ return false;
79
+ if (i === 0 || !CLAUDE_RULE_RE.test(lines[i - 1]))
80
+ return false;
81
+ for (let below = i + 1; below < lines.length && below <= i + CLAUDE_MAX_EDITOR_ROWS; below++) {
82
+ if (CLAUDE_RULE_RE.test(lines[below]))
83
+ return true;
84
+ }
85
+ return false;
86
+ });
87
+ }
88
+ export const CLAUDE_INPUT_BOX = declareInputBox("claude-code", {
89
+ fingerprint: "❯\u00A0",
90
+ match: (paneText) => matchesClaudeEditor(paneText, false),
91
+ emptyMatch: (paneText) => matchesClaudeEditor(paneText, true),
92
+ // The launch line settles on a clean shell line; the editor paints after it. Both interactive
93
+ // entry points count — a resumed session re-enters the same TUI (OBS-142's launch distinction).
94
+ launchCommand: (command) => command.startsWith("claude --model ") || command.startsWith("claude -r "),
95
+ // The 2026-08-03 capture reached a painted editor ~10s after the trust answer on a warm install.
96
+ readinessTimeoutMs: 30_000,
97
+ });
43
98
  export function claudeSlug(real) {
44
99
  return real.replace(/[^A-Za-z0-9]/g, "-");
45
100
  }
@@ -168,6 +223,7 @@ export const claudeCode = {
168
223
  // the daemon safely answers only the exact adapter-declared dialog once per slot.
169
224
  interactiveCommand: (promptFile, model) => `claude --model ${shq(model)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --permission-mode bypassPermissions "$(cat ${shq(promptFile)})"`,
170
225
  trustDialog: CLAUDE_TRUST_DIALOG,
226
+ inputBox: CLAUDE_INPUT_BOX,
171
227
  resumeCommand: (sessionId, promptFile, model) => `claude -r ${shq(sessionId)} --model ${shq(model)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --permission-mode bypassPermissions "$(cat ${shq(promptFile)})"`,
172
228
  invoke(task, _cwd, a, ctx) {
173
229
  return { command: this.headlessCommand(ctx.promptFile, a.model) };
@@ -35,14 +35,23 @@ export class FakeAdapter {
35
35
  // contains TICKMARKR-JUDGE — a review/consult prompt never touches it (research Pitfall 3).
36
36
  let prompt = "";
37
37
  let isJudge = false;
38
+ // v1.85 T4: the role THIS prompt is, when it can be told. The three scratch files are shared per
39
+ // script path, so a build that writes a role it is not can clobber a concurrently-dispatched
40
+ // sibling's file between that sibling's build and its `cat` — which is exactly what a round that
41
+ // launches judge and review together does. Writing only the matching role removes the race: the
42
+ // other roles' `grep` guards below can never fire for this prompt, so their files are never read.
43
+ let role;
38
44
  try {
39
45
  prompt = readFileSync(promptFile, "utf8");
40
46
  isJudge = /TICKMARKR-(?:JUDGE|SCOPE)/.test(prompt);
47
+ role = isJudge ? "judge" : /TICKMARKR-REVIEW/.test(prompt) ? "review" : /TICKMARKR-CONSULT/.test(prompt) ? "consult" : undefined;
41
48
  }
42
49
  catch {
43
50
  // unreadable promptFile: can't detect role; serve static values (legacy headless-call behavior)
44
51
  }
45
52
  const serve = (key) => {
53
+ if (role !== undefined && key !== role)
54
+ return join(dirname(this.scriptPath), `${key}.json`);
46
55
  let val = this.script[key];
47
56
  if (key === "judge" && isJudge && Array.isArray(val)) {
48
57
  // array served sequentially per judge prompt, clamped to last (steps[min(n,len-1)] idiom)
@@ -86,6 +86,7 @@ export interface InputBox {
86
86
  fingerprint: string;
87
87
  match?(paneText: string): boolean;
88
88
  emptyMatch?(paneText: string): boolean;
89
+ occupiedMatch?(paneText: string): boolean;
89
90
  launchCommand?(command: string): boolean;
90
91
  readinessTimeoutMs?: number;
91
92
  }
@@ -93,6 +94,8 @@ export declare function declareInputBox(adapterId: string, inputBox: InputBox):
93
94
  export declare function declaredInputBoxForWorkerName(workerName: string): InputBox | undefined;
94
95
  export declare function matchesInputBox(paneText: string, inputBox: InputBox): boolean;
95
96
  export declare function matchesEmptyInputBox(paneText: string, inputBox: InputBox): boolean;
97
+ export declare function matchesOccupiedInputBox(paneText: string, inputBox: InputBox): boolean;
98
+ export declare function missingInputStateDeclarations(inputBox: InputBox | undefined): string[];
96
99
  export interface WorkerAdapter {
97
100
  id: string;
98
101
  vendor: string;
@@ -45,6 +45,27 @@ export function matchesInputBox(paneText, inputBox) {
45
45
  export function matchesEmptyInputBox(paneText, inputBox) {
46
46
  return inputBox.emptyMatch?.(paneText) === true;
47
47
  }
48
+ // The OCCUPIED state: the adapter's own pin when it has one, else painted-and-not-empty, which two
49
+ // adapter declarations decide between them. Either way the answer comes from the adapter, never from
50
+ // where the prompt happens to sit in the transcript.
51
+ export function matchesOccupiedInputBox(paneText, inputBox) {
52
+ if (inputBox.occupiedMatch)
53
+ return inputBox.occupiedMatch(paneText) === true;
54
+ return matchesInputBox(paneText, inputBox) && !matchesEmptyInputBox(paneText, inputBox);
55
+ }
56
+ // v1.85 T5: which input-state declarations a typed delivery needs and this adapter has not made.
57
+ // Empty is always required (it is the only positive submission evidence); occupied needs either its
58
+ // own matcher or the `match` that derives it. A non-empty result is a fail-closed refusal, named.
59
+ export function missingInputStateDeclarations(inputBox) {
60
+ if (!inputBox)
61
+ return ["inputBox"];
62
+ const missing = [];
63
+ if (!inputBox.emptyMatch)
64
+ missing.push("emptyMatch");
65
+ if (!inputBox.occupiedMatch && !inputBox.match)
66
+ missing.push("occupiedMatch");
67
+ return missing;
68
+ }
48
69
  export function channelsFromConfig(adapterId, cfg) {
49
70
  const e = cfg.tiers[adapterId];
50
71
  if (!e)
@@ -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,52 @@ 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";
9
12
  // ponytail: fixed 2s refresh; promote to config.visibility.* only when an operator asks.
10
13
  const REFRESH_MS = 2000;
11
14
  const NOT_COMPARABLE_NOTICE = "graph recompiled since this run — task states not comparable; resume with `--graph-changed` to audit this recompile";
12
15
  const PRIOR_GRAPH_MARKER = "prior graph";
16
+ // One compact host-zone label per screen. Each event below is still converted from its own
17
+ // complete UTC instant, so the zone's historical rules — including a transition within one frame
18
+ // — stay intact rather than being rendered through this label's single sampled offset.
19
+ const localZoneLabel = (reference = new Date()) => {
20
+ const offset = -reference.getTimezoneOffset();
21
+ if (offset === 0)
22
+ return "UTC";
23
+ const sign = offset < 0 ? "-" : "+";
24
+ const hours = String(Math.floor(Math.abs(offset) / 60)).padStart(2, "0");
25
+ const minutes = Math.abs(offset) % 60;
26
+ return minutes === 0
27
+ ? `${sign}${hours}`
28
+ : `${sign}${hours}:${String(minutes).padStart(2, "0")}`;
29
+ };
30
+ const localClockText = (content, reference = new Date()) => {
31
+ return content.replace(/\b([01]\d|2[0-3]):([0-5]\d):([0-5]\d)\b/gu, (_whole, h, m, s) => {
32
+ const instant = new Date(Date.UTC(reference.getUTCFullYear(), reference.getUTCMonth(), reference.getUTCDate(), Number(h), Number(m), Number(s)));
33
+ return [instant.getHours(), instant.getMinutes(), instant.getSeconds()]
34
+ .map((part) => String(part).padStart(2, "0"))
35
+ .join(":");
36
+ });
37
+ };
38
+ /** The complete journal instant behind a folded clock phrase, not a screen-wide offset sample. */
39
+ const taskClockReference = (events, taskId, content, fallback) => {
40
+ const utcClock = content.match(/\b(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d\b/u)?.[0];
41
+ if (utcClock === undefined)
42
+ return fallback;
43
+ for (let index = events.length - 1; index >= 0; index -= 1) {
44
+ const event = events[index];
45
+ if (event.taskId !== taskId || event.ts.slice(11, 19) !== utcClock)
46
+ continue;
47
+ const instant = new Date(event.ts);
48
+ if (Number.isFinite(instant.getTime()))
49
+ return instant;
50
+ }
51
+ return fallback;
52
+ };
53
+ // Every attempt number a cell phrase carries is the engagement's own ladder (foldActivity counts
54
+ // dispatch labels from 1). Name that ruler wherever a bare one appears, not only in the one phrase
55
+ // shipping today — a phrase added upstream must not reach the operator unruled.
56
+ const labelAttemptRuler = (text) => text.replace(/(?<!engagement |run )\battempt (?=\d)/gu, "engagement attempt ");
13
57
  // The timer must keep the process ALIVE: an unref'd timer here let the event loop drain after the
14
58
  // first frame, so a live `--watch` printed once and exited 0 (OBS-11). Never unref this.
15
59
  const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -196,6 +240,25 @@ export const taskBox = (status) => {
196
240
  return "[!]";
197
241
  return "[ ]";
198
242
  };
243
+ const graphTaskStatus = (state, fallback) => {
244
+ if (state === "completed" || state === "done")
245
+ return "done";
246
+ if (state === "interrupted")
247
+ return "failed";
248
+ return state ?? fallback;
249
+ };
250
+ const surfaceStatusWord = (state) => {
251
+ if (state === "human")
252
+ return "parked";
253
+ return state;
254
+ };
255
+ const surfaceTaskBox = (state, merged) => {
256
+ if (merged)
257
+ return "[x]";
258
+ if (state === "failed" || state === "human")
259
+ return "[!]";
260
+ return "[ ]";
261
+ };
199
262
  export const gateBox = (state, unicode) => {
200
263
  if (unicode) {
201
264
  // shared glyph vocabulary: pass/fail verdicts, dash for skip, dim circle for not-yet-run
@@ -366,7 +429,7 @@ const tipVerifyText = (phase) => {
366
429
  return "tip-verify: pending";
367
430
  const gates = phase.gates.length ? phase.gates.join(", ") : "gate unknown";
368
431
  const failed = `tip-verify: FAILED (${gates})`;
369
- const reverify = phase.state === "re-verifying" ? ` → re-verifying (attempt ${phase.attempt})` : "";
432
+ const reverify = phase.state === "re-verifying" ? ` → re-verifying (run attempt ${phase.attempt})` : "";
370
433
  const fingerprints = phase.fingerprints === undefined
371
434
  ? ""
372
435
  : ` · ${phase.fingerprints} fingerprint${phase.fingerprints === 1 ? "" : "s"}`;
@@ -412,11 +475,24 @@ const statusEngagement = (events, loadedHash) => {
412
475
  }
413
476
  return { comparable: baseline.comparable };
414
477
  };
415
- const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness = new Map()) => {
478
+ // The journal's own reader rule (src/run/journal.ts readJsonl), applied to bytes already in hand:
479
+ // skip blanks, drop a line that will not parse (a torn trailing write after a crash), keep the rest.
480
+ const parseJournalSnapshot = (raw) => raw.split("\n").flatMap((line) => {
481
+ if (!line.trim())
482
+ return [];
483
+ try {
484
+ return [JSON.parse(line)];
485
+ }
486
+ catch {
487
+ return [];
488
+ }
489
+ });
490
+ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness = new Map(), journalRowsOnly = false) => {
416
491
  const g = loadGraph(cwd);
417
492
  const runId = Journal.latestRunId(cwd, { withJournal: true });
418
493
  const assignments = new Map();
419
494
  let replayed = null;
495
+ let cockpit;
420
496
  let events = [];
421
497
  const contexts = new Map();
422
498
  let comparable = false;
@@ -424,7 +500,19 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
424
500
  let supersededBy; // v1.53 T5: this run is dead — a newer run replaced it
425
501
  if (runId) {
426
502
  const j = Journal.open(cwd, runId);
427
- events = j.read();
503
+ // ONE byte snapshot per frame. The cockpit fold and every decoration below — foldActivity,
504
+ // livePhases, tipVerifyPhase, gateSnapshot, the failure and liveness readings — fold THESE
505
+ // bytes and nothing else, so a line the daemon appends mid-frame can never pair newer task
506
+ // rows against older activity, phases, gates or tip state. `Journal.open` refuses a run
507
+ // without a journal, so the file is there to read.
508
+ const raw = readFileSync(join(j.dir, "journal.jsonl"), "utf8");
509
+ events = parseJournalSnapshot(raw);
510
+ // The daemon creates journal.jsonl with lock-reclaimed before run-start and a reader may also
511
+ // catch an empty/torn first write. Those snapshots own no task fold yet; once run-start exists,
512
+ // however, a fold failure is unexpected and must surface instead of becoming a false empty run.
513
+ if (journalRowsOnly && events.some((event) => event.event === "run-start")) {
514
+ cockpit = deriveRunCockpitData({ fileName: `${runId}.journal.jsonl`, raw }, "status", { graph: g });
515
+ }
428
516
  const sup = [...events].reverse().find((e) => e.event === "superseded" && typeof e.data.by === "string");
429
517
  supersededBy = sup?.data.by;
430
518
  // The resume comparator is the fail-closed baseline; a matching graph-rehash is the daemon's
@@ -433,12 +521,14 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
433
521
  comparable = engagement.comparable;
434
522
  rehashAt = engagement.rehashAt;
435
523
  if (comparable) {
436
- replayed = j.replayStatuses();
524
+ if (!journalRowsOnly)
525
+ replayed = j.replayStatuses();
437
526
  for (const e of events) {
438
- if (e.event === "task-dispatch" && e.taskId) {
527
+ if (!journalRowsOnly && e.event === "task-dispatch" && e.taskId) {
439
528
  const a = e.data.assignment;
440
- if (typeof a.adapter === "string" && typeof a.model === "string")
529
+ if (typeof a.adapter === "string" && typeof a.model === "string") {
441
530
  assignments.set(e.taskId, `${a.adapter}:${a.model}`);
531
+ }
442
532
  }
443
533
  if (e.event === "context-sample" && e.taskId && typeof e.data.tokens === "number" && Number.isFinite(e.data.tokens)) {
444
534
  contexts.set(e.taskId, e.data.tokens); // last write wins
@@ -446,7 +536,34 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
446
536
  }
447
537
  }
448
538
  }
449
- const effective = { ...g, tasks: g.tasks.map((t) => ({ ...t, status: replayed?.get(t.id) ?? t.status })) };
539
+ const taskRows = new Map(cockpit?.taskRows.map((row) => [row.taskId, row]) ?? []);
540
+ const clockFallback = new Date(now);
541
+ const recordedClock = new Date(events.at(-1)?.ts ?? now);
542
+ const zoneReference = Number.isFinite(recordedClock.getTime()) ? recordedClock : clockFallback;
543
+ const effective = {
544
+ ...g,
545
+ tasks: g.tasks.map((task) => ({
546
+ ...task,
547
+ status: comparable
548
+ ? journalRowsOnly
549
+ ? graphTaskStatus(taskRows.get(task.id)?.state, task.status)
550
+ : replayed?.get(task.id) ?? task.status
551
+ : task.status,
552
+ })),
553
+ };
554
+ const renderedTasks = journalRowsOnly
555
+ ? cockpit !== undefined && comparable
556
+ ? g.tasks.filter((task) => {
557
+ const row = taskRows.get(task.id);
558
+ return row !== undefined && (row.state !== undefined
559
+ || row.lastEventTime !== undefined
560
+ || row.attempts !== undefined
561
+ || row.actor !== undefined
562
+ || row.merged === true
563
+ || row.parkKind !== undefined);
564
+ })
565
+ : []
566
+ : g.tasks;
450
567
  const starved = new Set(blockedTasks(effective).map((t) => t.id));
451
568
  // OBS-104: ONE activity fold feeds both surfaces — never re-derived here. Comparable events only
452
569
  // (a recompiled graph's journal must not animate the wrong tasks); with no or stale journal the
@@ -455,7 +572,10 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
455
572
  const unicode = visual();
456
573
  const divider = unicode ? " · " : " / ";
457
574
  const width = process.stdout.columns ?? 120;
458
- const done = effective.tasks.filter((t) => t.status === "done").length;
575
+ const done = journalRowsOnly
576
+ ? renderedTasks.filter((task) => graphTaskStatus(taskRows.get(task.id)?.state, task.status) === "done").length
577
+ : effective.tasks.filter((task) => task.status === "done").length;
578
+ const total = g.tasks.length;
459
579
  const ended = comparable && runHasEnded(events);
460
580
  const tipPhase = comparable ? tipVerifyPhase(events) : undefined;
461
581
  const taskIds = new Set(g.tasks.map((task) => task.id));
@@ -464,8 +584,10 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
464
584
  if (!taskIds.has(taskId))
465
585
  phases.delete(taskId);
466
586
  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;
587
+ const cells = renderedTasks.map((t) => {
588
+ const folded = journalRowsOnly && comparable ? taskRows.get(t.id) : undefined;
589
+ const st = folded?.state ?? replayed?.get(t.id) ?? t.status;
590
+ const merged = journalRowsOnly ? folded?.merged === true : st === "done" || st === "completed";
469
591
  const failureKind = comparable ? recordedTaskFailureKind(events, t.id) : undefined;
470
592
  const terminal = st === "failed" || st === "human";
471
593
  // Unknown legacy task-failed events stay red. Typed availability noise is warn-tier only while
@@ -475,28 +597,34 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
475
597
  || (st === "failed" && failureKind === undefined));
476
598
  const livePhase = phases.get(t.id);
477
599
  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);
600
+ const rawPhrase = livePhase ? phaseDetail(livePhase, now, workerLiveness.get(t.id)) : isStarved ? undefined : activity.cells.get(t.id);
601
+ const phrase = rawPhrase === undefined
602
+ ? undefined
603
+ : journalRowsOnly
604
+ ? labelAttemptRuler(localClockText(rawPhrase, taskClockReference(events, t.id, rawPhrase, clockFallback)))
605
+ : rawPhrase;
479
606
  const label = isStarved ? " starved" : phrase ? ` ${phrase}` : "";
480
- const channel = assignments.get(t.id) ?? "-";
607
+ const channel = journalRowsOnly ? folded?.actor ?? "-" : assignments.get(t.id) ?? "-";
481
608
  const ctx = contexts.get(t.id);
482
609
  const assignCol = ctx !== undefined ? `${channel}${divider}ctx ${ctx}` : channel;
483
610
  const gates = comparable
484
611
  ? gateSnapshot(t, events, rehashAt)
485
612
  : { states: defaultGateStates(t), priorGraph: false };
486
- return { t, st, failureKind, redTier, label, assignCol, isStarved, phrase, channel, ctx, livePhase, ...gates };
613
+ return { t, st, merged, failureKind, redTier, label, assignCol, isStarved, phrase, channel, ctx, livePhase, ...gates };
487
614
  });
488
615
  if (!unicode) {
489
616
  // machine/CI surface — journals without phase-start stay byte-identical; new phase-aware frames
490
617
  // use an ASCII spinner so pipes never receive terminal-only braille/ANSI.
491
- const rows = cells.map(({ t, st, label, assignCol, livePhase, states, priorGraph }) => {
618
+ const rows = cells.map(({ t, st, merged, label, assignCol, livePhase, states, priorGraph }) => {
492
619
  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}`;
620
+ const prefix = livePhase ? ` ${ASCII_SPINNER[animationFrame % ASCII_SPINNER.length]} ${t.id} ` : ` ${surfaceTaskBox(st, merged)} ${t.id} `;
621
+ const suffix = ` ${chain}${priorGraph ? ` ${PRIOR_GRAPH_MARKER}` : ""} ${livePhase ? "running" : surfaceStatusWord(st)}${label} ${assignCol}`;
495
622
  return `${prefix}${shortGoal(t.goal, Math.max(0, width - prefix.length - suffix.length))}${suffix}`;
496
623
  });
624
+ const zone = journalRowsOnly ? `${divider}zone ${localZoneLabel(zoneReference)}` : "";
497
625
  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`;
626
+ ? `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`}`
627
+ : `tickmarkr status${zone}${divider}no runs yet${divider}${done}/${total} done`;
500
628
  const legendLine = ` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(divider)}`;
501
629
  return {
502
630
  content: [header, legendLine, ...rows].join("\n"),
@@ -512,7 +640,7 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
512
640
  const dot = dim(" · ");
513
641
  const anyFailed = cells.some((c) => c.redTier);
514
642
  const gaugeCells = 10;
515
- const fill = g.tasks.length ? Math.round((done / g.tasks.length) * gaugeCells) : 0;
643
+ const fill = total ? Math.round((done / total) * gaugeCells) : 0;
516
644
  const tipFailed = tipPhase?.state === "failed";
517
645
  const progressTone = anyFailed || tipFailed ? fail : tipPhase ? warn : ok;
518
646
  const gauge = (fill ? progressTone("█".repeat(fill)) : "") + (fill < gaugeCells ? dim("░".repeat(gaugeCells - fill)) : "");
@@ -522,16 +650,17 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
522
650
  .replace(/\balive\b/, ok("alive"))
523
651
  .replaceAll(" · ", dot);
524
652
  const tally = tipPhase
525
- ? `${progressTone(`${done}/${g.tasks.length} tasks done`)}${dot}${progressTone("run not verified")}`
526
- : `${done}/${g.tasks.length} done`;
653
+ ? `${progressTone(`${done}/${total} tasks done`)}${dot}${progressTone("run not verified")}`
654
+ : `${done}/${total} done`;
527
655
  const tipStatus = tipPhase
528
656
  ? `${tipFailed ? fail(tipVerifyText(tipPhase)) : warn(tipVerifyText(tipPhase))}${dot}`
529
657
  : "";
530
- const header = ` ${title(runId ? `run ${runId}` : "tickmarkr")}${dot}` +
658
+ const zone = journalRowsOnly ? `zone ${localZoneLabel(zoneReference)}${dot}` : "";
659
+ const header = ` ${title(runId ? `run ${runId}` : "tickmarkr")}${dot}${zone}` +
531
660
  (runId
532
661
  ? `${supersededBy ? `${warn(`superseded by ${supersededBy}`)}${dot}` : ""}${!comparable ? `${warn(NOT_COMPARABLE_NOTICE)}${dot}` : ""}${live}${dot}${tipStatus}`
533
662
  : `no runs yet${dot}`) +
534
- `${gauge} ${tipPhase ? tally : done === g.tasks.length && g.tasks.length > 0 ? ok(tally) : tally}`;
663
+ `${gauge} ${tipPhase ? tally : done === total && total > 0 ? ok(tally) : tally}`;
535
664
  const hr = rule(Math.min(width, 100));
536
665
  // OBS-104 run-level now line: names the most recent journal event. TTY frame only — the non-TTY
537
666
  // machine surface is byte-pinned (status-brand golden) and must not drift. Rendered BELOW the task
@@ -542,25 +671,26 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
542
671
  // id, goal at full width, and only SHORT status words. Line 2 carries the machinery, dim and
543
672
  // aligned under the goal: gate chain, live activity phrase (or channel), ctx. Long channel names
544
673
  // 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);
674
+ const taskVerdict = (c) => c.merged ? "pass" : c.redTier ? "fail" : c.st === "failed" || c.st === "human" ? "warn" : "neutral";
675
+ const statusWord = (c) => c.livePhase ? "running" : c.redTier ? "failed" : c.st === "failed" ? "warn" : surfaceStatusWord(c.st);
547
676
  const idW = Math.max(...cells.map((c) => c.t.id.length), 2);
548
677
  // 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);
678
+ const suffixPlain = (c) => (c.isStarved ? " · starved" : "") + failedSuffix(c.states)
679
+ + humanGateSuffix(c.t, graphTaskStatus(c.st, c.t.status), c.states);
550
680
  const stW = Math.max(...cells.map((c) => statusWord(c).length + suffixPlain(c).length));
551
681
  const avail = Math.max(8, width - (5 + idW) - 2 - stW);
552
682
  const goals = cells.map((c) => shortGoal(c.t.goal, avail));
553
683
  const goalW = Math.max(8, ...goals.map((s) => s.length));
554
684
  const indent = " ".repeat(idW + 5); // line 2 starts under the goal column
555
685
  const rows = cells.map((c, i) => {
556
- const { t, st, failureKind, redTier, states, priorGraph, isStarved, phrase, channel, ctx, livePhase } = c;
686
+ const { t, st, merged, failureKind, redTier, states, priorGraph, isStarved, phrase, channel, ctx, livePhase } = c;
557
687
  const word = statusWord(c);
558
688
  const staleWorker = livePhase?.phase === "worker"
559
689
  && 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;
690
+ const stWord = staleWorker ? warn(word) : merged ? ok(word) : redTier ? fail(word) : st === "failed" || st === "human" ? warn(word) : word;
561
691
  // a fail names its gate in words right here — the one moment gate identity is needed on a row
562
692
  const f = failedGates(states);
563
- const human = humanGateSuffix(t, st, states);
693
+ const human = humanGateSuffix(t, graphTaskStatus(st, t.status), states);
564
694
  const statusCell = stWord +
565
695
  (isStarved ? dot + fail("starved") : "") +
566
696
  (f.length ? dot + fail(f.join(", ")) : "") +
@@ -709,7 +839,7 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
709
839
  }
710
840
  }
711
841
  else {
712
- frame = renderFrame(cwd, nowMs, i, workerLiveness);
842
+ frame = renderFrame(cwd, nowMs, i, workerLiveness, true);
713
843
  if (tty) {
714
844
  updateTitle(frame.hotPhase, nowMs);
715
845
  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[];