tickmarkr 2.4.1 → 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 (75) hide show
  1. package/README.md +110 -24
  2. package/dist/adapters/claude-code.js +7 -2
  3. package/dist/adapters/codex.d.ts +1 -0
  4. package/dist/adapters/codex.js +66 -5
  5. package/dist/adapters/types.d.ts +4 -0
  6. package/dist/adapters/types.js +33 -0
  7. package/dist/cli/commands/approve.d.ts +42 -0
  8. package/dist/cli/commands/approve.js +80 -13
  9. package/dist/cli/commands/doctor.d.ts +234 -0
  10. package/dist/cli/commands/doctor.js +139 -5
  11. package/dist/cli/commands/report.js +15 -44
  12. package/dist/cli/commands/scope.js +36 -6
  13. package/dist/cli/commands/stats.d.ts +2 -0
  14. package/dist/cli/commands/stats.js +42 -23
  15. package/dist/cli/commands/status.js +20 -4
  16. package/dist/cli/commands/ui.js +42 -53
  17. package/dist/cli/commands/unlock.d.ts +1 -1
  18. package/dist/cli/commands/unlock.js +59 -9
  19. package/dist/cli/help.d.ts +219 -0
  20. package/dist/cli/help.js +212 -0
  21. package/dist/cli/index.d.ts +42 -2
  22. package/dist/cli/index.js +23 -8
  23. package/dist/drivers/herdr.d.ts +7 -2
  24. package/dist/drivers/herdr.js +78 -48
  25. package/dist/drivers/orca.d.ts +3 -2
  26. package/dist/drivers/orca.js +43 -4
  27. package/dist/drivers/subprocess.d.ts +1 -0
  28. package/dist/drivers/subprocess.js +3 -0
  29. package/dist/drivers/types.d.ts +14 -0
  30. package/dist/gates/artifact-manifest.d.ts +50 -0
  31. package/dist/gates/artifact-manifest.js +23 -0
  32. package/dist/plan/scope.d.ts +25 -0
  33. package/dist/plan/scope.js +92 -12
  34. package/dist/report/operator-record.d.ts +49 -0
  35. package/dist/report/operator-record.js +137 -0
  36. package/dist/run/daemon.d.ts +3 -4
  37. package/dist/run/daemon.js +18 -10
  38. package/dist/run/lock.d.ts +59 -2
  39. package/dist/run/lock.js +184 -26
  40. package/dist/run/operator-state.d.ts +86 -0
  41. package/dist/run/operator-state.js +165 -0
  42. package/dist/run/supervision.d.ts +32 -0
  43. package/dist/run/supervision.js +138 -17
  44. package/dist/tui/cockpit/capture.d.ts +19 -0
  45. package/dist/tui/cockpit/capture.js +89 -1
  46. package/dist/tui/cockpit/components.d.ts +15 -1
  47. package/dist/tui/cockpit/components.js +79 -9
  48. package/dist/tui/cockpit/decision-actions.d.ts +147 -0
  49. package/dist/tui/cockpit/decision-actions.js +315 -0
  50. package/dist/tui/cockpit/derive.d.ts +1 -1
  51. package/dist/tui/cockpit/derive.js +2 -0
  52. package/dist/tui/cockpit/evidence-view.d.ts +119 -0
  53. package/dist/tui/cockpit/evidence-view.js +210 -0
  54. package/dist/tui/cockpit/home-view.d.ts +88 -0
  55. package/dist/tui/cockpit/home-view.js +240 -0
  56. package/dist/tui/cockpit/keys.d.ts +125 -0
  57. package/dist/tui/cockpit/keys.js +31 -0
  58. package/dist/tui/cockpit/layout.d.ts +14 -0
  59. package/dist/tui/cockpit/layout.js +15 -0
  60. package/dist/tui/cockpit/live-runtime.d.ts +46 -0
  61. package/dist/tui/cockpit/live-runtime.js +683 -0
  62. package/dist/tui/cockpit/live-store.d.ts +289 -0
  63. package/dist/tui/cockpit/live-store.js +308 -0
  64. package/dist/tui/cockpit/live.d.ts +21 -1
  65. package/dist/tui/cockpit/live.js +12 -1
  66. package/dist/tui/cockpit/run-view.d.ts +100 -0
  67. package/dist/tui/cockpit/run-view.js +202 -0
  68. package/dist/tui/cockpit/shell.d.ts +50 -0
  69. package/dist/tui/cockpit/shell.js +74 -0
  70. package/dist/tui/cockpit/theme.d.ts +27 -0
  71. package/dist/tui/cockpit/theme.js +21 -0
  72. package/package.json +1 -1
  73. package/skills/tickmarkr-auto/SKILL.md +10 -1
  74. package/skills/tickmarkr-loop/SKILL.md +72 -1
  75. package/skills/tickmarkr-overseer/SKILL.md +12 -0
