tickmarkr 2.4.2 → 2.5.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 (70) hide show
  1. package/README.md +110 -24
  2. package/dist/cli/commands/approve.d.ts +42 -0
  3. package/dist/cli/commands/approve.js +80 -13
  4. package/dist/cli/commands/doctor.d.ts +234 -0
  5. package/dist/cli/commands/doctor.js +139 -5
  6. package/dist/cli/commands/report.js +15 -44
  7. package/dist/cli/commands/scope.js +36 -6
  8. package/dist/cli/commands/stats.d.ts +2 -0
  9. package/dist/cli/commands/stats.js +42 -23
  10. package/dist/cli/commands/status.js +20 -4
  11. package/dist/cli/commands/ui.js +42 -53
  12. package/dist/cli/commands/unlock.d.ts +1 -1
  13. package/dist/cli/commands/unlock.js +59 -9
  14. package/dist/cli/help.d.ts +219 -0
  15. package/dist/cli/help.js +212 -0
  16. package/dist/cli/index.d.ts +42 -2
  17. package/dist/cli/index.js +23 -8
  18. package/dist/drivers/herdr.d.ts +7 -2
  19. package/dist/drivers/herdr.js +78 -48
  20. package/dist/drivers/orca.d.ts +3 -2
  21. package/dist/drivers/orca.js +43 -4
  22. package/dist/drivers/subprocess.d.ts +1 -0
  23. package/dist/drivers/subprocess.js +3 -0
  24. package/dist/drivers/types.d.ts +14 -0
  25. package/dist/gates/artifact-manifest.d.ts +50 -0
  26. package/dist/gates/artifact-manifest.js +23 -0
  27. package/dist/plan/scope.d.ts +25 -0
  28. package/dist/plan/scope.js +92 -12
  29. package/dist/report/operator-record.d.ts +49 -0
  30. package/dist/report/operator-record.js +137 -0
  31. package/dist/run/daemon.d.ts +3 -4
  32. package/dist/run/daemon.js +18 -10
  33. package/dist/run/lock.d.ts +59 -2
  34. package/dist/run/lock.js +184 -26
  35. package/dist/run/operator-state.d.ts +86 -0
  36. package/dist/run/operator-state.js +165 -0
  37. package/dist/run/supervision.d.ts +32 -0
  38. package/dist/run/supervision.js +138 -17
  39. package/dist/tui/cockpit/capture.d.ts +19 -0
  40. package/dist/tui/cockpit/capture.js +89 -1
  41. package/dist/tui/cockpit/components.d.ts +15 -1
  42. package/dist/tui/cockpit/components.js +79 -9
  43. package/dist/tui/cockpit/decision-actions.d.ts +147 -0
  44. package/dist/tui/cockpit/decision-actions.js +315 -0
  45. package/dist/tui/cockpit/derive.d.ts +1 -1
  46. package/dist/tui/cockpit/derive.js +2 -0
  47. package/dist/tui/cockpit/evidence-view.d.ts +119 -0
  48. package/dist/tui/cockpit/evidence-view.js +210 -0
  49. package/dist/tui/cockpit/home-view.d.ts +88 -0
  50. package/dist/tui/cockpit/home-view.js +240 -0
  51. package/dist/tui/cockpit/keys.d.ts +125 -0
  52. package/dist/tui/cockpit/keys.js +31 -0
  53. package/dist/tui/cockpit/layout.d.ts +14 -0
  54. package/dist/tui/cockpit/layout.js +15 -0
  55. package/dist/tui/cockpit/live-runtime.d.ts +46 -0
  56. package/dist/tui/cockpit/live-runtime.js +683 -0
  57. package/dist/tui/cockpit/live-store.d.ts +289 -0
  58. package/dist/tui/cockpit/live-store.js +308 -0
  59. package/dist/tui/cockpit/live.d.ts +21 -1
  60. package/dist/tui/cockpit/live.js +12 -1
  61. package/dist/tui/cockpit/run-view.d.ts +100 -0
  62. package/dist/tui/cockpit/run-view.js +202 -0
  63. package/dist/tui/cockpit/shell.d.ts +50 -0
  64. package/dist/tui/cockpit/shell.js +74 -0
  65. package/dist/tui/cockpit/theme.d.ts +27 -0
  66. package/dist/tui/cockpit/theme.js +21 -0
  67. package/package.json +1 -1
  68. package/skills/tickmarkr-auto/SKILL.md +10 -1
  69. package/skills/tickmarkr-loop/SKILL.md +72 -1
  70. package/skills/tickmarkr-overseer/SKILL.md +12 -0
