tickmarkr 1.83.0 → 1.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/adapters/claude-code.d.ts +1 -0
  2. package/dist/adapters/claude-code.js +57 -1
  3. package/dist/adapters/fake.js +9 -0
  4. package/dist/adapters/types.d.ts +3 -0
  5. package/dist/adapters/types.js +21 -0
  6. package/dist/cli/commands/status.js +160 -30
  7. package/dist/compile/collateral.d.ts +86 -2
  8. package/dist/compile/collateral.js +294 -3
  9. package/dist/config/config.d.ts +62 -0
  10. package/dist/config/config.js +157 -2
  11. package/dist/drivers/herdr.d.ts +20 -3
  12. package/dist/drivers/herdr.js +288 -105
  13. package/dist/gates/baseline.d.ts +1 -0
  14. package/dist/gates/baseline.js +91 -13
  15. package/dist/gates/review.d.ts +7 -0
  16. package/dist/gates/review.js +99 -6
  17. package/dist/gates/run-gates.d.ts +9 -0
  18. package/dist/gates/run-gates.js +285 -41
  19. package/dist/run/daemon.d.ts +48 -2
  20. package/dist/run/daemon.js +1417 -315
  21. package/dist/run/journal.d.ts +56 -3
  22. package/dist/run/journal.js +275 -1
  23. package/dist/run/stall.d.ts +35 -1
  24. package/dist/run/stall.js +118 -8
  25. package/dist/tui/cockpit/components.d.ts +30 -1
  26. package/dist/tui/cockpit/components.js +19 -3
  27. package/dist/tui/cockpit/derive.d.ts +29 -2
  28. package/dist/tui/cockpit/derive.js +219 -23
  29. package/dist/tui/cockpit/layout.d.ts +75 -6
  30. package/dist/tui/cockpit/layout.js +97 -19
  31. package/dist/tui/cockpit/live.d.ts +14 -1
  32. package/dist/tui/cockpit/live.js +223 -29
  33. package/dist/tui/cockpit/pointer.d.ts +261 -0
  34. package/dist/tui/cockpit/pointer.js +610 -0
  35. package/dist/tui/cockpit/run-cockpit.d.ts +36 -5
  36. package/dist/tui/cockpit/run-cockpit.js +270 -51
  37. package/package.json +1 -1
@@ -18,9 +18,23 @@ import { formatKeybarEntries, keybarEntries, SETUP_KEY_BINDINGS, } from "./keys.
18
18
  */
19
19
  import { cellWidth as stringWidth, sliceCells } from "./width.js";
20
20
  const SPARKLINE_GLYPHS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
21
+ /**
22
+ * The hover highlight's non-colour half: one cell wide, drawn in the separator
23
+ * the row already spends, from the block family the cockpit already draws with.
24
+ * Colour is the other half and the louder one, but Chalk suppresses inverse and
25
+ * colour together at level 0, so a highlight made of ink alone does not exist
26
+ * under `NO_COLOR` or a colourless TTY. This is a character; it always does.
27
+ */
28
+ export const HOVER_MARKER = "▌";
21
29
  const BAND_GAP_COLUMNS = 1;
22
30
  const PANEL_HORIZONTAL_CHROME = 4;
23
- const READABLE_PANEL_COLUMNS = 20;
31
+ /**
32
+ * The columns below which a bordered panel no longer reads — the floor a band
33
+ * allocation defends, and the floor planFrame's session resize override clamps
34
+ * the body panel at: a drag surrenders an element whole or not at all, it
35
+ * never collapses one below what reads.
36
+ */
37
+ export const READABLE_PANEL_COLUMNS = 20;
24
38
  export const PANEL_CHROME_ROWS = 3;
25
39
  export const BAND_CONTINUATION_PREFIX = "↳ ";
26
40
  const SETUP_KEYS = keybarEntries(SETUP_KEY_BINDINGS);