@@ -134,12 +134,11 @@ export function formatSummary(s) {
134
134
  : "";
135
135
  return `done: ${s.done.length}, failed: ${s.failed.length}, human: ${s.human.length}, blocked: ${s.blocked.length}, pending: ${s.pending.length}\nintegration branch: ${s.branch}${tip}${outstanding}`;
136
136
  }
137
- /** The narrator's command, bound to THIS run. `status` takes exactly one positional and it is the
138
- * run id (cli/commands/status.ts positionalRunId), so naming it here is what stops the board from
139
- * following the newest journal in a repo that already carries a second, newer run — a board showing
140
- * the wrong run is a recorded incident (skills/tickmarkr-overseer/SKILL.md). */
137
+ /** The narrator enters the production Run cockpit for this exact run. The
138
+ * completed static/growing four-hour records precede this default cutover;
139
+ * an explicit ID prevents a newer journal from redirecting the owned board. */
141
140
  export const daemonEntrypoint = fileURLToPath(new URL("../cli/index.js", import.meta.url));
142
- export const watchCommand = (runId) => `${shq(process.execPath)} ${shq(daemonEntrypoint)} status --watch ${shq(runId)}`;
141
+ export const watchCommand = (runId) => `${shq(process.execPath)} ${shq(daemonEntrypoint)} ui ${shq(runId)} --view run`;
143
142
  const MAX_ATTEMPTS = 10; // ponytail: hard cap so a pathological ladder can never loop forever
144
143
  // v1.85 T3 (retry economics): two repairs per engagement, then the fresh ladder. A repair re-uses the
145
144
  // findings and the landed diff instead of re-buying onboarding; when two of them have not closed the