package/dist/run/lock.js CHANGED
@@ -1,4 +1,4 @@
1
- import { linkSync, readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
1
+ import { closeSync, constants, fstatSync, linkSync, lstatSync, mkdtempSync, openSync, readFileSync, renameSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { tickmarkrDir, stateDirName } from "../graph/graph.js";
@@ -28,9 +28,10 @@ let approvalSequence = 0;
28
28
  process.once("exit", () => { if (heldPath)
29
29
  unlinkIfOurs(heldPath); });
30
30
  // LOCK-04: the ONE decision table. Both acquireRunLock and isRunLockLive consume this — the two
31
- // hand-maintained copies of the rule cannot drift. The table changes HERE, once. unlockRun uses the
32
- // SAME inspect() with its own live-holder refusal (`!dead && !garbage`) — it is the escape hatch, not
33
- // a second copy of the rule.
31
+ // hand-maintained copies of the rule cannot drift. The table changes HERE, once. previewUnlock /
32
+ // commitUnlock (below) read the SAME underlying snapshot shape with their own eligibility rules
33
+ // (matching run ID, provably dead, not garbage) — they are the escape hatch, not a second copy of
34
+ // the rule.
34
35
  // LOCK-02 (OBS-05): refuse iff garbage OR alive. dead (ESRCH) self-clears through the reclaim branch
35
36
  // below — no 60s heartbeat wait. Expiry no longer participates in the DECISION (Inspect still carries
36
37
  // `expired` for the race guard + reclaim audit); the heartbeat mechanism itself is untouched.
@@ -139,10 +140,10 @@ export function acquireRunLock(repoRoot, runId) {
139
140
  }
140
141
  const stateDir = stateDirName(repoRoot);
141
142
  if (garbage)
142
- throw new Error(`${stateDir}/graph.lock holds an unreadable/garbage payload — refusing to reclaim it; run \`tickmarkr unlock\` to remove it`);
143
+ throw new Error(`${stateDir}/graph.lock holds an unreadable/garbage payload — refusing to reclaim it; run \`tickmarkr unlock --garbage\` to remove it`);
143
144
  // LOCK-02: shouldRefuse is false whenever dead, so this throw is reached only for a LIVE holder
144
145
  // (incl. EPERM = alive-but-not-ours). The dead-but-fresh case self-clears via the reclaim branch.
145
- throw new Error(`${stateDir}/graph.lock held by pid ${pid ?? "?"}${heldRun ? ` (run ${heldRun})` : ""} — another tickmarkr run? (operator escape: \`tickmarkr unlock\`)`);
146
+ throw new Error(`${stateDir}/graph.lock held by pid ${pid ?? "?"}${heldRun ? ` (run ${heldRun})` : ""} — another tickmarkr run? (operator escape: \`tickmarkr unlock ${heldRun ?? "<run-id>"}\`)`);
146
147
  }
147
148
  }