@@ -269,6 +283,8 @@ export function KeyRoster({ entries, width, }) {
269
283
  export function StatusStrip({ items, width, }) {
270
284
  return (_jsx(OneLineItems, { width: width, label: "cockpit status", children: items.map((item, index) => (_jsxs(Box, { flexShrink: 0, children: [index > 0 && _jsx(BodyText, { emphasis: "dim", children: " \u00B7 " }), _jsx(StateGlyph, { state: item.state }), _jsxs(BodyText, { children: [" ", item.text] })] }, `${item.state}:${item.text}`))) }));
271
285
  }
272
- export function JournalRowPanel({ rows, width, focused, title = "JOURNAL", selection, }) {
273
- return (_jsx(Panel, { title: title, focused: focused, width: width, children: rows.map((row, index) => (_jsxs(Box, { flexDirection: "row", flexWrap: "nowrap", height: 1, overflow: "hidden", children: [_jsx(BodyText, { emphasis: "dim", children: row.time }), _jsx(BodyText, { children: " " }), _jsx(StateGlyph, { state: row.state }), _jsx(BodyText, { children: " " }), index === selection && (_jsxs(BodyText, { emphasis: "strong", children: [GLYPHS.pointer, " "] })), _jsx(BodyText, { children: row.text })] }, row.id))) }));
286
+ export function JournalRowPanel({ rows, width, focused, title = "JOURNAL", selection, hover, }) {
287
+ return (_jsx(Panel, { title: title, focused: focused, width: width, children: rows.map((row, index) => (_jsxs(Box, { flexDirection: "row", flexWrap: "nowrap", height: 1, overflow: "hidden", children: [_jsx(BodyText, { emphasis: "dim", children: row.time }), _jsx(BodyText, { children: " " }), _jsx(StateGlyph, { state: row.state }), _jsx(BodyText, { emphasis: index === hover ? "strong" : "normal", children: index === hover ? HOVER_MARKER : " " }), index === selection && (_jsxs(BodyText, { emphasis: "strong", children: [GLYPHS.pointer, " "] })), index === hover
288
+ ? (_jsx(Text, { inverse: true, color: `ansi256(${COCKPIT_DATA_RAMP[0].xterm})`, children: row.text }))
289
+ : _jsx(BodyText, { children: row.text })] }, row.id))) }));
274
290
  }
@@ -3,7 +3,14 @@ import type { RunViewId } from "./views.js";
3
3
  export declare const SPARKLINE_BUCKET_WINDOW = 12;
4
4
  export declare function selectSparklineBucketWidthMs(elapsedMs: number): number;
5
5
  type MetricSample = number | null;
6
- export type RunStatus = "done" | "failed" | "interrupted" | "running";
6
+ /**
7
+ * What the run is, in one word. `parked` is first-class beside the rest because
8
+ * a run holding an unresolved `human` task owes the operator a verb — it is not
9
+ * done, and calling it done is the render OBS-252 caught. The task-level word
10
+ * for the same fact stays `human` (TaskState): that is the event the journal
11
+ * writes, and `parked` is what the fold of it means for the run.
12
+ */
13
+ export type RunStatus = "done" | "failed" | "interrupted" | "parked" | "running";
7
14
  export type RunCockpitData = {
8
15
  readonly binaryVersion: string;
9
16
  readonly runId: string;
@@ -80,12 +87,30 @@ export type TaskRow = {
80
87
  readonly attempts?: number;
81
88
  readonly actor?: string;
82
89
  readonly lastEventTime?: string;
90
+ /** Complete UTC instant paired with `lastEventTime`; additive for existing consumers. */
91
+ readonly lastEventTimestamp?: string;
83
92
  readonly title?: string;
93
+ /**
94
+ * The park the task is standing in right now, as the daemon recorded it
95
+ * (`task-human` data.kind). A resolved park has no kind at all: the badge
96
+ * retires with the state it belonged to, so a merged task never wears the
97
+ * `gate-fail` it was released from an hour earlier (OBS-256).
98
+ */
99
+ readonly parkKind?: string;
100
+ /**
101
+ * Recorded merged, and only then. A task the journal carried to `done` has
102
+ * finished its work; a task the journal recorded a `merge` for has landed.
103
+ * The two are different rows, so a consumer drawing a check-mark has the fact
104
+ * that earns one rather than having to read `done` as if it were the same.
105
+ */
106
+ readonly merged?: true;
84
107
  };
85
108
  /** One recorded gate result. `details` is the record's own text, verbatim. */
86
109
  export type GateRow = {
87
110
  readonly id: string;
88
111
  readonly time: string;
112
+ /** Complete UTC instant paired with `time`; additive for existing consumers. */
113
+ readonly timestamp?: string;
89
114
  readonly state: ComponentState;
90
115
  readonly gate?: string;
91
116
  readonly taskId?: string;
@@ -99,8 +124,10 @@ export type FleetRow = {
99
124
  readonly model: string;
100
125
  readonly dispatches?: number;
101
126
  readonly lastEventTime?: string;
127
+ /** Complete UTC instant paired with `lastEventTime`; additive for existing consumers. */
128
+ readonly lastEventTimestamp?: string;
102
129
  };
103
- export type TaskState = "done" | "failed" | "human" | "pending" | "running" | "interrupted";
130
+ export type TaskState = "completed" | "done" | "failed" | "human" | "pending" | "running" | "interrupted";
104
131
  /**
105
132
  * The source identities the named view's rows carry, in draw order. Selection
106
133
  * is repaired against these, so it follows a row across a refresh instead of
@@ -116,6 +116,75 @@ function daemonPid(events) {
116
116
  }
117
117
  return undefined;
118
118
  }
119
+ /**
120
+ * The state an event writes onto the task it names — the whole list of them, so
121
+ * "did anything move this task" is asked in one place. `task-dispatch` and
122
+ * `merge` carry more than a state and are folded by hand below; they are named
123
+ * here because they move one all the same.
124
+ *
125
+ * A Map, because an event name is whatever the journal wrote: an object's own
126
+ * lookup answers `constructor` and `toString` with something inherited and
127
+ * truthy, so a line naming one would masquerade as a state transition — writing
128
+ * a function where a TaskState belongs and releasing a park the run declared.
129
+ * A Map answers only for the keys stated here.
130
+ */
131
+ const TASK_STATE_BY_EVENT = new Map([
132
+ ["task-dispatch", "running"],
133
+ ["merge", "done"],
134
+ ["task-done", "done"],
135
+ ["task-failed", "failed"],
136
+ ["task-human", "human"],
137
+ ["task-approved", "pending"],
138
+ ]);
139
+ /** Only task identities the summary actually records can enter its fold. */
140
+ function summaryTaskIds(value) {
141
+ return Array.isArray(value)
142
+ ? value.filter((id) => typeof id === "string")
143
+ : [];
144
+ }
145
+ /**
146
+ * The parks the latest `run-end` says the daemon stopped holding, that nothing
147
+ * since resolved. The summary is the daemon's own closing statement, so a run
148
+ * whose end names a task in `human` owes the operator a verb whether or not a
149
+ * `task-human` line happens to precede it — that dependence is what let a run
150
+ * end read `done` over a parked task (OBS-252).
151
+ *
152
+ * "Since" is measured from the summary forward, and only forward. The summary
153
+ * describes the moment the daemon exited, so a state written *before* it —
154
+ * the dispatch it parked, the completion of a round it then held for review —
155
+ * is history the summary already accounts for and cannot answer it. Weighing
156
+ * the whole-journal fold against it let a line above the summary retire a park
157
+ * the summary itself declares: `run-start · task-dispatch T1 · run-end
158
+ * human:[T1]` read `interrupted`, and the same journal with a `task-done T1`
159
+ * read `done` — OBS-252 again, one line lower.
160
+ *
161
+ * So a name is resolved only when a later event moved that task: approved,
162
+ * redispatched, finished, landed. Nothing later — including a task the record
163
+ * never mentions at all — leaves the park standing, because an uncorroborated
164
+ * park is still the run's own claim that it is waiting and withholding `done`
165
+ * on a claim the surface cannot check is the fail-closed reading. It stays a
166
+ * reading of the run and of the tasks it names: a summary is a journal event,
167
+ * so its explicit task outcomes are facts the board must count and draw even
168
+ * when the more detailed task event is absent.
169
+ */
170
+ function unresolvedSummaryParks(events) {
171
+ for (let index = events.length - 1; index >= 0; index -= 1) {
172
+ const event = events[index];
173
+ if (event.event !== "run-end")
174
+ continue;
175
+ const parked = Array.isArray(event.data.human) ? event.data.human : [];
176
+ const released = new Map();
177
+ for (const later of events.slice(index + 1)) {
178
+ if (later.taskId === undefined)
179
+ continue;
180
+ if (!TASK_STATE_BY_EVENT.has(later.event))
181
+ continue;
182
+ released.set(later.taskId, later.event !== "task-human");
183
+ }
184
+ return parked.filter((id) => typeof id === "string" && released.get(id) !== true);
185
+ }
186
+ return [];
187
+ }
119
188
  function deriveTasks(events, interrupted) {
120
189
  const tasks = new Map();
121
190
  const task = (taskId, index, ts) => {
@@ -126,6 +195,8 @@ function deriveTasks(events, interrupted) {
126
195
  id: taskId,
127
196
  dispatches: [],
128
197
  state: undefined,
198
+ parkKind: undefined,
199
+ merged: false,
129
200
  lastIndex: index,
130
201
  lastTs: ts,
131
202
  phase: "pending",
@@ -134,6 +205,30 @@ function deriveTasks(events, interrupted) {
134
205
  return created;
135
206
  };
136
207
  for (const [index, event] of events.entries()) {
208
+ if (event.event === "run-end") {
209
+ // The closing summary is a recorded fold, not commentary. Apply all
210
+ // task outcomes it owns before later resume events get their ordinary
211
+ // chance to supersede them. Failure comes last so a malformed overlap
212
+ // fails closed instead of repainting a failed task done.
213
+ const summaries = [
214
+ [event.data.done, "done"],
215
+ [event.data.human, "human"],
216
+ [event.data.failed, "failed"],
217
+ ];
218
+ for (const [ids, state] of summaries) {
219
+ for (const taskId of summaryTaskIds(ids)) {
220
+ const fact = task(taskId, index, event.ts);
221
+ fact.state = state;
222
+ // A summary does not invent a park kind; it preserves one only when
223
+ // it confirms that the same park still stands.
224
+ if (state !== "human")
225
+ fact.parkKind = undefined;
226
+ fact.lastIndex = index;
227
+ fact.lastTs = event.ts;
228
+ }
229
+ }
230
+ continue;
231
+ }
137
232
  if (!event.taskId)
138
233
  continue;
139
234
  if (event.event === "task-dispatch") {
@@ -148,11 +243,25 @@ function deriveTasks(events, interrupted) {
148
243
  assignment: assignmentFrom(event.data.assignment),
149
244
  });
150
245
  fact.state = "running";
246
+ fact.parkKind = undefined;
151
247
  fact.phase = "worker";
152
248
  fact.lastIndex = index;
153
249
  fact.lastTs = event.ts;
154
250
  continue;
155
251
  }
252
+ if (event.event === "merge") {
253
+ const fact = task(event.taskId, index, event.ts);
254
+ fact.merged = true;
255
+ // A merge is a landing, and it is a state the journal recorded: the task
256
+ // is done, and whatever park it was released from is retired by the merge
257
+ // itself rather than by whichever event happened to precede it. Nothing
258
+ // here depends on a `task-done` line arriving first (OBS-256).
259
+ fact.state = "done";
260
+ fact.parkKind = undefined;
261
+ fact.lastIndex = index;
262
+ fact.lastTs = event.ts;
263
+ continue;
264
+ }
156
265
  if (event.event === "phase-start") {
157
266
  const fact = task(event.taskId, index, event.ts);
158
267
  fact.phase = typeof event.data.phase === "string" ? event.data.phase : fact.phase;
@@ -160,15 +269,19 @@ function deriveTasks(events, interrupted) {
160
269
  fact.lastTs = event.ts;
161
270
  continue;
162
271
  }
163
- const state = {
164
- "task-done": "done",
165
- "task-failed": "failed",
166
- "task-human": "human",
167
- "task-approved": "pending",
168
- }[event.event];
272
+ // `task-dispatch` and `merge` returned above; the rest write their state and
273
+ // nothing else. One list, read from one place, so a new state-writing event
274
+ // cannot move the fold without also moving what resolves a park.
275
+ const state = TASK_STATE_BY_EVENT.get(event.event);
169
276
  if (state) {
170
277
  const fact = task(event.taskId, index, event.ts);
171
278
  fact.state = state;
279
+ // The park kind travels with the park: `task-human` records it, and the
280
+ // next recorded state — approved, done, failed — retires it with the park
281
+ // it named. A resolved state must not leave its badge behind (OBS-256).
282
+ fact.parkKind = state === "human" && typeof event.data.kind === "string"
283
+ ? event.data.kind
284
+ : undefined;
172
285
  fact.lastIndex = index;
173
286
  fact.lastTs = event.ts;
174
287
  }
@@ -250,14 +363,42 @@ function recordsFailure(event) {
250
363
  || event.data.pass === false
251
364
  || event.data.ok === false
252
365
  || (typeof event.data.exitCode === "number" && event.data.exitCode !== 0)
253
- || (event.event === "run-end" && event.data.tipVerify === "failed");
366
+ || (event.event === "run-end"
367
+ && (event.data.tipVerify === "failed"
368
+ || summaryTaskIds(event.data.failed).length > 0));
254
369
  }
370
+ /**
371
+ * The outcome an event records as a success. `merge` is here and `task-done` is
372
+ * not: a merge records the landing, and a completion records only that the
373
+ * worker stopped working — the gates that judge it and the merge that lands it
374
+ * come after. A `run-end` counts only when its tip verification reads passed:
375
+ * an absent verdict is nothing verified, and a check-mark on nothing verified
376
+ * is the run claiming a pass tickmarkr never corroborated — the same
377
+ * fail-closed reading the status strip already draws (OBS-244), now on the row.
378
+ */
255
379
  function recordsSuccess(event) {
256
- return event.event === "task-done"
257
- || event.event === "merge"
380
+ return event.event === "merge"
258
381
  || event.data.pass === true
259
382
  || event.data.ok === true
260
- || (event.event === "run-end" && event.data.tipVerify !== "failed");
383
+ || (event.event === "run-end" && event.data.tipVerify === "passed");
384
+ }
385
+ /** The newest run-end speaks for the run; spotlight is context, not outcome. */
386
+ function runPresentation(runStatus, spotlight) {
387
+ switch (runStatus) {
388
+ case "failed":
389
+ return { state: "fail", word: "failed" };
390
+ case "parked":
391
+ return { state: "warn", word: "parked" };
392
+ case "interrupted":
393
+ return { state: "warn", word: "interrupted" };
394
+ case "running":
395
+ return { state: "neutral", word: "running" };
396
+ case "done":
397
+ // A task named beside the run still earns a check only from its merge.
398
+ return spotlight === undefined || spotlight.merged
399
+ ? { state: "pass", word: "pass" }
400
+ : { state: "neutral", word: "done" };
401
+ }
261
402
  }
262
403
  /**
263
404
  * A row depicts the event it names: the event's own recorded outcome decides
@@ -268,11 +409,25 @@ function recordsSuccess(event) {
268
409
  * Every state carries its word, so meaning never rides on hue alone.
269
410
  */
270
411
  function eventPresentation(event, task) {
271
- if (event.event === "escalation" || event.event === "task-human") {
412
+ if (event.event === "escalation")
272
413
  return { state: "warn", word: "warn" };
273
- }
414
+ // A park is a state, not a severity: the row says what the engagement is
415
+ // waiting for, which is the operator.
416
+ if (event.event === "task-human")
417
+ return { state: "warn", word: "parked" };
274
418
  if (recordsFailure(event))
275
419
  return { state: "fail", word: "fail" };
420
+ if (event.event === "run-end"
421
+ && summaryTaskIds(event.data.human).length > 0) {
422
+ return { state: "warn", word: "parked" };
423
+ }
424
+ // Work finished is not work landed. `task-done` records the first, so its row
425
+ // says `done` and carries no check: the check-mark belongs to the `merge` that
426
+ // records the landing, and until the journal writes one there is nothing to
427
+ // put it on (OBS-256). This is the same reading `taskPresentation` gives the
428
+ // task itself, stated here for the event because a row depicts its own event.
429
+ if (event.event === "task-done")
430
+ return { state: "neutral", word: "done" };
276
431
  if (recordsSuccess(event))
277
432
  return { state: "pass", word: "pass" };
278
433
  if (task?.state === "interrupted") {
@@ -282,19 +437,29 @@ function eventPresentation(event, task) {
282
437
  }
283
438
  function historyRow(event, tasks, spotlight, newest, runStatus) {
284
439
  const task = event.taskId === undefined ? undefined : tasks.get(event.taskId);
285
- const { state, word } = eventPresentation(event, task);
440
+ // Spotlight is context only for the taskless closing summary. A task-owned
441
+ // newest event keeps its own identity and outcome: T2's dispatch or failure
442
+ // cannot become a pass for merged T1 merely because T1 wins the spotlight.
443
+ const summary = newest && event.event === "run-end"
444
+ ? runPresentation(runStatus, spotlight)
445
+ : undefined;
446
+ const { state, word } = summary ?? eventPresentation(event, task);
447
+ const displayedTask = newest
448
+ ? event.taskId === undefined ? spotlight : task
449
+ : task;
286
450
  // The spotlight entry must keep the task, its attempt and its acting adapter
287
451
  // inside the narrowest band's width budget — that budget cannot pay for the
288
452
  // separators and the event name, so the state word and the run's status
289
453
  // (what the newest event recorded) ride after the adapter instead.
290
- const text = newest && spotlight !== undefined
291
- ? `${spotlight.id} attempt ${taskAttempt(spotlight)} ${taskActor(spotlight)} ${word}${word === runStatus ? "" : ` ${runStatus}`}`
454
+ const text = newest && displayedTask !== undefined
455
+ ? `${displayedTask.id} attempt ${taskAttempt(displayedTask)} ${taskActor(displayedTask)} ${word}${word === runStatus ? "" : ` ${runStatus}`}`
292
456
  : task !== undefined
293
457
  ? `${task.id} ${word} · ${event.event}`
294
458
  : `${word} · ${event.event}`;
295
459
  return {
296
460
  id: `event:${event.line}`,
297
461
  time: eventTime(event.ts),
462
+ timestamp: event.ts,
298
463
  state,
299
464
  text,
300
465
  };
@@ -332,14 +497,28 @@ function taskRows(events, tasks, graph) {
332
497
  // zero when a run resumes, so a task dispatched 0,1,0 was tried three times
333
498
  // and reporting its last label would say one.
334
499
  const attempts = fact?.dispatches.length ?? 0;
500
+ // `task-done` records completion; only `merge` records landing. The fixed
501
+ // Tasks-view consumer maps `done` to a check, so the derived row must keep
502
+ // an unmerged completion in the neutral `completed` state and reserve
503
+ // `done` for a fact whose merge the journal actually carries.
504
+ const state = fact?.state === "done" && fact.merged !== true
505
+ ? "completed"
506
+ : fact?.state;
335
507
  return {
336
508
  id: `task:${id}`,
337
509
  taskId: id,
338
- ...(fact?.state === undefined ? {} : { state: fact.state }),
510
+ ...(state === undefined ? {} : { state }),
339
511
  ...(attempts === 0 ? {} : { attempts }),
340
512
  ...(assignment === undefined ? {} : { actor: channelKey(assignment) }),
341
513
  ...(lastTs === undefined ? {} : { lastEventTime: eventTime(lastTs) }),
514
+ ...(lastTs === undefined ? {} : { lastEventTimestamp: lastTs }),
342
515
  ...(title === undefined ? {} : { title }),
516
+ // Only while the park stands: the fold cleared it the moment the journal
517
+ // recorded what the park resolved to.
518
+ ...(fact?.parkKind === undefined ? {} : { parkKind: fact.parkKind }),
519
+ // Landing is its own fact. `done` is work finished; only a recorded merge
520
+ // is work landed, and only that earns a check-mark.
521
+ ...(fact?.merged === true ? { merged: true } : {}),
343
522
  };
344
523
  });
345
524
  }
@@ -356,6 +535,7 @@ function gateRows(events) {
356
535
  return [{
357
536
  id: `gate:${event.line}`,
358
537
  time: eventTime(event.ts),
538
+ timestamp: event.ts,
359
539
  state: pass === true ? "pass" : pass === false ? "fail" : "neutral",
360
540
  ...(typeof event.data.gate === "string" ? { gate: event.data.gate } : {}),
361
541
  ...(event.taskId === undefined ? {} : { taskId: event.taskId }),
@@ -421,6 +601,7 @@ function fleetRows(events) {
421
601
  model: channel.model,
422
602
  ...(channel.dispatches === 0 ? {} : { dispatches: channel.dispatches }),
423
603
  lastEventTime: eventTime(channel.lastTs),
604
+ lastEventTimestamp: channel.lastTs,
424
605
  }));
425
606
  }
426
607
  /**
@@ -535,15 +716,30 @@ export function deriveRunCockpitData(source, binaryVersion, options = {}) {
535
716
  const tipPassed = tipVerificationPassed(events);
536
717
  const spotlight = spotlightTask(tasks);
537
718
  const samples = bucketedMetricSamples(events);
719
+ // A task the journal carried somewhere other than done — approved and awaiting
720
+ // the resume that redispatches it, most of all. `done` is a claim about all of
721
+ // them, so one outstanding task retires it.
722
+ const outstanding = taskFacts.some((task) => task.state !== undefined && task.state !== "done");
723
+ // Parked outranks every reading but failure: a run holding an unresolved
724
+ // `human` task owes the operator a verb, and the daemon's exit after a park is
725
+ // the park's own consequence — not an interruption, and never done. A run that
726
+ // also failed a task owes a fix first, so failure still leads (OBS-252).
727
+ //
728
+ // The word is owed on two readings, not one: the parks the fold holds, and
729
+ // the parks the latest run-end names and nothing since resolved. The second
730
+ // is what keeps the reading off the luck of a `task-human` line preceding the
731
+ // run-end — the run's own closing statement is enough.
538
732
  const runStatus = failed > 0
539
733
  ? "failed"
540
- : lifecycle === "superseded" || hasInterruptedTask
541
- ? "interrupted"
542
- : lifecycle === "completed"
543
- ? "done"
544
- : interrupted
545
- ? "interrupted"
546
- : "running";
734
+ : human > 0 || unresolvedSummaryParks(events).length > 0
735
+ ? "parked"
736
+ : lifecycle === "superseded" || hasInterruptedTask
737
+ ? "interrupted"
738
+ : lifecycle === "completed"
739
+ ? outstanding ? "interrupted" : "done"
740
+ : interrupted
741
+ ? "interrupted"
742
+ : "running";
547
743
  return {
548
744
  binaryVersion,
549
745
  runId: source.fileName.replace(/(?:\.interrupted)?\.journal\.jsonl$/, ""),
@@ -87,11 +87,40 @@ export interface FrameState {
87
87
  detail?: boolean;
88
88
  /** Header caption, pre-measured in terminal cells by the caller. */
89
89
  captionCells?: number;
90
+ /**
91
+ * The session's panel resize override: the rail columns the operator dragged
92
+ * the boundary to. An INPUT to the plan, never a bypass of it — the plan
93
+ * recomputes from the measured size plus this override and the renderer
94
+ * draws the recomputed plan unmodified. It lives only in the running
95
+ * process: nothing writes it anywhere, and a relaunch plans the default
96
+ * layout. The plan clamps it at both panels' readable floors — see
97
+ * `planRailColumns`.
98
+ */
99
+ railColumns?: number;
90
100
  }
91
101
  export declare const FRAME_WIDTH_FLOOR = 40;
92
102
  export declare const FRAME_HEIGHT_FLOOR = 14;
93
103
  export declare const SIDEBAR_COLUMN_FLOOR = 64;
94
104
  export declare const RAIL_COLUMNS = 15;
105
+ /**
106
+ * The narrowest rail that still reads: the selection marker, one space and the
107
+ * longest view name, whole — the name measured in display cells by the width
108
+ * authority, never String length, so a name that ever gains a wide glyph moves
109
+ * the floor with it. A drag never takes the rail below it — below the floor
110
+ * the element stands at the floor, surrendered whole per the contract, never
111
+ * collapsed.
112
+ */
113
+ export declare const RAIL_READABLE_FLOOR_COLUMNS: number;
114
+ /**
115
+ * The rail width the plan draws for a session resize override, clamped at both
116
+ * panels' readable floors: the rail never narrows past its own floor, and
117
+ * never widens past what leaves the body its readable floor
118
+ * (`READABLE_PANEL_COLUMNS`). The clamp is the plan's — the override is a
119
+ * request, the floors are the contract, and an element is surrendered whole or
120
+ * not at all, never collapsed by a drag. No override is the contract's
121
+ * constant rail.
122
+ */
123
+ export declare function planRailColumns(columns: number, override?: number): number;
95
124
  export declare const STANDARD_TAIL_HEIGHT = 16;
96
125
  export declare const FULL_TAIL_HEIGHT = 24;
97
126
  export declare const TAIL_STANDARD_ROWS = 1;
@@ -127,7 +156,24 @@ export interface FrameFit {
127
156
  * ordering decision — not a comment about one.
128
157
  */
129
158
  export declare const FRAME_SURRENDER_FIT: Readonly<Record<SurrenderedRegionId, (fit: FrameFit) => boolean>>;
130
- export type FrameRegionId = "header" | "rule" | "strip" | "rail" | "body" | "rule2" | "tail" | "status" | "keybar" | "caption";
159
+ export type FrameRegionId = "header" | "rule" | "strip" | "rail" | "body" | "items" | "rule2" | "tail" | "status" | "keybar" | "caption";
160
+ /**
161
+ * Border plus horizontal padding the drawn panel charges around its body —
162
+ * half of it on each side. The plan owns this because the plan owns the item
163
+ * rows: the paint draws its list into the region planned below, and nothing
164
+ * downstream subtracts chrome of its own to find it again.
165
+ */
166
+ export declare const RUN_PANEL_HORIZONTAL_CHROME = 4;
167
+ /**
168
+ * Row bands drawn INSIDE another band rather than beside it, mapped to the band
169
+ * that hosts them. A nested band refines its host's rows, so the band walk skips
170
+ * it when tiling the height and asserts containment instead. It is no less a
171
+ * member of `rowSpans` for that: the oracle pins its span against the planned
172
+ * region, and demands the paint register a node at exactly that rectangle —
173
+ * which is precisely what a parallel field beside the plan could never be held
174
+ * to.
175
+ */
176
+ export declare const FRAME_NESTED_ROW_BANDS: Readonly<Partial<Record<FrameRegionId, FrameRegionId>>>;
131
177
  /** The vitals elements in block order, top first; a short rail surrenders from the top, so the meter is the last to go. */
132
178
  export declare const SIDEBAR_VITALS_ORDER: readonly ["tasks", "gates", "meter"];
133
179
  export type SidebarVitalsElement = (typeof SIDEBAR_VITALS_ORDER)[number];
@@ -139,6 +185,13 @@ export declare const SIDEBAR_VITALS_COMPACT_ELEMENT_ROWS = 1;
139
185
  export interface SidebarPlan {
140
186
  /** Rows the menu block occupies at the rail's top: the five views, plus the label row when it fits. */
141
187
  menuRows: number;
188
+ /**
189
+ * The frame row each view's name is drawn on. The plan does the menu's
190
+ * arithmetic once, here, where the label row is decided — so a rail hit is a
191
+ * lookup in the plan's own rows rather than a second reconstruction of the
192
+ * menu's shape from `menuRows`.
193
+ */
194
+ viewRows: Readonly<Record<FrameView, number>>;
142
195
  /**
143
196
  * Blank rows between the last menu row and the first vitals row. Never zero
144
197
  * while any vitals element draws — a short rail surrenders whole vitals
@@ -157,8 +210,14 @@ export interface SidebarPlan {
157
210
  * one gap row is the vitals budget, spent on the full two-row block when it
158
211
  * fits whole, else on the one-row elements, surrendered from the top of the
159
212
  * block with the meter surrendered last. An element draws whole or not at all.
213
+ * `railRow` is the frame row the rail band starts at, so the view rows the plan
214
+ * reports are frame rows a reported cell is compared against directly. It is
215
+ * required for that reason: a default here would answer rail-relative rows in a
216
+ * field documented as frame rows — the same defaulted-measurement defect the key
217
+ * delivery carried, and this one would land in the rows a rail hit resolves
218
+ * through.
160
219
  */
161
- export declare function planSidebar(railRows: number): SidebarPlan;
220
+ export declare function planSidebar(railRows: number, railRow: number): SidebarPlan;
162
221
  export interface FrameRegion {
163
222
  id: FrameRegionId;
164
223
  row: number;
@@ -182,8 +241,11 @@ export interface PlannedFrame {
182
241
  content: FrameBandContent;
183
242
  regions: readonly FrameRegion[];
184
243
  /**
185
- * Row bands, top to bottom; their spans tile the height exactly. A band
186
- * map: the nested caption sub-region is deliberately excluded.
244
+ * Row bands, top to bottom. `items` is nested inside the body band and so
245
+ * refines it rather than tiling beside it (see FRAME_NESTED_ROW_BANDS); the
246
+ * remaining spans tile the height exactly. The caption is excluded: it adds
247
+ * no row extent of its own, being the header's own row refined on the column
248
+ * axis, and the column map already carries no row.
187
249
  */
188
250
  rowSpans: Readonly<Record<string, number>>;
189
251
  /**
@@ -217,7 +279,9 @@ export type FramePlan = PlannedFrame | PlainFallbackPlan;
217
279
  * height exactly — header, rule, the view strip (40–63 columns), the body
218
280
  * band, a second rule, the journal tail surrendered whole by height tier,
219
281
  * status, keybar — and, at ≥64 columns, column bands that tile the width
220
- * exactly: the constant 15-column rail beside the body. The body is the sole
282
+ * exactly: the rail beside the body, at the constant 15 columns or at the
283
+ * session's dragged override clamped to both panels' readable floors
284
+ * (`planRailColumns`). The body is the sole
221
285
  * flexible element on both axes. The view and state decide what the flexible
222
286
  * bands draw (the Journal view is the full-height tail; SETUP turns the tail
223
287
  * into PENDING WRITES; an open detail owns the body) without moving any
@@ -231,7 +295,12 @@ export type FramePlan = PlannedFrame | PlainFallbackPlan;
231
295
  * of the block, the meter last, rather than closing the gap. rowSpans and
232
296
  * columnSpans are band maps: they deliberately exclude the nested caption,
233
297
  * and column bands are reported for the body band only, so each map's spans
234
- * sum to the full height and width respectively.
298
+ * sum to the full height and width respectively. The body band's item rows are
299
+ * a band of the plan in their own right — a region in `regions` and a span in
300
+ * `rowSpans`, nested inside the body band rather than tiling beside it — so the
301
+ * region the paint draws its list into and the region a pointer resolves a row
302
+ * through are the one thing the oracle pins, and neither subtracts the drawn
303
+ * panel's chrome for itself.
235
304
  */
236
305
  export declare function planFrame(size: MeasuredSize, view?: FrameView, state?: FrameState): FramePlan;
237
306
  export declare function resolveCockpitLayout(columns: number, rows: number): CockpitLayout;