@@ -1266,15 +1265,17 @@ export async function runDaemon(repoRoot, opts = {}) {
1266
1265
  // slot closed once (task-done, quota reroute, gate self-clean) can never be closed twice.
1267
1266
  const liveSlots = new Set();
1268
1267
  const closeSlot = async (s) => {
1269
- if (!liveSlots.delete(s))
1268
+ if (!liveSlots.has(s))
1270
1269
  return; // already closed — never twice
1271
1270
  await driver.close(s);
1271
+ liveSlots.delete(s);
1272
1272
  };
1273
1273
  const trackedDriver = {
1274
1274
  id: driver.id,
1275
1275
  interactive: driver.interactive,
1276
1276
  ...(driver.readSource ? { readSource: driver.readSource } : {}),
1277
1277
  ...(driver.describe ? { describe: driver.describe.bind(driver) } : {}),
1278
+ ...(driver.focus ? { focus: driver.focus.bind(driver) } : {}),
1278
1279
  slot: async (cwd, name, o) => { const s = await driver.slot(cwd, name, o); liveSlots.add(s); return s; },
1279
1280
  run: (s, cmd) => driver.run(s, cmd),
1280
1281
  waitOutput: (s, p, ms, o) => driver.waitOutput(s, p, ms, o),
@@ -1392,7 +1393,9 @@ export async function runDaemon(repoRoot, opts = {}) {
1392
1393
  watchSlot = await trackedDriver.narrator(repoRoot, watchCommand(runId), runId);
1393
1394
  }
1394
1395
  catch (error) {
1395
- journal.append("watch-placement-failed", undefined, { error: error instanceof Error ? error.message : String(error) });
1396
+ const reason = error instanceof Error ? error.message : String(error);
1397
+ journal.append("watch-placement-failed", undefined, { error: reason });
1398
+ console.error(`tickmarkr: narrator not opened: ${reason}`);
1396
1399
  }
1397
1400
  };
1398
1401
  let baseRef;
@@ -1550,12 +1553,14 @@ export async function runDaemon(repoRoot, opts = {}) {
1550
1553
  // (no run identity) is never this run's to sweep.
1551
1554
  if (watchSlot && watchSlot.name === watchName && !desired.has(watchName)) {
1552
1555
  const w = watchSlot;
1553
- watchSlot = undefined;
1554
1556
  await trackedDriver.close(w);
1557
+ watchSlot = undefined;
1555
1558
  }
1556
1559
  }
1557
- catch {
1558
- /* cosmetic — visibility is never a gate */
1560
+ catch (error) {
1561
+ const reason = error instanceof Error ? error.message : String(error);
1562
+ journal.append("watch-cleanup-failed", undefined, { error: reason });
1563
+ console.error(`tickmarkr: ${reason}`);
1559
1564
  }
1560
1565
  };
1561
1566
  // run start/resume boundary: nothing in flight, so the sweep takes this run's judge/review/consult
@@ -2725,6 +2730,9 @@ export async function runDaemon(repoRoot, opts = {}) {
2725
2730
  journal.append("worker-launch", t.id, {
2726
2731
  attempt,
2727
2732
  retryMode,
2733
+ driver: trackedDriver.id,
2734
+ slot: { ...slot },
2735
+ workspace: driver.id === "herdr" ? process.env.HERDR_WORKSPACE_ID : undefined,
2728
2736
  ...(trackedDriver.describe ? await trackedDriver.describe(slot) : {}),
2729
2737
  });
2730
2738
  };
@@ -36,12 +36,69 @@ export declare function runLockOwner(repoRoot: string): {
36
36
  export declare function runLockRunId(repoRoot: string): string | undefined;
37
37
  export declare function runStatusLine(repoRoot: string, runId: string, events: readonly RunLineEvent[]): string | null;
38
38
  export declare function isRunLockLive(repoRoot: string): boolean;
39
- export declare function unlockRun(repoRoot: string): {
39
+ export interface LockSnapshot {
40
+ pid?: number;
41
+ runId?: string;
42
+ garbage: boolean;
43
+ unreadable: boolean;
44
+ dead: boolean;
45
+ ino: number;
46
+ mtimeMs: number;
47
+ raw: Buffer;
48
+ }
49
+ export type UnlockPreview = {
40
50
  held: false;
41
51
  } | {
42
52
  held: true;
53
+ eligible: false;
54
+ reason: string;
55
+ pid?: number;
56
+ runId?: string;
57
+ } | {
58
+ held: true;
59
+ eligible: true;
60
+ pid: number;
61
+ runId: string;
62
+ ino: number;
63
+ mtimeMs: number;
64
+ raw: Buffer;
65
+ };
66
+ export declare function previewUnlock(repoRoot: string, runId: string): UnlockPreview;
67
+ export declare function commitUnlock(repoRoot: string, target: {
68
+ pid: number;
69
+ runId: string;
70
+ ino: number;
71
+ mtimeMs: number;
72
+ raw: Buffer;
73
+ }): {
43
74
  removed: true;
75
+ pid: number;
76
+ runId: string;
77
+ } | {
78
+ removed: false;
79
+ reason: string;
80
+ };
81
+ export type UnlockGarbagePreview = {
82
+ held: false;
83
+ } | {
84
+ held: true;
85
+ eligible: false;
86
+ reason: string;
44
87
  pid?: number;
45
88
  runId?: string;
46
- garbage: boolean;
89
+ } | {
90
+ held: true;
91
+ eligible: true;
92
+ ino: number;
93
+ raw: Buffer;
94
+ };
95
+ export declare function previewGarbageUnlock(repoRoot: string): UnlockGarbagePreview;
96
+ export declare function commitGarbageUnlock(repoRoot: string, target: {
97
+ ino: number;
98
+ raw: Buffer;
99
+ }): {
100
+ removed: true;
101
+ } | {
102
+ removed: false;
103
+ reason: string;
47
104
  };
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
+ }