148
149
  export function releaseRunLock(repoRoot) {
@@ -293,32 +294,189 @@ export function runStatusLine(repoRoot, runId, events) {
293
294
  export function isRunLockLive(repoRoot) {
294
295
  return runLockOwner(repoRoot)?.live ?? false;
295
296
  }
296
- // LOCK-03: operator escape hatch. Liveness-checked delete — removes a dead-holder or garbage lock,
297
- // REFUSES to remove one whose holder is alive (incl. EPERM = alive-but-not-ours). No --force: the
298
- // refusal names the pid; the operator kills the process. Liveness logic stays here (LOCK-04
299
- // discipline extends to this caller) — the CLI command is a thin formatter.
300
- // W4 TOCTOU: liveness is checked, THEN unlinked — a live holder that dies (or a dead pid reused)
301
- // between inspect() and unlinkSync is a window this does NOT close. Acceptable: unlock is
302
- // operator-initiated on an already-parked run; worst case is removing a lock a just-reborn process
303
- // would want, which the operator triggered and can recover by re-running. NOT an atomic re-check.
304
- export function unlockRun(repoRoot) {
305
- const p = lockPath(repoRoot);
306
- let insp;
297
+ // Unlike inspect() (owned by the acquire/reclaim path this task must not change), a stat failure
298
+ // here is never silently read as "no lock" — only ENOENT is. Any other stat error (e.g. EACCES)
299
+ // propagates so the CLI fails closed and loud instead of returning the neutral "nothing to remove"
300
+ // receipt over a lock it could not actually see (R41).
301
+ //
302
+ // A content READ failure (stat succeeds, the bytes don't — e.g. EACCES) is its OWN `unreadable`
303
+ // state, distinct from `garbage`: we never observed the bytes, so we cannot confirm the payload is
304
+ // malformed, and a well-formed live lock made merely unreadable must never become eligible for
305
+ // either recovery route. `raw` stays a Buffer (never decoded) for identity: decoding invalid UTF-8
306
+ // collapses distinct byte sequences to the same replacement-character string, which would let a
307
+ // changed-bytes attack slip past a text-equality check (AC2's byte-confirmation requirement).
308
+ function readLockSnapshot(p) {
309
+ let st;
307
310
  try {
308
- insp = inspect(p);
311
+ st = lstatSync(p);
309
312
  }
310
- catch {
311
- return { held: false };
312
- } // statSync ENOENT ⇒ no lock
313
- if (!insp.dead && !insp.garbage) {
314
- throw new Error(`${stateDirName(repoRoot)}/graph.lock held by LIVE pid ${insp.pid}${insp.runId ? ` (run ${insp.runId})` : ""} — refusing to unlock; stop that run first`);
313
+ catch (e) {
314
+ if (e.code === "ENOENT")
315
+ return undefined;
316
+ throw e;
315
317
  }
318
+ const unreadable = () => ({ garbage: false, unreadable: true, dead: true, ino: st.ino, mtimeMs: st.mtimeMs, raw: Buffer.alloc(0) });
319
+ if (!st.isFile())
320
+ return unreadable();
321
+ let raw;
322
+ let fd;
316
323
  try {
317
- unlinkSync(p);
324
+ // Read bytes and inode from the same open file. Refuse symlinks/non-files even if the path
325
+ // changes after lstat; NONBLOCK prevents a substituted FIFO from hanging the command.
326
+ fd = openSync(p, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
327
+ st = fstatSync(fd);
328
+ if (!st.isFile())
329
+ return unreadable();
330
+ raw = readFileSync(fd);
318
331
  }
319
332
  catch (e) {
320
- if (e.code !== "ENOENT")
333
+ if (e.code === "ENOENT")
334
+ return undefined; // raced away between stat and read
335
+ return unreadable();
336
+ }
337
+ finally {
338
+ if (fd !== undefined)
339
+ closeSync(fd);
340
+ }
341
+ let parsedJson = null;
342
+ try {
343
+ parsedJson = JSON.parse(raw.toString("utf8"));
344
+ }
345
+ catch { /* not JSON ⇒ garbage below */ }
346
+ const parsed = PayloadSchema.safeParse(parsedJson);
347
+ const pid = parsed.success ? parsed.data.pid : undefined;
348
+ return {
349
+ pid,
350
+ runId: parsed.success ? parsed.data.runId : undefined,
351
+ garbage: !parsed.success,
352
+ unreadable: false,
353
+ dead: pid === undefined ? true : !isPidLive(pid),
354
+ ino: st.ino,
355
+ mtimeMs: st.mtimeMs,
356
+ raw,
357
+ };
358
+ }
359
+ // rename is the single capture operation, not a snapshot followed by an unlink of graph.lock.
360
+ // mkdtemp gives this commit a private destination on the same filesystem. All validation and
361
+ // deletion happens there, so a later acquisition at graph.lock survives even during the read.
362
+ function commitCapturedLock(repoRoot, validate) {
363
+ const p = lockPath(repoRoot);
364
+ const dir = mkdtempSync(`${p}.unlock-`);
365
+ const captured = join(dir, "graph.lock");
366
+ const restore = (reason) => {
367
+ try {
368
+ // Never rename back: that would clobber a successor installed while we validated.
369
+ linkSync(captured, p);
370
+ }
371
+ catch (e) {
372
+ return { removed: false, reason: `${reason}; could not restore graph.lock (${e.code}) — captured entry retained at ${captured}` };
373
+ }
374
+ try {
375
+ unlinkSync(captured);
376
+ }
377
+ catch (e) {
378
+ if (e.code !== "ENOENT") {
379
+ return { removed: false, reason: `${reason}; graph.lock restored, additional captured link retained at ${captured}: ${e.message}` };
380
+ }
381
+ }
382
+ return { removed: false, reason };
383
+ };
384
+ try {
385
+ try {
386
+ renameSync(p, captured);
387
+ }
388
+ catch (e) {
389
+ if (e.code === "ENOENT")
390
+ return { removed: false, reason: "graph.lock no longer exists — nothing captured or removed" };
321
391
  throw e;
392
+ }
393
+ try {
394
+ const snap = readLockSnapshot(captured);
395
+ if (!snap)
396
+ return { removed: false, reason: "captured graph.lock no longer exists — not removed" };
397
+ const reason = validate(snap);
398
+ if (reason)
399
+ return restore(reason);
400
+ unlinkSync(captured);
401
+ return { removed: true, snap };
402
+ }
403
+ catch (e) {
404
+ if (e.code === "ENOENT")
405
+ return { removed: false, reason: "captured graph.lock no longer exists — not removed" };
406
+ return restore(`graph.lock could not be removed: ${e.message}`);
407
+ }
408
+ }
409
+ finally {
410
+ // Best-effort empty-directory cleanup only. Never recursively delete a refused capture that
411
+ // could not be restored, or obscure its recovery path with a cleanup error.
412
+ try {
413
+ rmdirSync(dir);
414
+ }
415
+ catch { /* retained capture or directory cleanup failure */ }
416
+ }
417
+ }
418
+ // AC1: eligible only for a matching-run, provably-dead, non-garbage lock. Live, EPERM and any other
419
+ // signal-probe error all read `dead: false` through the one shared isPidLive predicate — never a
420
+ // second copy that would treat an unknown errno as death. A different run ID refuses even when the
421
+ // holder is dead: the operator named a run, and this is not it.
422
+ export function previewUnlock(repoRoot, runId) {
423
+ const snap = readLockSnapshot(lockPath(repoRoot));
424
+ if (!snap)
425
+ return { held: false };
426
+ const stateDir = stateDirName(repoRoot);
427
+ if (snap.unreadable) {
428
+ return { held: true, eligible: false, reason: `${stateDir}/graph.lock content could not be read — refusing to unlock without observing its payload` };
429
+ }
430
+ if (snap.garbage) {
431
+ return { held: true, eligible: false, reason: `${stateDir}/graph.lock holds an unreadable/garbage payload — run \`tickmarkr unlock --garbage\` to recover it, not a named unlock`, pid: snap.pid, runId: snap.runId };
432
+ }
433
+ if (snap.runId !== runId) {
434
+ return { held: true, eligible: false, reason: `${stateDir}/graph.lock is held for run ${snap.runId ?? "?"}, not ${runId} — refusing to unlock a different run`, pid: snap.pid, runId: snap.runId };
435
+ }
436
+ if (!snap.dead) {
437
+ return { held: true, eligible: false, reason: `${stateDir}/graph.lock held by LIVE pid ${snap.pid}${snap.runId ? ` (run ${snap.runId})` : ""} — refusing to unlock; stop that run first`, pid: snap.pid, runId: snap.runId };
438
+ }
439
+ return { held: true, eligible: true, pid: snap.pid, runId: snap.runId, ino: snap.ino, mtimeMs: snap.mtimeMs, raw: snap.raw };
440
+ }
441
+ export function commitUnlock(repoRoot, target) {
442
+ const commit = commitCapturedLock(repoRoot, (snap) => {
443
+ if (snap.unreadable || snap.garbage || snap.ino !== target.ino || snap.mtimeMs !== target.mtimeMs || snap.pid !== target.pid || !snap.raw.equals(target.raw)) {
444
+ return "graph.lock changed since preview — refusing to remove the new holder";
445
+ }
446
+ if (snap.runId !== target.runId)
447
+ return `graph.lock now names run ${snap.runId ?? "?"}, not ${target.runId}`;
448
+ if (!snap.dead)
449
+ return `holder pid ${snap.pid} is alive`;
450
+ });
451
+ if (!commit.removed)
452
+ return commit;
453
+ return { removed: true, pid: commit.snap.pid, runId: commit.snap.runId };
454
+ }
455
+ // AC2: eligible only for an ACTUALLY malformed snapshot — a well-formed payload (dead or live) is
456
+ // refused here and pointed at the ordinary named route instead, so --garbage can never become a
457
+ // second, run-ID-free way to remove a legitimate lock. An unreadable payload is refused too: its
458
+ // bytes were never observed, so it is neither confirmed malformed nor confirmed well-formed.
459
+ export function previewGarbageUnlock(repoRoot) {
460
+ const snap = readLockSnapshot(lockPath(repoRoot));
461
+ if (!snap)
462
+ return { held: false };
463
+ if (snap.unreadable) {
464
+ return { held: true, eligible: false, reason: `${stateDirName(repoRoot)}/graph.lock content could not be read — refusing to treat it as garbage without observing its bytes` };
322
465
  }
323
- return { held: true, removed: true, pid: insp.pid, runId: insp.runId, garbage: insp.garbage };
466
+ if (!snap.garbage) {
467
+ return { held: true, eligible: false, reason: `${stateDirName(repoRoot)}/graph.lock holds a well-formed payload — run \`tickmarkr unlock ${snap.runId ?? "<run-id>"}\` instead of --garbage`, pid: snap.pid, runId: snap.runId };
468
+ }
469
+ return { held: true, eligible: true, ino: snap.ino, raw: snap.raw };
470
+ }
471
+ export function commitGarbageUnlock(repoRoot, target) {
472
+ const commit = commitCapturedLock(repoRoot, (snap) => {
473
+ if (snap.unreadable)
474
+ return "graph.lock content became unreadable since preview — refusing to remove it blind";
475
+ if (!snap.garbage)
476
+ return "graph.lock now holds a well-formed payload — refusing to remove a valid holder";
477
+ if (snap.ino !== target.ino || !snap.raw.equals(target.raw)) {
478
+ return "graph.lock bytes/inode changed since preview — refusing to remove the new file";
479
+ }
480
+ });
481
+ return commit.removed ? { removed: true } : commit;
324
482
  }
@@ -0,0 +1,86 @@
1
+ import { type RunGraph } from "../graph/schema.js";
2
+ import { type JournalEvent } from "./journal.js";
3
+ /** An evidence identity is a physical journal line, never a filtered row ordinal. */
4
+ export interface EvidenceIdentity {
5
+ source: string;
6
+ line: number;
7
+ id: string;
8
+ generation?: number;
9
+ }
10
+ export interface OperatorRecord extends EvidenceIdentity {
11
+ event: JournalEvent;
12
+ }
13
+ export type OperatorTaskState = "unknown" | "pending" | "running" | "completed" | "merged" | "failed" | "human" | "blocked";
14
+ export type OperatorGateState = "unknown" | "not-run" | "disabled" | "running" | "passed" | "failed";
15
+ export interface OperatorGate {
16
+ state: OperatorGateState;
17
+ evidence?: EvidenceIdentity;
18
+ }
19
+ export interface OperatorTask {
20
+ id: string;
21
+ title?: string;
22
+ state: OperatorTaskState;
23
+ merged: boolean;
24
+ dispatches: number;
25
+ attempt?: number;
26
+ path?: string;
27
+ pane?: string;
28
+ alarmMs?: number;
29
+ parkKind?: string;
30
+ evidence?: EvidenceIdentity;
31
+ mergeEvidence?: EvidenceIdentity;
32
+ gates: Record<string, OperatorGate>;
33
+ }
34
+ export interface OperatorSnapshot {
35
+ sequence: number;
36
+ observedAt: number;
37
+ lastEventAt?: string;
38
+ lifecycle: "PENDING" | "RUNNING" | "PARTIAL" | "APPROVED" | "COMPLETE" | "UNKNOWN";
39
+ label: string;
40
+ green: boolean;
41
+ currentTip: "passed" | "not required" | "pending" | "failed" | "unknown";
42
+ comparable: boolean;
43
+ comparison: "matching graph" | "not comparable";
44
+ merged: number;
45
+ planned?: number;
46
+ tasks: readonly OperatorTask[];
47
+ buckets: Record<"failed" | "human" | "blocked" | "pending", readonly string[] | undefined>;
48
+ gatesRan: {
49
+ passed: number;
50
+ total: number;
51
+ };
52
+ latestRunEnd?: EvidenceIdentity;
53
+ approvedResumeRequired: boolean;
54
+ }
55
+ /** Incremental pure lifecycle fold. Retains facts per task/gate, never verdict bodies or event history. */
56
+ export declare class OperatorStateFold {
57
+ private tasks;
58
+ private start?;
59
+ private startEvent?;
60
+ private latestGraphRehash?;
61
+ private end?;
62
+ private active;
63
+ private approved;
64
+ private tipFailed;
65
+ private lastEventAt?;
66
+ private passed;
67
+ private total;
68
+ apply(record: OperatorRecord): void;
69
+ private task;
70
+ snapshot({ graph, sequence, observedAt, readable }?: {
71
+ graph?: RunGraph;
72
+ sequence?: number;
73
+ observedAt?: number;
74
+ readable?: boolean;
75
+ }): OperatorSnapshot;
76
+ private comparableTo;
77
+ }
78
+ /** C1/C6 share this pure reader; callers supply the same observation and journal snapshot. */
79
+ export declare function readOperatorState({ events, source, ...options }: {
80
+ events: readonly (JournalEvent | OperatorRecord)[];
81
+ source?: string;
82
+ graph?: RunGraph;
83
+ sequence?: number;
84
+ observedAt?: number;
85
+ readable?: boolean;
86
+ }): OperatorSnapshot;
@@ -0,0 +1,165 @@
1
+ import { graphDefinitionHash } from "../graph/graph.js";
2
+ import { GATE_NAMES } from "../graph/schema.js";
3
+ import { engagementComparable } from "./journal.js";
4
+ const bucketNames = ["failed", "human", "blocked", "pending"];
5
+ const strings = (value) => Array.isArray(value) && value.every(v => typeof v === "string") ? [...value] : undefined;
6
+ const identity = ({ source, line, id, generation }) => ({ source, line, id, generation });
7
+ const emptyGates = () => Object.fromEntries(GATE_NAMES.map(g => [g, { state: "not-run" }]));
8
+ /** Incremental pure lifecycle fold. Retains facts per task/gate, never verdict bodies or event history. */
9
+ export class OperatorStateFold {
10
+ tasks = new Map();
11
+ start;
12
+ startEvent;
13
+ latestGraphRehash;
14
+ end;
15
+ active = false;
16
+ approved = false;
17
+ tipFailed = false;
18
+ lastEventAt;
19
+ passed = 0;
20
+ total = 0;
21
+ apply(record) {
22
+ const { event: e } = record;
23
+ this.lastEventAt = e.ts;
24
+ const ref = identity(record);
25
+ if (e.event === "run-start" || e.event === "run-resume") {
26
+ if (e.event === "run-start" && !this.start) {
27
+ const startEvent = { ...e, data: { graphDefinitionHash: e.data.graphDefinitionHash } };
28
+ this.start = { ...ref, event: startEvent };
29
+ this.startEvent = startEvent;
30
+ }
31
+ this.end = undefined;
32
+ this.active = true;
33
+ this.approved = false;
34
+ this.tipFailed = false;
35
+ }
36
+ if (e.event === "graph-rehash")
37
+ this.latestGraphRehash = { ...e, data: { from: e.data.from, to: e.data.to } };
38
+ if (e.event === "tip-verify-failed" || (e.event === "tip-verify" && e.data.pass === false))
39
+ this.tipFailed = true;
40
+ if (e.event === "run-end") {
41
+ const tip = e.data.tipVerify;
42
+ this.end = {
43
+ evidence: ref,
44
+ buckets: Object.fromEntries(bucketNames.map(k => [k, strings(e.data[k])])),
45
+ tip: tip === "passed" ? "passed" : tip === "failed" ? "failed" : tip === "not required" || tip === "not-required" || tip === "skipped" ? "not required" : "unknown",
46
+ };
47
+ this.active = false;
48
+ this.approved = false;
49
+ for (const key of bucketNames)
50
+ for (const id of this.end.buckets[key] ?? []) {
51
+ const task = this.task(id);
52
+ task.state = key;
53
+ task.evidence = ref;
54
+ }
55
+ }
56
+ if (e.event === "gate-result" && e.data.skipped !== true && e.data.disabled !== true && typeof e.data.pass === "boolean") {
57
+ this.total++;
58
+ if (e.data.pass)
59
+ this.passed++;
60
+ }
61
+ if (!e.taskId)
62
+ return;
63
+ const t = this.task(e.taskId);
64
+ t.evidence = ref;
65
+ if (e.event === "task-dispatch") {
66
+ t.state = "running";
67
+ t.dispatches++;
68
+ t.parkKind = undefined;
69
+ t.gates = emptyGates();
70
+ t.attempt = typeof e.data.attempt === "number" ? e.data.attempt : undefined;
71
+ // These are recorded values only: no guessed directory or universal silence threshold.
72
+ t.path = typeof e.data.worktree === "string" ? e.data.worktree : typeof e.data.cwd === "string" ? e.data.cwd : undefined;
73
+ t.pane = typeof e.data.pane === "string" ? e.data.pane : undefined;
74
+ t.alarmMs = typeof e.data.alarmMs === "number" ? e.data.alarmMs : undefined;
75
+ }
76
+ if (e.event === "task-done")
77
+ t.state = "completed";
78
+ if (e.event === "merge") {
79
+ t.state = "merged";
80
+ t.merged = true;
81
+ t.mergeEvidence = ref;
82
+ t.parkKind = undefined;
83
+ }
84
+ if (e.event === "task-failed")
85
+ t.state = "failed";
86
+ if (e.event === "task-human") {
87
+ t.state = "human";
88
+ t.parkKind = typeof e.data.kind === "string" ? e.data.kind : undefined;
89
+ }
90
+ if (e.event === "task-blocked")
91
+ t.state = "blocked";
92
+ if (e.event === "task-approved") {
93
+ t.state = "pending";
94
+ t.parkKind = undefined;
95
+ this.approved = !this.active;
96
+ }
97
+ const gate = e.data.gate;
98
+ if (typeof gate === "string" && GATE_NAMES.includes(gate)) {
99
+ if (e.event === "gate-start")
100
+ t.gates[gate] = { state: "running", evidence: ref };
101
+ if (e.event === "gate-result")
102
+ t.gates[gate] = {
103
+ state: e.data.disabled === true ? "disabled" : e.data.skipped === true ? "not-run" : e.data.pass === true ? "passed" : e.data.pass === false ? "failed" : "unknown", evidence: ref,
104
+ };
105
+ }
106
+ }
107
+ task(id) {
108
+ let t = this.tasks.get(id);
109
+ if (!t) {
110
+ t = { id, state: "unknown", merged: false, dispatches: 0, gates: emptyGates() };
111
+ this.tasks.set(id, t);
112
+ }
113
+ return t;
114
+ }
115
+ snapshot({ graph, sequence = 0, observedAt = Date.now(), readable = true } = {}) {
116
+ const hash = graph && graphDefinitionHash(graph);
117
+ const comparable = this.comparableTo(hash);
118
+ const ids = comparable ? [...graph.tasks.map(t => t.id), ...[...this.tasks.keys()].filter(id => !graph.tasks.some(t => t.id === id))] : [...this.tasks.keys()];
119
+ const tasks = ids.map(id => {
120
+ const t = this.tasks.get(id) ?? { id, state: "unknown", merged: false, dispatches: 0, gates: emptyGates() };
121
+ return { ...t, title: comparable ? graph.tasks.find(g => g.id === id)?.title : undefined, gates: Object.fromEntries(Object.entries(t.gates).map(([g, cell]) => [g, { ...cell }])) };
122
+ });
123
+ const buckets = Object.fromEntries(bucketNames.map(k => [k, this.end?.buckets[k] === undefined ? undefined : [...this.end.buckets[k]]]));
124
+ // A matching graph contributes planned tasks even when the journal has never named them.
125
+ // Their silence is unknown evidence, not completion. Comparison itself is also required for
126
+ // green: without the recorded graph identity there is no trustworthy planned denominator.
127
+ const merged = tasks.filter(t => t.merged && (!comparable || graph.tasks.some(g => g.id === t.id))).length;
128
+ const planned = comparable ? graph.tasks.length : undefined;
129
+ const unresolved = tasks.some(t => ["unknown", "failed", "human", "blocked", "pending", "running"].includes(t.state));
130
+ const allPlannedMerged = comparable && merged === planned;
131
+ const currentTip = this.active ? "pending" : this.tipFailed ? "failed" : this.end?.tip ?? "unknown";
132
+ const green = readable && comparable && allPlannedMerged && !!this.start && !!this.end && !this.approved && !unresolved
133
+ && (currentTip === "passed" || currentTip === "not required")
134
+ && bucketNames.every(k => buckets[k]?.length === 0);
135
+ const lifecycle = !readable || !this.start ? "UNKNOWN" : this.active ? "RUNNING" : this.approved ? "APPROVED" : green ? "COMPLETE" : this.end ? "PARTIAL" : "PENDING";
136
+ return {
137
+ sequence, observedAt, lastEventAt: this.lastEventAt, lifecycle,
138
+ label: this.approved ? "approved; resume required" : lifecycle,
139
+ green, currentTip, comparable, comparison: comparable ? "matching graph" : "not comparable",
140
+ merged,
141
+ planned, tasks, buckets,
142
+ gatesRan: { passed: this.passed, total: this.total }, latestRunEnd: this.end?.evidence,
143
+ approvedResumeRequired: this.approved,
144
+ };
145
+ }
146
+ comparableTo(hash) {
147
+ if (!hash)
148
+ return false;
149
+ const events = [this.startEvent, this.latestGraphRehash].filter((e) => e !== undefined);
150
+ const baseline = engagementComparable(events, hash);
151
+ if (this.latestGraphRehash) {
152
+ const from = baseline.comparable ? baseline.recorded : baseline.reason === "mismatch" ? baseline.recorded : null;
153
+ return this.latestGraphRehash.data.to === hash && this.latestGraphRehash.data.from === from;
154
+ }
155
+ return baseline.comparable;
156
+ }
157
+ }
158
+ /** C1/C6 share this pure reader; callers supply the same observation and journal snapshot. */
159
+ export function readOperatorState({ events, source = "journal.jsonl", ...options }) {
160
+ const fold = new OperatorStateFold();
161
+ events.forEach((event, i) => fold.apply(typeof event.event === "object" ? event : {
162
+ source, line: i + 1, id: `${source}#L${i + 1}`, event: event,
163
+ }));
164
+ return fold.snapshot(options);
165
+ }
@@ -43,6 +43,9 @@ export declare function beatSupervision(repoRoot: string, tier: SupervisionTier,
43
43
  /** Handle a watcher holds for as long as it is supervising; disarm stands it down and is idempotent. */
44
44
  export interface ArmedSupervision {
45
45
  disarm: () => void;
46
+ readonly id?: string;
47
+ /** True only after this handle removed its own presence. */
48
+ released?: () => boolean;
46
49
  }
47
50
  export declare function armSupervision(repoRoot: string, tier: SupervisionTier, beatMs?: number, seat?: string): ArmedSupervision;
48
51
  /** Arm the live status board's own tier through the normal supervision writer and lifecycle. */
@@ -53,3 +56,32 @@ export declare function supervisionStatus(repoRoot: string, tier: SupervisionTie
53
56
  export declare const readSupervision: (repoRoot: string, now?: number) => TierLiveness[];
54
57
  /** One line, one word per tier. Shared by both status surfaces so neither can render a state twice. */
55
58
  export declare const supervisionText: (tiers: readonly TierLiveness[], divider?: string) => string;
59
+ /** A board's durable identity is independent of its short title and of other observers. */
60
+ export interface WatchBoardOwner {
61
+ repo: string;
62
+ runId: string;
63
+ driver: string;
64
+ workspace: string;
65
+ pane: string;
66
+ name: string;
67
+ token: string;
68
+ pid?: number;
69
+ armId?: string;
70
+ }
71
+ export declare const WATCH_OWNER_ENV = "TICKMARKR_WATCH_OWNER";
72
+ export declare function readWatchBoard(repo: string, runId: string): WatchBoardOwner | undefined;
73
+ /** Only a driver that has just verified placement may reserve this binding. */
74
+ export declare function reserveWatchBoard(owner: Omit<WatchBoardOwner, "repo" | "token" | "pid" | "armId"> & {
75
+ repo: string;
76
+ }): WatchBoardOwner;
77
+ export declare function requestWatchBoardStop(owner: WatchBoardOwner): void;
78
+ export declare function watchBoardAcknowledged(owner: WatchBoardOwner): boolean;
79
+ export declare function stopWatchBoard(owner: WatchBoardOwner, time?: {
80
+ now: () => number;
81
+ sleep: (ms: number) => Promise<void>;
82
+ }, timeoutMs?: number): Promise<void>;
83
+ /** The UI alone publishes acknowledgement, after releasing its own presence. */
84
+ export declare function observeNamedRun(repo: string, runId: string, env?: NodeJS.ProcessEnv): {
85
+ stopRequested: () => boolean;
86
+ close: () => void;
87
+ };