tickmarkr 2.6.4 → 2.6.6

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 (73) hide show
  1. package/README.md +7 -3
  2. package/dist/adapters/catalog-remote.d.ts +23 -0
  3. package/dist/adapters/catalog-remote.js +38 -0
  4. package/dist/adapters/claude-code.d.ts +1 -1
  5. package/dist/adapters/claude-code.js +4 -1
  6. package/dist/adapters/codex-commit-check.d.ts +36 -0
  7. package/dist/adapters/codex-commit-check.js +205 -0
  8. package/dist/adapters/codex.d.ts +1 -0
  9. package/dist/adapters/codex.js +12 -6
  10. package/dist/adapters/model-discovery-check.d.ts +33 -0
  11. package/dist/adapters/model-discovery-check.js +123 -0
  12. package/dist/adapters/model-lints.d.ts +1 -1
  13. package/dist/adapters/model-lints.js +4 -0
  14. package/dist/adapters/prompt.js +3 -2
  15. package/dist/adapters/types.d.ts +10 -0
  16. package/dist/cli/commands/approve.js +10 -4
  17. package/dist/cli/commands/doctor.d.ts +2 -0
  18. package/dist/cli/commands/doctor.js +17 -3
  19. package/dist/cli/commands/fleet.d.ts +10 -1
  20. package/dist/cli/commands/fleet.js +363 -106
  21. package/dist/cli/commands/init.js +10 -3
  22. package/dist/cli/commands/plan.js +76 -4
  23. package/dist/cli/commands/verify.d.ts +1 -1
  24. package/dist/cli/commands/verify.js +65 -15
  25. package/dist/config/fleet-overlay.d.ts +16 -1
  26. package/dist/config/fleet-overlay.js +230 -28
  27. package/dist/drivers/orca.d.ts +34 -2
  28. package/dist/drivers/orca.js +206 -32
  29. package/dist/gates/cache.d.ts +4 -0
  30. package/dist/gates/cache.js +10 -2
  31. package/dist/gates/llm.d.ts +4 -1
  32. package/dist/gates/llm.js +6 -1
  33. package/dist/gates/review.js +8 -3
  34. package/dist/gates/run-gates.d.ts +5 -3
  35. package/dist/gates/run-gates.js +112 -56
  36. package/dist/gates/test-manifest.d.ts +3 -0
  37. package/dist/gates/test-manifest.js +1 -1
  38. package/dist/gates/timeout-shaped.d.ts +3 -0
  39. package/dist/gates/timeout-shaped.js +10 -0
  40. package/dist/run/daemon.js +326 -130
  41. package/dist/run/git.js +9 -0
  42. package/dist/run/journal.d.ts +6 -2
  43. package/dist/run/journal.js +22 -7
  44. package/dist/run/launchservices-check.d.ts +56 -0
  45. package/dist/run/launchservices-check.js +117 -0
  46. package/dist/run/operator-state.d.ts +22 -2
  47. package/dist/run/operator-state.js +20 -3
  48. package/dist/run/title-environment.d.ts +45 -0
  49. package/dist/run/title-environment.js +127 -0
  50. package/dist/run/title-preload.cjs +38 -0
  51. package/dist/run/title-preload.d.cts +1 -0
  52. package/dist/tui/cockpit/board.js +12 -6
  53. package/dist/tui/cockpit/keys.d.ts +8 -0
  54. package/dist/tui/cockpit/keys.js +4 -1
  55. package/dist/tui/cockpit/layout.d.ts +2 -2
  56. package/dist/tui/cockpit/layout.js +2 -2
  57. package/dist/tui/cockpit/live-runtime.js +46 -20
  58. package/dist/tui/cockpit/live-store.d.ts +24 -1
  59. package/dist/tui/cockpit/live-store.js +99 -8
  60. package/dist/tui/cockpit/log-view.d.ts +28 -0
  61. package/dist/tui/cockpit/log-view.js +60 -0
  62. package/dist/tui/cockpit/run-view.js +4 -0
  63. package/dist/tui/cockpit/shell.d.ts +2 -1
  64. package/dist/tui/cockpit/shell.js +6 -4
  65. package/dist/tui/ink/fleet-app.d.ts +37 -10
  66. package/dist/tui/ink/fleet-app.js +65 -21
  67. package/package.json +1 -1
  68. package/skills/tickmarkr-auto/SKILL.md +32 -2
  69. package/skills/tickmarkr-loop/SKILL.md +46 -2
  70. package/skills/tickmarkr-overseer/SKILL.md +83 -8
  71. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +58 -16
  72. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +19 -6
  73. package/skills/tickmarkr-overseer/scripts/watch-parks.sh +121 -64
@@ -136,13 +136,16 @@ export function boardFrame(input, width) {
136
136
  const unknown = buckets.filter(([, v]) => v === undefined).map(([k]) => k);
137
137
  const parked = buckets.filter(([, v]) => v !== undefined && v.length > 0).map(([k, v]) => `${k} ${v.join(",")}`);
138
138
  const tally = `${doneIds.size} merged · ${(s.buckets.human ?? []).length} parked · ${(s.buckets.blocked ?? []).length} dep-blocked`;
139
- const green = !unknown.length && !parked.length && (s.currentTip === "passed" || s.currentTip === "not required");
139
+ // I2: the same CURRENT owed-check authority as the lifecycle — debt must be known and empty.
140
+ const owed = s.debt === 0 ? undefined : s.debt === "unknown" ? "outstanding unknown" : `outstanding ${s.debt} (${s.outstanding.join(", ")})`;
141
+ const tipOk = s.currentTip === "passed" || s.currentTip === "not required";
142
+ const green = !unknown.length && !parked.length && tipOk && !owed;
140
143
  const now = green
141
144
  ? `${pass("RUN ENDED — GREEN")} ${mute(`tip ${s.currentTip} · ${tally}`)}`
142
145
  : s.currentTip === "failed"
143
146
  ? `${fail("RUN ENDED — TIP VERIFY FAILED")} ${mute(tally)}`
144
- : parked.length || unknown.length
145
- ? `${fail("RUN ENDED — NOT GREEN")} ${mute(`${[...parked, ...unknown.map((k) => `${k} bucket unknown`)].join(" · ")} · ${tally}`)}`
147
+ : parked.length || unknown.length || tipOk
148
+ ? `${fail("RUN ENDED — NOT GREEN")} ${mute(`${[...parked, ...unknown.map((k) => `${k} bucket unknown`), ...(owed ? [owed] : [])].join(" · ")} · ${tally}`)}`
146
149
  : `${warn("RUN ENDED — TIP NOT VERIFIED")} ${mute(`tip ${s.currentTip} · ${tally}`)}`;
147
150
  top.push(` ${accent("▌")} ${bold("NOW")} ${now}`);
148
151
  top.push("");
@@ -152,8 +155,11 @@ export function boardFrame(input, width) {
152
155
  top.push("");
153
156
  // ── aligned gate header: each label truncated to the cell it owns (prototype :382-394) ──
154
157
  const elastic = Math.max(30, W - BOARD_FIXED - BOARD_NOTE_RESERVE);
155
- const LBL = Math.min(46, Math.max(18, Math.round(elastic * 0.62)));
156
- const CHAN = Math.min(26, Math.max(12, elastic - LBL));
158
+ const compactLabel = Math.min(46, Math.max(18, Math.round(elastic * 0.62)));
159
+ const stacked = boardBand(W) === "stacked";
160
+ const LBL = stacked ? Math.max(0, W - 4 - 5 - BOARD_AREA_W - BOARD_DEPS_W)
161
+ : compactLabel;
162
+ const CHAN = Math.min(26, Math.max(12, elastic - compactLabel));
157
163
  const gateHead = BOARD_GATES.map((g) => faint(pad(g.slice(0, BOARD_CELL - 1), BOARD_CELL))).join("");
158
164
  const gateHeader = {
159
165
  head: ` ${pad("", 5)}${pad(faint("area"), BOARD_AREA_W)}${pad(faint("deps"), BOARD_DEPS_W)}${pad(faint("task"), LBL)}`,
@@ -200,7 +206,7 @@ export function boardFrame(input, width) {
200
206
  ? depText
201
207
  : depIds.length > 1 ? `${depIds[0]}+${depIds.length - 1}` : `${depIds[0].slice(0, BOARD_DEPS_W - 3)}…`;
202
208
  const deps = pad(faint(depLabel), BOARD_DEPS_W);
203
- const titleText = sliceCells(g.title ?? "", LBL - 2).head;
209
+ const titleText = sliceCells(g.title ?? "", stacked ? LBL : LBL - 2).head;
204
210
  const title = hot ? body(pad(titleText, LBL)) : faint(pad(titleText, LBL));
205
211
  const note = [
206
212
  parks >= 3 ? park(`◍ parked ×${parks}`) : "",
@@ -259,6 +259,10 @@ export declare const SHELL_BINDINGS: readonly [{
259
259
  readonly key: "5";
260
260
  readonly label: "Evidence";
261
261
  readonly action: "evidence";
262
+ }, {
263
+ readonly key: "7";
264
+ readonly label: "Log";
265
+ readonly action: "log";
262
266
  }, {
263
267
  readonly key: "Tab";
264
268
  readonly label: "Focus";
@@ -322,6 +326,10 @@ export declare function shellBindings(context: {
322
326
  readonly key: "5";
323
327
  readonly label: "Evidence";
324
328
  readonly action: "evidence";
329
+ } | {
330
+ readonly key: "7";
331
+ readonly label: "Log";
332
+ readonly action: "log";
325
333
  } | {
326
334
  readonly key: "Tab";
327
335
  readonly label: "Focus";
@@ -637,6 +637,7 @@ export const SHELL_BINDINGS = [
637
637
  { key: "1", label: "Home", action: "home" },
638
638
  { key: "4", label: "Run", action: "run" },
639
639
  { key: "5", label: "Evidence", action: "evidence" },
640
+ { key: "7", label: "Log", action: "log" },
640
641
  { key: "Tab", label: "Focus", action: "focus" },
641
642
  { key: "Enter", label: "Open", action: "open" },
642
643
  { key: "a", label: "Actions", action: "actions" },
@@ -658,7 +659,9 @@ export function shellBindings(context) {
658
659
  return context.canResize === true;
659
660
  if (binding.action === "export")
660
661
  return context.view === "evidence";
661
- if (binding.action === "filter" || binding.action === "follow")
662
+ if (binding.action === "follow")
663
+ return context.view === "log" || context.view === "evidence" && !context.evidenceSection;
664
+ if (binding.action === "filter")
662
665
  return context.view === "evidence" && !context.evidenceSection;
663
666
  return true;
664
667
  });
@@ -306,8 +306,8 @@ export declare function planFrame(size: MeasuredSize, view?: FrameView, state?:
306
306
  export declare function resolveCockpitLayout(columns: number, rows: number): CockpitLayout;
307
307
  /**
308
308
  * FINAL §3.1: one geometry for paint, focus and pointer hit testing. A ZERO shortcut budget is the
309
- * rail-less mount (BD-1, RULING-231-19 §2): the daemon-placed board is a one-view observer, so it
310
- * plans no rail and no shortcuts at any width — the budget is honoured, never floored back to 20.
309
+ * explicit compact mount: it plans no rail and no shortcuts at any width. Daemon-owned and manual
310
+ * boards both use the default budget; an explicit zero is honoured, never floored back to 20.
311
311
  */
312
312
  export declare function planShell(columns: number, rows: number, shortcutColumns?: number): {
313
313
  columns: number;
@@ -391,8 +391,8 @@ export function resolveCockpitLayout(columns, rows) {
391
391
  }
392
392
  /**
393
393
  * FINAL §3.1: one geometry for paint, focus and pointer hit testing. A ZERO shortcut budget is the
394
- * rail-less mount (BD-1, RULING-231-19 §2): the daemon-placed board is a one-view observer, so it
395
- * plans no rail and no shortcuts at any width — the budget is honoured, never floored back to 20.
394
+ * explicit compact mount: it plans no rail and no shortcuts at any width. Daemon-owned and manual
395
+ * boards both use the default budget; an explicit zero is honoured, never floored back to 20.
396
396
  */
397
397
  export function planShell(columns, rows, shortcutColumns = 22) {
398
398
  columns = Math.max(0, Math.floor(columns));
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { existsSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { existsSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { renderMarkdownRecord } from "../../cli/commands/report.js";
5
5
  import { loadConfig } from "../../config/config.js";
@@ -7,13 +7,14 @@ import { estimateCosts } from "../../report/cost.js";
7
7
  import { PerformanceObserver, performance } from "node:perf_hooks";
8
8
  import { render, useInput, useStdin } from "ink";
9
9
  import { useLayoutEffect, useSyncExternalStore } from "react";
10
- import { createLiveStore } from "./live-store.js";
10
+ import { createLiveStore, journalBasis, readObservedJournal } from "./live-store.js";
11
11
  import { HomeView, deriveHomeView, selectNeedsYouTarget } from "./home-view.js";
12
12
  import { RunView, applyRunViewKey, initialRunViewSession, runGateCells, evidenceLookup } from "./run-view.js";
13
13
  import { renderBoardLines, stripBoardAnsi } from "./board.js";
14
14
  import { EvidenceView, deriveEvidenceView, defaultExportPath, planEvidenceExport, writeEvidenceExport } from "./evidence-view.js";
15
15
  import { ShellGridFlow, ShellJournalSelection } from "./components.js";
16
16
  import { CockpitShell, initialShellState } from "./shell.js";
17
+ import { LogView, readLogPage, moveLogEnd } from "./log-view.js";
17
18
  import { planShell } from "./layout.js";
18
19
  import { shellBindings } from "./keys.js";
19
20
  import { createPointerReportReader, POINTER_TRACKING_ON, POINTER_TRACKING_OFF } from "./pointer.js";
@@ -21,7 +22,7 @@ import { deriveRunDecisions, previewDecision, executeDecision, withDecisionPrevi
21
22
  import { approvalRunOwner } from "../../cli/commands/approve.js";
22
23
  import { GLYPHS } from "../../brand.js";
23
24
  import { effectiveEvents, Journal, parseJournalText, physicalLine } from "../../run/journal.js";
24
- import { observeNamedRun, WATCH_OWNER_ENV } from "../../run/supervision.js";
25
+ import { observeNamedRun } from "../../run/supervision.js";
25
26
  import { formatOwnedName } from "../../drivers/types.js";
26
27
  import { readOperatorState } from "../../run/operator-state.js";
27
28
  import { cellWidth, wrapCells } from "./width.js";
@@ -59,6 +60,10 @@ export function borrowRuntimeTimeline() {
59
60
  }
60
61
  export const SHELL_TERMINAL_ENTER = "\x1b[?1049h\x1b[22;0t\x1b]0;tickmarkr cockpit\x07" + POINTER_TRACKING_ON;
61
62
  export const SHELL_TERMINAL_RESTORE = POINTER_TRACKING_OFF + "\x1b[23;0t\x1b[?25h\x1b[?1049l";
63
+ /** Rows not bound to the store's observation: only the store's own observation is served, with debt unknown
64
+ * (the fold's formula then gives PARTIAL for a headline that was green — green implies ended and unapproved). */
65
+ const unbound = (snap) => ({ ...snap, owed: undefined, operator: { ...snap.operator, debt: "unknown", outstanding: [],
66
+ green: false, ...(snap.operator.green ? { lifecycle: "PARTIAL", label: "PARTIAL" } : {}) } });
62
67
  /**
63
68
  * OBS-1178: the store's incremental fold reads every task-approved row as a release. A refused or unsound
64
69
  * decision released nothing, so whenever the rows the store has read hold one — whatever state the raw
@@ -70,36 +75,48 @@ export const SHELL_TERMINAL_RESTORE = POINTER_TRACKING_OFF + "\x1b[23;0t\x1b[?25
70
75
  */
71
76
  export function decidedLiveStore(store) {
72
77
  let key;
73
- let operator;
78
+ let events;
79
+ // I2: the refold is keyed by the store's owed authority too, so a debt the store re-folded (an artifact
80
+ // removed or altered under unchanged journal bytes) is never served from an earlier observation's refold.
81
+ let folded;
74
82
  const decided = (snap) => {
75
83
  const { journal, graph } = snap;
76
- const observed = `${journal.source}:${journal.generation}:${journal.offset}:${graph.identity}:${graph.status}`;
84
+ const observed = `${journalBasis(journal)}:${graph.identity}:${graph.status}:${snap.lock.state}`;
77
85
  if (observed !== key) {
78
86
  key = observed;
79
- operator = undefined;
87
+ events = undefined;
88
+ folded = undefined;
80
89
  let bytes;
90
+ // I2 (D-857): the refold reads the very journal version the store observed — the one its owed authority
91
+ // and basis name. A changed or failed read is never cached (un-keyed, re-read next observation) and
92
+ // decides nothing meanwhile: debt unknown, never green.
81
93
  try {
82
- bytes = readFileSync(journal.source).subarray(0, journal.offset);
94
+ bytes = readObservedJournal(journal);
83
95
  }
84
96
  catch {
85
- return snap;
97
+ key = undefined;
98
+ return unbound(snap);
86
99
  }
87
100
  if (bytes.includes("task-approved")) {
88
101
  const rows = parseJournalText(bytes.subarray(0, bytes.lastIndexOf(10) + 1).toString("utf8"))
89
102
  .filter(e => e !== null && typeof e === "object" && typeof e.event === "string" && typeof e.data === "object" && e.data !== null);
90
103
  const kept = effectiveEvents(rows);
91
104
  if (kept.length !== rows.length) {
92
- const events = kept.map((event, i) => {
105
+ events = kept.map((event, i) => {
93
106
  const line = physicalLine(kept, i);
94
107
  return { source: journal.source, line, id: `${journal.source}#L${line}`, generation: journal.generation, event };
95
108
  });
96
- operator = readOperatorState({ events, graph: graph.status === "readable" ? graph.value : undefined,
97
- readable: journal.status === "readable" && journal.backlogBytes === 0 });
98
109
  }
99
110
  }
100
111
  }
101
- if (!operator)
112
+ if (!events)
102
113
  return snap;
114
+ if (!folded || folded.owed !== snap.owed) {
115
+ folded = { owed: snap.owed, operator: readOperatorState({ events, graph: graph.status === "readable" ? graph.value : undefined,
116
+ readable: journal.status === "readable" && journal.backlogBytes === 0, daemonDead: snap.lock.state === "dead",
117
+ owed: snap.owed, basis: journalBasis(journal) }) };
118
+ }
119
+ const operator = folded.operator;
103
120
  const { sequence, observedAt, graphAvailability } = snap.operator;
104
121
  return { ...snap, operator: { ...operator, sequence, observedAt, graphAvailability } };
105
122
  };
@@ -147,10 +164,8 @@ export async function runConsolidatedCockpit(options) {
147
164
  const source = decidedLiveStore(store);
148
165
  if (options.observeRun !== false)
149
166
  observation = observeNamedRun(cwd, runId, options.environment);
150
- // BD-1 (RULING-231-19 §2): the daemon-placed board carries the watch owner token and mounts
151
- // rail-less — a zero shortcut budget the plan honours at every width. The manual cockpit keeps its rail.
152
- const railless = Boolean((options.environment ?? process.env)[WATCH_OWNER_ENV]);
153
- let state = { ...initialShellState(), view: options.initialView ?? "home", ...(railless ? { shortcutColumns: 0 } : {}) };
167
+ // Daemon-placed and manual boards share the same rails and navigation.
168
+ let state = { ...initialShellState(), view: options.initialView ?? "home" };
154
169
  let runSession = initialRunViewSession();
155
170
  if (options.initialParks) {
156
171
  runSession = { ...runSession, selection: Math.max(0, source.snapshot().operator.tasks.findIndex(t => t.state === "human" || t.state === "blocked")) };
@@ -202,6 +217,9 @@ export async function runConsolidatedCockpit(options) {
202
217
  snap.journal.generation, snap.journal.offset, snap.journal.status, snap.journal.malformedCount, snap.journal.backlogBytes, snap.journal.pending, snap.journal.error,
203
218
  snap.graph.identity, snap.graph.status, snap.config.identity, snap.config.status, snap.cache.identity, snap.cache.status,
204
219
  snap.lock.value, snap.lock.status, snap.lock.state, snap.lock.alive,
220
+ // I2: owed-check debt moves under unchanged journal bytes (an artifact removed or altered) and
221
+ // decides the drawn lifecycle and board headline.
222
+ snap.operator.lifecycle, snap.operator.debt, snap.operator.outstanding,
205
223
  // D-383: supervision tier state is not drawn by any view; an ARMED→STALE transition earns no
206
224
  // frame. Its UNREADABLE case already surfaces through snap.errors above.
207
225
  ]);
@@ -418,7 +436,7 @@ export async function runConsolidatedCockpit(options) {
418
436
  }
419
437
  else {
420
438
  const action = shellBindings(state).find(b => b.key === text)?.action;
421
- if (action === "home" || action === "run" || action === "evidence")
439
+ if (action === "home" || action === "run" || action === "evidence" || action === "log")
422
440
  state = { ...state, view: action, scroll: 0, evidenceSection: action === state.view ? state.evidenceSection : 0 };
423
441
  else if (action === "widen" || action === "narrow")
424
442
  state = { ...state, shortcutColumns: planShell(geometry().columns, geometry().rows, geometry().shortcuts + (action === "widen" ? 1 : -1)).shortcuts };
@@ -432,6 +450,13 @@ export async function runConsolidatedCockpit(options) {
432
450
  }
433
451
  else if (state.view === "evidence" && (k.leftArrow || k.rightArrow))
434
452
  state = { ...state, evidenceSection: ((state.evidenceSection ?? 0) + (k.rightArrow ? 1 : 4)) % 5 };
453
+ else if (state.view === "log" && (k.pageDown || k.pageUp || k.upArrow || k.downArrow || action === "follow")) {
454
+ const snap = source.snapshot();
455
+ const sourceLog = { journal: snap.journal, page: source.page, runId, columns: geometry().bodyColumns };
456
+ const delta = (k.pageDown || k.downArrow ? 1 : -1) * (k.pageDown || k.pageUp ? geometry().bodyRows : 1);
457
+ state = { ...state, logEnd: action === "follow" ? state.logEnd === undefined ? snap.journal.lines : undefined
458
+ : moveLogEnd(sourceLog, state.logEnd, delta), scroll: 0 };
459
+ }
435
460
  else if ((k.pageDown || k.pageUp) && state.view !== "run")
436
461
  state = { ...state, scroll: Math.max(0, state.scroll + (k.pageDown ? 1 : -1) * geometry().bodyRows) };
437
462
  else if (k.return || action === "open") {
@@ -471,7 +496,7 @@ export async function runConsolidatedCockpit(options) {
471
496
  else if (text === "a")
472
497
  state = { ...state, overlay: ["Existing CLI actions", "tickmarkr fleet", "tickmarkr plan", "tickmarkr doctor"], overlayOffset: 0 };
473
498
  }
474
- if (!terminal && state.view !== "run" && !state.overlay && !state.help && state.editor === undefined) {
499
+ if (!terminal && state.view !== "run" && state.view !== "log" && !state.overlay && !state.help && state.editor === undefined) {
475
500
  const bytes = k.upArrow ? "\x1b[A" : k.downArrow ? "\x1b[B" : k.rightArrow ? "\x1b[C" : k.leftArrow ? "\x1b[D" : k.pageUp ? "\x1b[5~" : k.pageDown ? "\x1b[6~" : text === "f" ? "f" : undefined;
476
501
  if (bytes)
477
502
  forwardLeaf(bytes);
@@ -700,7 +725,7 @@ export async function runConsolidatedCockpit(options) {
700
725
  const usedTasks = new Set();
701
726
  for (const row of plan.paintedRows) {
702
727
  if (row.row < plan.bodyRow && (row.text.startsWith("1 Home") || row.text.startsWith("1H"))) {
703
- for (const binding of bindings.filter(binding => ["home", "run", "evidence"].includes(binding.action))) {
728
+ for (const binding of bindings.filter(binding => ["home", "run", "evidence", "log"].includes(binding.action))) {
704
729
  const caption = plan.columns < 60 ? `${binding.key}${binding.label[0]}` : `${binding.key} ${binding.label}`;
705
730
  const index = row.text.indexOf(caption);
706
731
  if (index >= 0)
@@ -758,7 +783,8 @@ export async function runConsolidatedCockpit(options) {
758
783
  committedTargets = targets;
759
784
  }, children: state.view === "home" ? _jsx(ShellGridFlow, { width: p.bodyColumns, columns: p.columns < 120 ? 2 : 3, rowHeight: p.rows < 20 ? 1 : p.columns < 120 ? 2 : 3, children: _jsx(HomeView, { width: p.bodyColumns, model: homeModel, focused: focused, onOpenPark: id => { runSession = { ...runSession, selection: Math.max(0, snap.operator.tasks.findIndex(t => t.id === id)) }; state = { ...state, view: "run", scroll: 0 }; followSelection(); publish(); }, onOpenDiagnostic: id => { state = { ...state, overlay: [id], overlayOffset: 0 }; publish(); }, onOpenEvidence: openEvidence, onSelectNeedsYou: reportHomeNeedsYouSelection }, String(homeModel.needsYou.length > 0)) }) :
760
785
  state.view === "run" ? _jsx(RunView, { snapshot: snap.operator, rows: snap.journal.history, page: source.page, graph: snap.graph.value, decisions: visibleDecisions, session: runSession, columns: p.bodyColumns, run: approvalRunOwner(cwd, runId), now: options.now }) :
761
- _jsx(ShellJournalSelection.Provider, { value: reportEvidenceSelection, children: _jsx(EvidenceView, { width: p.bodyColumns, model: { ...model, journal: evidenceRows }, focusEvidence: selectedEvidence, focused: focused, onExport: beginExport, onSelect: id => { selectedEvidence = id; openDetail(); publish(); } }, `${state.query}:${evidenceNavigation}`) }) });
786
+ state.view === "log" ? _jsx(LogView, { page: readLogPage({ journal: snap.journal, page: source.page, runId, columns: p.bodyColumns }, p.bodyRows, state.logEnd) }) :
787
+ _jsx(ShellJournalSelection.Provider, { value: reportEvidenceSelection, children: _jsx(EvidenceView, { width: p.bodyColumns, model: { ...model, journal: evidenceRows }, focusEvidence: selectedEvidence, focused: focused, onExport: beginExport, onSelect: id => { selectedEvidence = id; openDetail(); publish(); } }, `${state.query}:${evidenceNavigation}`) }) });
762
788
  }
763
789
  app = render(_jsx(App, {}), { stdin, stdout: output, debug: options.debug, exitOnCtrlC: false, patchConsole: false });
764
790
  void app.waitUntilExit().then(() => stop(), stop);
@@ -1,5 +1,5 @@
1
1
  import { type JournalEvent } from "../../run/journal.js";
2
- import { type OperatorRecord } from "../../run/operator-state.js";
2
+ import { type OperatorRecord, type OwedAuthority } from "../../run/operator-state.js";
3
3
  export declare const OBSERVATION_INTERVAL_MS = 1000;
4
4
  /** Stat-to-fstat observations one poll spends on a journal a writer keeps appending to. */
5
5
  export declare const APPEND_RACE_OBSERVATIONS = 3;
@@ -27,9 +27,12 @@ export interface JournalLine extends Omit<OperatorRecord, "event"> {
27
27
  endOffset: number;
28
28
  }
29
29
  export interface TailSnapshot {
30
+ /** `digest` (I2): sha256 of every byte the tail consumed, [0, offset) — the bytes its fold was built from. */
30
31
  source: string;
31
32
  generation: number;
32
33
  identity?: string;
34
+ version?: string;
35
+ digest: string;
33
36
  offset: number;
34
37
  lines: number;
35
38
  history: readonly JournalLine[];
@@ -64,11 +67,17 @@ export declare class JournalTail {
64
67
  private lastSuccessfulReadAt?;
65
68
  private failure?;
66
69
  private stale;
70
+ private hash;
71
+ private invalid;
67
72
  constructor(source: string, hooks?: {
68
73
  record?: (record: OperatorRecord) => void;
69
74
  reset?: () => void;
70
75
  });
71
76
  private reset;
77
+ /** I2: the consumed prefix was found rewritten (an in-place rewrite then growth reads as an append), so the
78
+ * next poll treats the file as replaced and refolds it from its first byte. */
79
+ invalidate(): void;
80
+ get invalidated(): boolean;
72
81
  /** A same-inode append between stat and fstat is retried; exhaustion keeps the last good snapshot, pending. */
73
82
  poll(now?: number): TailSnapshot;
74
83
  /** One stat-to-fstat observation. False when an append raced it; nothing was consumed or reset. */
@@ -86,6 +95,18 @@ export interface CachedSource<T = unknown> {
86
95
  error?: string;
87
96
  observedAt: number;
88
97
  }
98
+ /** I2: the journal basis an owed-check fold read — the observed file version, its generation and the bytes consumed. */
99
+ export declare const journalBasis: (journal: TailSnapshot) => string;
100
+ /** I2: the bytes the tail consumed are no longer the file's prefix — rewritten in place, then grown. */
101
+ export declare class JournalRewritten extends Error {
102
+ constructor();
103
+ }
104
+ /** I2 (D-857): the ONE way any reader beside the tail gets `journal`'s bytes — every consumed byte of the very
105
+ * file version the tail observed (an in-place rewrite, even an equal-length one, changes it), stamped before
106
+ * and after the read, and proved by digest to be the very bytes the tail's fold consumed (a rewrite followed by
107
+ * growth keeps the tail appending). Any change throws, so no second read is ever combined with this
108
+ * observation's lifecycle, owed or basis. */
109
+ export declare function readObservedJournal(journal: TailSnapshot): Buffer;
89
110
  export interface LiveStoreOptions {
90
111
  cwd: string;
91
112
  runId: string;
@@ -102,6 +123,7 @@ export declare function createLiveStore(options: LiveStoreOptions): {
102
123
  delayed: boolean;
103
124
  freshness: string;
104
125
  operator: import("../../run/operator-state.js").OperatorSnapshot;
126
+ owed: OwedAuthority | undefined;
105
127
  journal: TailSnapshot;
106
128
  graph: CachedSource<{
107
129
  version: 1;
@@ -204,6 +226,7 @@ export declare function createLiveStore(options: LiveStoreOptions): {
204
226
  delayed: boolean;
205
227
  freshness: string;
206
228
  operator: import("../../run/operator-state.js").OperatorSnapshot;
229
+ owed: OwedAuthority | undefined;
207
230
  journal: TailSnapshot;
208
231
  graph: CachedSource<{
209
232
  version: 1;
@@ -1,8 +1,9 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { closeSync, fstatSync, openSync, readSync, statSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { graphPath, stateDirName } from "../../graph/graph.js";
4
5
  import { validateGraph } from "../../graph/schema.js";
5
- import { parseRunId } from "../../run/journal.js";
6
+ import { foldOwedChecks, parseJournalText, parseRunId } from "../../run/journal.js";
6
7
  import { isPidLive, STALE_MS } from "../../run/lock.js";
7
8
  import { readTierLiveness, SUPERVISION_TIERS } from "../../run/supervision.js";
8
9
  import { OperatorStateFold } from "../../run/operator-state.js";
@@ -14,6 +15,12 @@ export const STORE_LIMITS = { graphBytes: 16 * 1024 * 1024, history: 256, histor
14
15
  const errorText = (e) => e instanceof Error ? e.message : String(e);
15
16
  const fileIdentity = (st) => `${st.dev}:${st.ino}`;
16
17
  const stamp = (st) => `${fileIdentity(st)}:${st.size}:${st.mtimeNs}:${st.ctimeNs}`;
18
+ /** The one record shape the tail accepts; the complete-journal (owed) read validates against it too. */
19
+ const isJournalRecord = (e) => {
20
+ const r = e;
21
+ return !!r && typeof r === "object" && typeof r.ts === "string" && Number.isFinite(Date.parse(r.ts)) && typeof r.event === "string"
22
+ && !!r.data && typeof r.data === "object" && !Array.isArray(r.data) && (r.taskId === undefined || typeof r.taskId === "string");
23
+ };
17
24
  function decodeLine(bytes, source, line, offset, endOffset, generation, oversized = false) {
18
25
  const base = { source, line, id: `${source}#L${line}`, offset, endOffset, generation };
19
26
  let raw = "";
@@ -24,7 +31,7 @@ function decodeLine(bytes, source, line, offset, endOffset, generation, oversize
24
31
  if (!raw.trim())
25
32
  return { ...base, raw };
26
33
  const e = JSON.parse(raw);
27
- if (!e || typeof e !== "object" || typeof e.ts !== "string" || !Number.isFinite(Date.parse(e.ts)) || typeof e.event !== "string" || !e.data || typeof e.data !== "object" || Array.isArray(e.data) || (e.taskId !== undefined && typeof e.taskId !== "string"))
34
+ if (!isJournalRecord(e))
28
35
  throw new Error("invalid journal record");
29
36
  return { ...base, raw, event: e };
30
37
  }
@@ -51,6 +58,8 @@ export class JournalTail {
51
58
  lastSuccessfulReadAt;
52
59
  failure;
53
60
  stale = false;
61
+ hash = createHash("sha256");
62
+ invalid = false;
54
63
  constructor(source, hooks = {}) {
55
64
  this.source = source;
56
65
  this.hooks = hooks;
@@ -66,8 +75,14 @@ export class JournalTail {
66
75
  this.errors = [];
67
76
  this.malformedCount = 0;
68
77
  this.generation++;
78
+ this.hash = createHash("sha256");
79
+ this.invalid = false;
69
80
  this.hooks.reset?.();
70
81
  }
82
+ /** I2: the consumed prefix was found rewritten (an in-place rewrite then growth reads as an append), so the
83
+ * next poll treats the file as replaced and refolds it from its first byte. */
84
+ invalidate() { this.invalid = true; }
85
+ get invalidated() { return this.invalid; }
71
86
  /** A same-inode append between stat and fstat is retried; exhaustion keeps the last good snapshot, pending. */
72
87
  poll(now = Date.now()) {
73
88
  try {
@@ -94,7 +109,7 @@ export class JournalTail {
94
109
  const st = statSync(this.source, { bigint: true });
95
110
  if (!st.isFile())
96
111
  throw new Error("journal source is not a regular file");
97
- const replaced = !this.st || fileIdentity(st) !== fileIdentity(this.st) || st.size < this.st.size || (st.size === this.st.size && stamp(st) !== stamp(this.st));
112
+ const replaced = this.invalid || !this.st || fileIdentity(st) !== fileIdentity(this.st) || st.size < this.st.size || (st.size === this.st.size && stamp(st) !== stamp(this.st));
98
113
  if (Number(st.size) > (replaced ? 0 : this.offset)) {
99
114
  fd = openSync(this.source, "r");
100
115
  const opened = fstatSync(fd, { bigint: true });
@@ -115,6 +130,7 @@ export class JournalTail {
115
130
  throw new Error("journal truncated during read");
116
131
  this.bytesRead += count;
117
132
  remaining -= count;
133
+ this.hash.update(buffer.subarray(0, count));
118
134
  let begin = 0;
119
135
  for (let index = 0; index < count; index++)
120
136
  if (buffer[index] === 10) {
@@ -158,7 +174,7 @@ export class JournalTail {
158
174
  }
159
175
  snapshot() {
160
176
  return {
161
- source: this.source, generation: this.generation, identity: this.st && fileIdentity(this.st), offset: this.offset, lines: this.line,
177
+ source: this.source, generation: this.generation, identity: this.st && fileIdentity(this.st), version: this.st && stamp(this.st), digest: this.hash.copy().digest("hex"), offset: this.offset, lines: this.line,
162
178
  history: this.history.slice(), errors: this.errors.slice(), malformedCount: this.malformedCount,
163
179
  pending: this.carryBytes ? { line: this.line + 1, bytes: this.carryBytes } : undefined,
164
180
  backlogBytes: Math.max(0, Number(this.st?.size ?? 0) - this.offset),
@@ -262,6 +278,48 @@ class JsonSource {
262
278
  }
263
279
  }
264
280
  }
281
+ /** I2: the journal basis an owed-check fold read — the observed file version, its generation and the bytes consumed. */
282
+ export const journalBasis = (journal) => `${journal.source}:${journal.generation}:${journal.version}:${journal.offset}`;
283
+ const unknownOwed = (reason) => ({ known: false, debt: "unknown", outstanding: [], acceptedRisk: [], discharged: [], unknown: [{ reason }] });
284
+ /** I2: the bytes the tail consumed are no longer the file's prefix — rewritten in place, then grown. */
285
+ export class JournalRewritten extends Error {
286
+ constructor() { super("journal prefix rewritten since the tail consumed it"); }
287
+ }
288
+ /** I2 (D-857): the ONE way any reader beside the tail gets `journal`'s bytes — every consumed byte of the very
289
+ * file version the tail observed (an in-place rewrite, even an equal-length one, changes it), stamped before
290
+ * and after the read, and proved by digest to be the very bytes the tail's fold consumed (a rewrite followed by
291
+ * growth keeps the tail appending). Any change throws, so no second read is ever combined with this
292
+ * observation's lifecycle, owed or basis. */
293
+ export function readObservedJournal(journal) {
294
+ const fd = openSync(journal.source, "r");
295
+ try {
296
+ const observed = () => { if (stamp(fstatSync(fd, { bigint: true })) !== journal.version)
297
+ throw new Error("journal changed since its observation"); };
298
+ observed();
299
+ const bytes = Buffer.alloc(journal.offset);
300
+ for (let length = 0; length < bytes.length;) {
301
+ const n = readSync(fd, bytes, length, bytes.length - length, length);
302
+ if (!n)
303
+ throw new Error("journal truncated since its observation");
304
+ length += n;
305
+ }
306
+ observed();
307
+ if (createHash("sha256").update(bytes).digest("hex") !== journal.digest)
308
+ throw new JournalRewritten();
309
+ return bytes;
310
+ }
311
+ finally {
312
+ closeSync(fd);
313
+ }
314
+ }
315
+ /** The complete journal behind `journal`'s basis: its observed bytes, every non-blank line a valid record. */
316
+ function readLedger(journal) {
317
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(readObservedJournal(journal));
318
+ const events = parseJournalText(text);
319
+ if (events.length !== text.split("\n").filter(line => line.trim()).length || !events.every(isJournalRecord))
320
+ throw new Error("complete journal holds an invalid line");
321
+ return events;
322
+ }
265
323
  /** C1 mount/C6 reader boundary. No timers or presence writes: the mount owns scheduling and disarm. */
266
324
  export function createLiveStore(options) {
267
325
  const now = options.now ?? Date.now;
@@ -285,10 +343,41 @@ export function createLiveStore(options) {
285
343
  let disposed = false;
286
344
  let queued;
287
345
  const metrics = [];
346
+ // I2: the complete journal is re-read only when its basis moves; its owed fold re-reads every discharge
347
+ // artifact on every observation, and only the immutable git re-proof is reused through this store's memo.
348
+ // ponytail: an ended run's whole parsed journal stays resident; fold a debt-only projection if that measures large.
349
+ const owedMemo = new Set();
350
+ let ledger;
351
+ const owedAuthority = (journal) => {
352
+ const basis = journalBasis(journal);
353
+ if (ledger?.basis !== basis) {
354
+ // Never cache a failure: only a successful read is reused; a failed one is unknown debt now and re-read next observation.
355
+ try {
356
+ ledger = { basis, events: readLedger(journal) };
357
+ }
358
+ catch (e) {
359
+ ledger = undefined;
360
+ if (e instanceof JournalRewritten)
361
+ tail.invalidate();
362
+ return { basis, fold: unknownOwed(`complete journal unreadable: ${errorText(e)}`) };
363
+ }
364
+ }
365
+ return { basis, fold: foldOwedChecks(ledger.events, options.cwd, owedMemo) };
366
+ };
288
367
  function observe() {
289
368
  const observedAt = now();
290
369
  const delayed = lastObservation !== undefined && observedAt - lastObservation > 2 * OBSERVATION_INTERVAL_MS;
291
- const journal = tail.poll(observedAt);
370
+ const isReadable = (j) => j.status === "readable" && j.backlogBytes === 0;
371
+ // Debt decides only an ended run's headline; a running or unreadable journal never pays for the fold.
372
+ const owedOf = (j) => isReadable(j) && fold.ended ? owedAuthority(j) : undefined;
373
+ let journal = tail.poll(observedAt);
374
+ let owed = owedOf(journal);
375
+ // I2: the owed read found the tail's consumed prefix rewritten, so its fold (lifecycle) is stale. Refold the
376
+ // whole file now: lifecycle and debt then come from the same bytes. A repeat rewrite is unknown debt.
377
+ if (tail.invalidated) {
378
+ journal = tail.poll(observedAt);
379
+ owed = owedOf(journal);
380
+ }
292
381
  const graphReading = graph.read(observedAt);
293
382
  const configReading = config.read(observedAt);
294
383
  const cacheReading = cache.read(observedAt);
@@ -301,11 +390,13 @@ export function createLiveStore(options) {
301
390
  if (metrics.length > STORE_LIMITS.metrics)
302
391
  metrics.shift();
303
392
  lastObservation = observedAt;
304
- const readable = journal.status === "readable" && journal.backlogBytes === 0;
393
+ const readable = isReadable(journal);
305
394
  return {
306
395
  sequence: ++sequence, observedAt, delayed, freshness: errors.length ? "failed" : delayed ? "delayed" : "fresh",
307
- operator: fold.snapshot({ graph: graphReading.status === "readable" ? graphReading.value : undefined, sequence, observedAt, readable, graphAvailability: { status: graphReading.status, error: graphReading.error } }),
308
- journal, graph: graphReading, config: configReading, cache: cacheReading,
396
+ // I1: only this run's own lock naming a provably-dead (ESRCH) holder settles unfinished cells; an alive,
397
+ // EPERM, foreign, unreadable or absent lock is not proof the work stopped.
398
+ operator: fold.snapshot({ graph: graphReading.status === "readable" ? graphReading.value : undefined, sequence, observedAt, readable, graphAvailability: { status: graphReading.status, error: graphReading.error }, daemonDead: lockState === "dead", owed, basis: journalBasis(journal) }),
399
+ owed, journal, graph: graphReading, config: configReading, cache: cacheReading,
309
400
  lock: { ...owner, state: lockState, alive, expired: owner.identity ? observedAt - Number(owner.identity.split(":")[3]) / 1e6 > STALE_MS : undefined },
310
401
  supervision, errors, actionsEnabled: readable && !errors.length && !delayed,
311
402
  viewport, inputSequence, metrics: metrics.slice(),
@@ -0,0 +1,28 @@
1
+ import type { JournalEvent } from "../../run/journal.js";
2
+ import { type JournalLine, type TailSnapshot } from "./live-store.js";
3
+ /** The board uses the daemon's TTY narration, including its suppression and sanitization. */
4
+ export declare const renderLogLines: (events: readonly JournalEvent[], runId: string, columns: number) => string[];
5
+ export interface LogRow {
6
+ line: number;
7
+ text: string;
8
+ }
9
+ export interface LogPage {
10
+ rows: readonly LogRow[];
11
+ error?: string;
12
+ }
13
+ type PageReader = (firstLine: number, count?: number, generation?: number) => readonly JournalLine[];
14
+ interface LogSource {
15
+ journal: TailSnapshot;
16
+ page: PageReader;
17
+ runId: string;
18
+ columns: number;
19
+ }
20
+ /** Demand-page backwards to fill the viewport, including events outside the store's retained tail.
21
+ * Only the requested visible rows survive; suppressed events never consume viewport rows. */
22
+ export declare function readLogPage(source: LogSource, count: number, end?: number): LogPage;
23
+ /** Move by visible narration rows. Undefined means follow the journal tail again. */
24
+ export declare function moveLogEnd(source: LogSource, end: number | undefined, delta: number): number | undefined;
25
+ export declare function LogView({ page }: {
26
+ page: LogPage;
27
+ }): import("react").JSX.Element;
28
+ export {};